为什么在.NET中不能为结构体定义默认构造函数?

302
在.NET中,值类型(C#struct)不能有没有参数的构造函数。根据这篇文章,这是CLI规范所规定的。实际上为每个值类型创建了一个默认构造函数(由编译器创建?),它将所有成员初始化为零(或null)。
为什么不允许定义这样的默认构造函数呢?
其中一个微不足道的用途是有理数:
public struct Rational {
    private long numerator;
    private long denominator;

    public Rational(long num, long denom)
    { /* Todo: Find GCD etc. */ }

    public Rational(long num)
    {
        numerator = num;
        denominator = 1;
    }

    public Rational() // This is not allowed
    {
        numerator = 0;
        denominator = 1;
    }
}

使用当前版本的C#,默认的有理数是0/0,这并不太好。

PS: 默认参数是否能帮助解决C# 4.0的问题,或CLR定义的默认构造函数会被调用?


Jon Skeet回答:

以您的示例为例,当某人执行以下操作时,您想要发生什么:

 Rational[] fractions = new Rational[1000];

它应该运行1000次吗?

当然可以,这就是我首先编写默认构造函数的原因。当没有显式默认构造函数时,CLR应该使用默认的清零构造函数;因此您只需为所使用的部分付费。然后,如果我想要一个包含1000个非默认Rational的容器(并且想要优化掉这1000个构造),我将使用List<Rational>而不是数组。

在我看来,这个原因还不足以阻止定义默认构造函数。


6
我曾经遇到过类似的问题,最后将结构体转换为类。 - Dirk Vollmar
4
C#4中的默认参数无法帮助,因为Rational()调用了无参构造函数而不是Rational(long num=0, long denom=1) - LaTeX
7
请注意,在 Visual Studio 2015 中附带的 C# 6.0 版本中,允许为结构体编写零参数实例构造函数。因此,如果存在构造函数,则new Rational()将调用该构造函数;但是,如果不存在构造函数,则new Rational()将等同于default(Rational)。无论如何,建议在您想要使用结构体的“零值”(在您提出的Rational设计中是一个“坏”的数字)时使用语法default(Rational)。值类型T的默认值始终为default(T)。因此,new Rational[1000]将永远不会调用结构体构造函数。 - Jeppe Stig Nielsen
8
为了解决这个特定问题,你可以在结构体中存储“分母减1”的值,这样默认值就会变为0/1。 - miniBill
5
如果我想要一个包含1000个非默认有理数的容器(并希望优化掉这1000个构造),我会使用List<Rational>而不是数组。你为什么期望数组会调用一个不同于List的结构体构造函数? - mjwills
显示剩余8条评论
13个回答

220

注意:下面的答案是在C# 6之前写的,C# 6计划引入在结构体中声明无参数构造函数的能力 - 但它们仍然不会在所有情况下被调用(例如用于数组创建)(最终这个特性没有被添加到C# 6)...但后来它在C# 10中被添加了 - 但有一些限制,所以不能期望构造函数在每种情况下都运行。


编辑:由于Grauenwolf对CLR的见解,我已经编辑了下面的答案。

CLR允许值类型具有无参构造函数,但C#不允许。我认为这是因为它会引入一个预期,即构造函数将在不需要时被调用。例如,请考虑以下情况:

MyStruct[] foo = new MyStruct[1000];

CLR能够通过分配适当的内存并将其全部清零来高效执行此操作。如果它必须运行MyStruct构造函数1000次,那将效率低下得多。(事实上,如果你有一个无参数构造函数,在创建数组时或者在有未初始化实例变量时,它不会被执行。)

C#中的基本规则是“任何类型的默认值都不能依赖于任何初始化”。他们本可以允许定义无参数构造函数,但在所有情况下都不要求执行该构造函数-但这会导致更多的混乱。(至少我相信这个论点如此。)

编辑:使用您的示例,当有人这样做时,您希望发生什么:

Rational[] fractions = new Rational[1000];

你的构造函数应该运行1000次吗?

  • 如果不是,我们最终会得到1000个无效的有理数。
  • 如果是的话,那么如果我们要用实际值填充数组,我们可能会浪费大量的工作。

编辑:(回答问题的更多部分)编译器不会创建无参数的构造函数。就CLR而言,值类型不需要构造函数-尽管事实证明,如果你在IL中编写它,它确实可以。当你在C#中写"new Guid()"时,它会生成与调用普通构造函数时不同的IL代码。关于这一方面,请参阅这个SO问题

怀疑框架中没有任何带有无参数构造函数的值类型。毫无疑问,如果我足够友好地询问NDepend,它会告诉我... C#禁止这样做已经足够让我认为这可能是一个坏主意。


9
在C++中,结构体(struct)和类(class)本质上是同一种东西的两个不同方面。唯一真正的区别在于,一个默认为公共(public),而另一个默认为私有(private)。在.Net中,结构体和类之间存在更大的差异,因此了解这一点非常重要。 - Joel Coehoorn
46
@Joel: 但这并没有很好地解释这个特定的限制,是吗? - Jon Skeet
8
CLR允许值类型拥有无参数的构造函数。是的,它会对数组中的每个元素都运行一次。C#认为这是一个不好的想法,因此不允许它,但您可以编写一个允许该功能的.NET语言。 - Jonathan Allen
3
抱歉,我对下面的内容有些困惑。如果Rational是一个类而不是一个结构体,那么Rational[] fractions = new Rational[1000];是否也会浪费很多工作?如果是这样,为什么类有默认构造函数? - kiss my armpit
5
您需要更具体地说明“浪费大量工作”的含义。但无论如何,它也不会调用构造函数1000次。如果Rational是一个类,您最终将得到一个由1000个空引用组成的数组。 - Jon Skeet
显示剩余15条评论

53

结构体是值类型,值类型在声明时必须具有默认值。

MyClass m;
MyStruct m2;

如果您上面声明了两个字段而没有实例化它们,然后断点调试,m将为null,但m2不会。鉴于此,一个无参数的构造函数是毫无意义的,事实上,结构体上的任何构造函数都只是分配值,本身已经通过声明存在。实际上,m2可以在上面的示例中被很好地使用,并调用其方法(如果有)以及操作其字段和属性!


3
不确定为什么有人对你进行了投票。你似乎是这里最正确的答案。 - pipTheGeek
16
在C++中,如果一个类型有默认构造函数,那么当创建此类对象时没有显式调用构造函数时会使用默认构造函数。在C#中可以利用这个特性来使用默认构造函数初始化m2,因此这个答案不太有帮助。 - Motti
3
如果你不想让结构体在声明时调用它们自己的构造函数,那就不要定义这样的默认构造函数!:) 这是Motti的说法。 - Stefan Monov
8
@Tarik 我不同意你的观点。相反,一个无参构造函数是很有意义的:如果我想创建一个“矩阵”结构体,它总是具有身份矩阵作为默认值,那么除此以外还有其他方法吗? - Elo
1
我不确定我完全同意 "实际上m2可以很愉快地使用.."。在以前的C#中可能是正确的,但现在声明一个结构体而不是new它,然后尝试使用其成员将导致编译器错误。 - Caius Jard
显示剩余4条评论

