为什么CLR不总是调用值类型构造函数?

38

我有一个关于值类型中的类型构造函数的问题。这个问题是受 Jeffrey Richter 在《CLR via C# 第三版》中写的一些东西启发的,他在第8章的第195页上说,你不应该在值类型中实际定义类型构造函数,因为有时候CLR不会调用它。

例如(好吧...实际上是Jeffrey Richter的例子),即使通过查看IL代码,我也无法确定以下代码中为什么未调用类型构造函数:

internal struct SomeValType
{
    static SomeValType()
    {
        Console.WriteLine("This never gets displayed");
    }
    public Int32 _x;
}
public sealed class Program
{
    static void Main(string[] args)
    {
        SomeValType[] a = new SomeValType[10];
        a[0]._x = 123;
        Console.WriteLine(a[0]._x);     //Displays 123
    }
}

应用以下类型构造函数规则,我就是无法理解为什么上面的值类型构造函数根本没有被调用。

  1. 我可以定义一个静态的值类型构造函数来设置类型的初始状态。
  2. 一个类型最多只能有一个构造函数 - 没有默认构造函数。
  3. 类型构造函数在隐式情况下是私有的
  4. JIT编译器检查类型的类型构造函数是否已经在此AppDomain中执行。如果没有,则它会生成对本机代码的调用,否则不会,因为它知道该类型已经'初始化'。

所以...我就是想不明白为什么我看不到这个类型数组被构造。

我最好的猜测是:

  1. CLR构造类型数组的方式。我原以为当创建第一个项目时,静态构造函数会被调用
  2. 构造函数中的代码没有初始化任何静态字段,因此被忽略了。我尝试在构造函数中初始化私有静态字段,但该字段仍保持默认的0值 - 因此构造函数没有被调用。
  3. 或者...编译器由于设置了公共Int32而对构造函数调用进行了优化 - 但这只是模糊的猜测!

最佳实践等不予考虑,我只是对此深感兴趣,因为我想自己看看为什么它没有被调用。

编辑:我在下面添加了自己的答案,只是引用了Jeffrey Richter关于此问题的说法。

如果有人有任何想法,那将是很棒的。 非常感谢, 詹姆斯


好问题。我得试一试,亲身体验一下。我从来没有想过这个。 - David Hoerster
这里似乎数组是冗余的。 - Denis Palnitsky
一个支持性问题是:是否存在不调用静态构造函数的情况,因为结构体没有访问静态状态,这会有问题吗?编译器似乎足够智能,可以避免调用,但出于某种原因,它不适用于引用类型。 - Adam Houldsworth
@Adam:我不确定是否应该将这视为智能。构造函数具有明显的副作用——调用Console.WriteLine——但它没有被调用,尽管规范似乎表明它应该被调用。现在,如果编译器/即时编译器可以确定何时没有副作用——也许他们已经可以了——然后避免调用,那就是智能了。 - LukeH
@LukeH 确实,我认为我有些放纵地使用了这个词。个人而言,我认为这是一个漏洞,但可能之前还没有被发现/解决,因为它在现实中的影响较少——静态构造函数导致的副作用,在能够显著改变程序状态的情况下可能极为罕见。 - Adam Houldsworth
7个回答

19

Microsoft C#4 Spec已经与以前的版本略有不同,现在更准确地反映了我们在这里看到的行为:

11.3.10 静态构造函数

结构体的静态构造函数遵循大多数类的规则。当应用程序域中发生以下第一个事件时,将触发结构体类型的静态构造函数:

  • 引用结构体类型的静态成员。
  • 调用结构体类型的显式声明构造函数。

结构体类型的默认值创建(§11.3.4)不会触发静态构造函数。(例如数组中元素的初始值。)

ECMA规范Microsoft C#3规范 在列表中都有一个额外的事件:"引用结构类型的实例成员"。 因此,C#3在这里似乎违反了自己的规范。C#4规范已经更加接近于C#3和4的实际行为。

编辑...