23
你可以创建一个静态属性,用于初始化并返回一个默认的“有理数”:
public static Rational One => new Rational(0, 1); 

并像这样使用:

var rat = Rational.One;

30
在这种情况下,“Rational.Zero”可能会更加清晰明了。 - Kevin

14

简短解释:

C++中,结构体(struct)和类(class)本质上是相同的。唯一的区别在于一个默认为公有(public),而另一个则为私有(private)。

.NET中,结构体和类之间有更大的区别。主要的区别在于结构体提供值类型语义,而类提供引用类型语义。当您开始考虑这种变化的影响时,其他的变化也开始变得更加合理,包括您所描述的构造函数行为。


8
请更明确地阐述这是如何透过值类型和引用类型分割来实现的,我不太理解。 - Motti
值类型具有默认值-即使您没有定义构造函数,它们也不为null。虽然乍一看这并不排除定义默认构造函数的可能性,但框架使用此功能来对结构进行某些假设。 - Joel Coehoorn
@annakata:其他构造函数在涉及反射的某些情况下可能很有用。此外,如果泛型被增强以允许参数化的“new”约束,则具有符合它们的结构将非常有用。 - supercat
@annakata 我认为这是因为C#有一个特别强的要求,即必须使用new来调用构造函数。在C++中,构造函数以隐藏的方式在数组的声明或实例化时被调用。在C#中,一切都是指针,所以从null开始,或者它是一个结构体,必须从某个地方开始,但当你不能写new时...(比如数组初始化),那将违反C#的一个强规则。 - v.oddou