进一步调查后,似乎除了直接字段访问之外,几乎所有的实例成员访问都会触发静态构造函数(至少在当前的Microsoft C#3和4实现中)。

因此,当前的实现与ECMA和C#3规范中给出的规则更为密切相关,而不是C#4规范中的规则:当访问所有实例成员除了字段时,正确实现了C#3规则;只有在访问字段时才正确实现了C#4规则。

(对于静态成员访问和显式声明的构造函数相关的规则,不同的规范都达成了一致 - 并且显然正确地实现了。)


哇!也许结论应该是不要在结构体中使用静态构造函数,因为它们非常不可靠。 - Jordão
3
然而,添加 public int Foo { get; set; } 并访问该属性也会调用静态构造函数。因此,删除该行似乎并没有使规范更加准确。无论如何,这是一个不错的发现。 - Marc
1
@Marc:干得好。另外,如果您向类型添加任何方法并调用它--例如public void Test() { Console.WriteLine("Test"); }--那么这也会触发静态构造函数。因此,看起来编译器/ Jitter将为方法调用(包括用于属性访问的基础方法)发出对静态构造函数的调用。 - LukeH
1
@Matthew:刚刚测试了一下。访问索引器或连接事件也会触发静态构造函数。 - LukeH
1
@Qwertie,类应该在创建实例或引用静态成员时触发。因此,它们不应该在实例方法上触发。当然,要调用实例方法,您必须首先调用实例构造函数。因此,您可能是正确的,真正的实现会在所有方法上触发,但我不知道如何测试这一点。 - Matthew Flaschen
显示剩余5条评论

11

来自标准的§18.3.10(参见The C# programming language书):

当应用程序域中发生以下事件之一时,将触发结构体的静态构造函数执行:

  • 结构体的实例成员被引用。
  • 结构体的静态成员被引用。
  • 显式声明的结构体构造函数被调用。

[注意:结构体类型的默认值创建(§18.3.4)不会触发静态构造函数。(例如,数组中元素的初始值。)结束备注]

所以我同意你的程序的最后两行应该各触发第一条规则。

经过测试,共识是它对于方法、属性、事件和索引器都能够一致触发。这意味着它对于所有显式实例成员都是正确的,除了字段。因此,如果选择微软的C# 4规则作为标准,那么他们的实现将从大多数正确变为大多数错误。

2
嗯...我本来期望a[0]._x = 123;会触发它...感谢你查阅规范参考,这正是我要看的。 - Jon Skeet
@Jon,在这种情况下,该字段是否不算作实例成员,因为直接访问实例字段无论静态构造函数是否被调用都不会有不同的行为? - Dan Bryant
2
@Dan:我看不出为什么它不应该算作实例成员。我认为如果有这种情况,我应该在规范中看到过...不过我会去追查一下。 - Jon Skeet
标准也很清楚。 §17.2.5:“当字段、方法、属性、事件、索引器、构造函数或终结器声明不包括静态修饰符时,它声明了一个实例成员。” 这在类部分中,但§18.2将其纳入了结构体,“所有种类的类成员声明(除了终结器声明)也是结构体成员声明。 除了§18.3中指出的差异之外,§17.1.4到§17.11提供的类成员描述也适用于结构体成员。” 我查看了§18.3,并没有看到其他相关内容。 - Matthew Flaschen
initobj似乎不会在字段访问时被调用,但对于属性(调用静态构造函数)是这样的。 - Marc
C#4规范的相关部分已经从列表中删除了“引用实例成员”的事件。有关详细信息,请参见我的答案:https://dev59.com/GXA75IYBdhLWcg3wg5js#3246817 - LukeH

2

我把这个作为一个“答案”分享一下Richter先生对此的看法(顺便问一下,有没有最新CLR规范的链接,很容易找到2006版,但是找到最新版有点困难):

对于这种情况,通常最好查看CLR规范而不是C#规范。CLR规范如下所示:

4. 如果未标记BeforeFieldInit,则该类型的初始化器方法在以下情况下执行(即由以下情况触发):

• 第一次访问该类型的任何静态字段,或

• 第一次调用该类型的任何静态方法,或

• 如果它是值类型,则第一次调用该类型的任何实例或虚拟方法,或

• 第一次调用该类型的任何构造函数。

由于没有满足这些条件,因此不会调用静态构造函数。需要注意的唯一棘手的部分是“_x”是实例字段而不是静态字段,并且构造结构体数组不会调用任何实例构造函数。


1

更新:我的观察是,除非使用静态状态,否则静态构造函数永远不会被调用 - 运行时似乎决定了这一点,并且不适用于引用类型。这引出了一个问题,如果它是因为影响很小而被保留的错误,还是因为它是设计缺陷,或者是一个未解决的错误。

更新2:就个人而言,除非在构造函数中做了一些奇怪的事情,否则运行时的这种行为永远不会引起问题。一旦访问静态状态,它就会正确地工作。

更新3:根据LukeH的评论和Matthew Flaschen的答案,实现并调用结构体中自己的构造函数也会触发静态构造函数的调用。这意味着在三种情况中的其中一种,行为与其表面上所说的不同。

我刚刚向类型添加了一个静态属性并访问了该静态属性 - 它调用了静态构造函数。如果没有访问静态属性,只是创建类型的新实例,则不会调用静态构造函数。

internal struct SomeValType
    {
        public static int foo = 0;
        public int bar;

        static SomeValType()
        {
            Console.WriteLine("This never gets displayed");
        }
    }

    static class Program
    {
        /// <summary>
        /// The main entry point for the application.
        /// </summary>
        [STAThread]
        static void Main()
        {
            // Doesn't hit static constructor
            SomeValType v = new SomeValType();
            v.bar = 1;

            // Hits static constructor
            SomeValType.foo = 3;
        }
    }

这个链接中的注释指出,当仅访问实例时,静态构造函数不会被调用:

http://www.jaggersoft.com/pubs/StructsVsClasses.htm#default


不完全正确。如果您向类型添加显式实例构造函数并调用它,则静态构造函数也会被触发。似乎马修答案中列出的三个事件中有两个是正确的,只有第一个似乎不适用。 - LukeH
@LukeH 是的,我看到了他的列表,我并没有尝试实现自己的构造函数,但得出结论,该列表中的第一项并不成立 - 我会修改帖子。 - Adam Houldsworth

1

另一个有趣的示例:

   struct S
    {
        public int x;
        static S()
        {
            Console.WriteLine("static S()");
        }
        public void f() { }
    }

    static void Main() { new S().f(); }

0
我猜你正在创建一个值类型的数组。因此,使用new关键字来初始化数组的内存。
这是有效的说法。
SomeValType i;
i._x = 5;

在这里,你实际上是没有使用任何新的关键字。如果SomeValType是引用类型,你必须初始化数组中的每个元素。

array[i] = new SomeRefType();

这是关于静态构造函数,而不是实例构造函数。 - recursive

0
这是MSIL中“beforefieldinit”属性的疯狂设计行为。它也会影响C++/CLI,我已经提交了一个错误报告,微软非常好心地解释了为什么行为是这样的,并指出了多个语言标准中不一致/需要更新以描述实际行为的部分。但它并不是公开可见的。无论如何,这是来自微软的最终结论(讨论C++/CLI中类似情况的时候):

既然我们在调用标准, 那么Partition I, 8.9.5中的这一行 是这样说的:

如果标记为BeforeFieldInit,则 类型的初始化方法在访问该类型 定义的任何静态字段之前或之时执行。

该部分实际上详细说明了语言实现 如何选择防止您所描述的行为。 C++/CLI选择不这样做,而是允许程序员 如果他们愿意,可以这样做。

基本上,由于下面的代码根本没有静态字段, JIT在简单地不调用静态类构造函数方面是完全正确的。

相同的行为是您所看到的,尽管使用的是不同的语言。

1
不,那不是同一回事。有一个静态构造函数,这意味着类型根本就不会有beforefieldinit标志。 - Jon Skeet
行为不同,我添加了一个静态字段以防编译器由于缺少静态成员而忽略静态构造函数,但它仍然忽略构造函数。只有在访问静态成员时才调用构造函数。 - Adam Houldsworth
嗯,好的。Jon 是对的,类型不会被标记为 beforefieldinit,但它确实继承自 System.ValueType,而这个类是 beforefieldinit 的。虽然这个标志不应该是可继承的,但当出现奇怪的情况时,你需要检查所有东西,对吧?我不知道静态成员访问如何无法触发类型初始化。 - Ben Voigt
我对“基本上,由于下面的代码没有任何静态字段,JIT在不调用静态类构造函数的情况下是完全正确的”这句话提出了异议,但无论如何,它的行为都很奇怪 :-) - Adam Houldsworth
Adam: 这就是必要和充分的区别。逻辑是正确的,但有点令人困惑:代码没有静态字段 -> 代码不访问静态字段。代码不访问静态字段 -> 类型初始化程序不必运行。 - Ben Voigt
显示剩余2条评论

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