9

5
根据您发布的链接,我看到只有在显式调用构造函数时才会被激活,而不是在通过“default”或数组创建时... 哎呀。 - Motti

3

我还没有看到类似于我将要提供的解决方案,所以在这里呈现。

使用偏移量将默认值0的值移动到任何您喜欢的值中。在这里,必须使用属性来代替直接访问字段。(也许使用可能的C#7特性,您可以定义属性作用域字段,从而使它们免受在代码中直接访问的保护。)

此解决方案适用于仅具有值类型(无引用类型或可空结构体)的简单结构体。

public struct Tempo
{
    const double DefaultBpm = 120;
    private double _bpm; // this field must not be modified other than with its property.

    public double BeatsPerMinute
    {
        get => _bpm + DefaultBpm;
        set => _bpm = value - DefaultBpm;
    }
}

这与此答案不同,这种方法不是特殊情况,而是使用偏移量,适用于所有范围。

以枚举为字段的示例。

public struct Difficaulty
{
    Easy,
    Medium,
    Hard
}

public struct Level
{
    const Difficaulty DefaultLevel = Difficaulty.Medium;
    private Difficaulty _level; // this field must not be modified other than with its property.

    public Difficaulty Difficaulty
    {
        get => _level + DefaultLevel;
        set => _level = value - DefaultLevel;
    }
}

就像我所说的,这个技巧并不一定适用于所有情况,即使结构体只有值字段,只有你知道它是否在你的情况下有效。只需检查一下即可,但你应该明白这个通用思路。


这是一个很好的解决方案,适用于我所举的例子,但它实际上只是一个例子,问题是普遍的。 - Motti

2

特殊情况特殊处理。如果你看到分子为0,分母也为0,那就假装它有你想要的值。


5
就我个人而言,我不希望我的类/结构体有这种行为。默默失败(或以开发者猜测的最佳方式恢复)是导致错误未被捕获的道路。 - Boris Callens
2
+1 这是一个好的答案,因为对于值类型,您必须考虑它们的默认值。这使您可以使用其行为“设置”默认值。 - IS4
这正是他们实现 Nullable<T> 类的方式(例如 int?)。 - Jonathan Allen
这是一个非常糟糕的想法。0/0 应该始终是无效分数(NaN)。如果有人调用“new Rational(x,y)”,其中 x 和 y 恰好为 0,会怎样呢? - Mike Rosoft
如果您有一个实际的构造函数,那么您可以抛出异常,防止真正的0/0发生。或者,如果您确实希望它发生,则必须添加一个额外的布尔值来区分默认值和0/0。 - Jonathan Allen

2
我使用的是null-coalescing operator (??)与后备字段相结合,代码如下:
public struct SomeStruct {
  private SomeRefType m_MyRefVariableBackingField;

  public SomeRefType MyRefVariable {
    get { return m_MyRefVariableBackingField ?? (m_MyRefVariableBackingField = new SomeRefType()); }
  }
}

希望这有所帮助;)
注意:null coalescing assignment目前是C# 8.0的一个功能建议。

1
我找到了一个简单的解决方案:
struct Data
{
    public int Point { get; set; }
    public HazardMap Map { get; set; }

    public Data Initialize()
    {
        Point = 1; // Set anything you want as default
        Map = new HazardMap();
        return this;
    }
}

在代码中,只需执行以下操作:
Data input = new Data().Initialize();

1

因为您正在使用C#,所以无法定义默认构造函数。

.NET中的结构体可以有默认构造函数,尽管我不知道是否有任何特定支持它的语言。


在C#中,类和结构在语义上是不同的。结构是值类型,而类是引用类型。 - Tomas Ramirez Sarduy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接