为什么递归构造函数调用会使无效的C#代码编译?

时间:2013-05-20 08:16:16

标签: c#

观看网络研讨会 Jon Skeet Inspects ReSharper 后,我开始玩一点 递归构造函数调用并发现,以下代码是有效的C#代码(有效我的意思是它编译)。

class Foo
{
    int a = null;
    int b = AppDomain.CurrentDomain;
    int c = "string to int";
    int d = NonExistingMethod();
    int e = Invalid<Method>Name<<Indeeed();

    Foo()       :this(0)  { }
    Foo(int v)  :this()   { }
}

正如我们大家都知道的那样,字段初始化由编译器移动到构造函数中。因此,如果您有int a = 42;之类的字段,则所有构造函数中都会有a = 42。但是如果你有构造函数调用另一个构造函数,那么只有被调用的初始化代码。

例如,如果你有带有参数调用默认构造函数的构造函数,那么你只能在默认构造函数中使用赋值a = 42

为了说明第二种情况,下一个代码:

class Foo
{
    int a = 42;

    Foo() :this(60)  { }
    Foo(int v)       { }
}

编译成:

internal class Foo
{
    private int a;

    private Foo()
    {
        this.ctor(60);
    }

    private Foo(int v)
    {
        this.a = 42;
        base.ctor();
    }
}

所以主要的问题是,我在这个问题的开头给出的代码被编译成:

internal class Foo
{
    private int a;
    private int b;
    private int c;
    private int d;
    private int e;

    private Foo()
    {
        this.ctor(0);
    }

    private Foo(int v)
    {
        this.ctor();
    }
}

正如您所看到的,编译器无法决定将字段初始化放在何处,因此不会将其放在任何位置。另请注意,没有base构造函数调用。当然,如果您尝试创建StackOverflowException的实例,则无法创建任何对象,并且您将始终使用Foo

我有两个问题:

为什么编译器完全允许递归构造函数调用?

为什么我们在这样的类中初始化的字段中观察到编译器的这种行为?


有些说明:ReSharper警告Possible cyclic constructor calls。此外,在Java中,这样的构造函数调用不会进行事件编译,因此Java编译器在这种情况下更具限制性(Jon在网络研讨会上提到了这些信息)。

这使得这些问题更加有趣,因为对于Java社区而言,C#编译器至少更现代。

这是使用C# 4.0C# 5.0编译器编译的,并使用dotPeek进行反编译。

4 个答案:

答案 0 :(得分:11)

有趣的发现。

看来实际上只有两种实例构造函数:

  1. 使用: this( ...)语法链接另一个相同类型的实例构造函数的实例构造函数。
  2. 一个实例构造函数,它链接基类的实例构造函数。这包括没有指定chainig的实例构造函数,因为: base()是默认值。
  3. (我忽略了System.Object的实例构造函数,这是一个特例。System.Object没有基类!但是System.Object也没有字段。)

    可能存在于类中的实例字段初始值设定项需要复制到上面类型为 2。 的所有实例构造函数的主体的开头,而没有类型 1的实例构造函数。 需要字段分配代码。

    显然,C#编译器不需要对 1。 类型的构造函数进行分析,看看是否存在循环。

    现在,您的示例提供了所有实例构造函数类型为 1。 的情况。在这种情况下,字段initaializer代码不需要放在任何地方。所以它似乎没有得到很深入的分析。

    事实证明,当所有实例构造函数都是 1。 类型时,您甚至可以从没有可访问构造函数的基类派生。但是,基类必须是非密封的。例如,如果您编写的类只包含private个实例构造函数,那么如果派生类中的所有实例构造函数的类型都为 1,那么人们仍然可以从您的类派生。 < / strong>以上。但是,当然,新的对象创建表达式永远不会完成。要创建派生类的实例,必须“欺骗”并使用System.Runtime.Serialization.FormatterServices.GetUninitializedObject方法之类的东西。

    另一个例子:System.Globalization.TextInfo类只有一个internal实例构造函数。但是,您仍然可以使用此技术从mscorlib.dll以外的程序集中派生此类。

    最后,关于

    Invalid<Method>Name<<Indeeed()
    

    语法。根据C#规则,这应该被理解为

    (Invalid < Method) > (Name << Indeeed())
    

    因为左移运算符<<的优先级高于小于运算符<和大于运算符>的优先级。后两个操作数具有相同的优先级,因此通过左关联规则进行评估。如果类型是

    MySpecialType Invalid;
    int Method;
    int Name;
    int Indeed() { ... }
    

    如果MySpecialType引入(MySpecialType, int)的{​​{1}}重载,那么表达式

    operator <

    是合法且有意义的。


    在我看来,如果编译器在这种情况下发出警告会更好。例如,它可以说Invalid < Method > Name << Indeeed() 并指向永远不会转换为IL的字段初始值设定项的行号和列号。

答案 1 :(得分:5)

我认为因为language specification只排除直接调用正在定义的相同构造函数。

从10.11.1开始:

  

所有实例构造函数(类object除外)都隐式地包含在构造函数体之前的另一个实例构造函数的调用。隐式调用的构造函数由constructor-initializer

确定

...

  
      
  • this( argument-list opt )形式的实例构造函数初始值设定项导致实例构造函数要调用的类本身...如果实例构造函数声明包含调用构造函数本身的构造函数初始值设定项,则会发生编译时错误
  •   

最后一句似乎只是排除直接调用自身产生编译时错误,例如

Foo() : this() {}

是非法的。


我承认 - 我看不出允许它的具体原因。当然,在IL级别允许这样的构造,因为我可以在运行时选择不同的实例构造函数 - 所以如果它终止,你可以进行递归。


我认为它没有标记或警告的另一个原因是因为它不需要检测这种情况。想象一下,追逐数百个不同的构造函数,只是为了查看循环是否存在 - 当任何尝试的使用将很快(如我们所知)在运行时爆炸时,对于一个相当优势的情况。

当它为每个构造函数执行代码生成时,它只考虑constructor-initializer,字段初始值设定项和构造函数的主体 - 它不考虑任何其他代码:

  • 如果constructor-initializer是类本身的实例构造函数,则它不会发出字段初始值设定项 - 它会发出constructor-initializer调用,然后是正文。

  • 如果constructor-initializer是直接基类的实例构造函数,它会发出字段初始值设定项,然后是constructor-initializer调用,然后是主体。

在任何情况下都不需要去寻找其他地方 - 所以它不是“无法”决定在哪里放置字段初始化器 - 它只是遵循一些只考虑当前构造函数的简单规则。

答案 2 :(得分:2)

你的例子

class Foo
{
    int a = 42;

    Foo() :this(60)  { }
    Foo(int v)       { }
}

可以正常工作,因为你可以毫无问题地实例化那个Foo对象。但是,以下内容更像是您要询问的代码

class Foo
{
    int a = 42;

    Foo() :this(60)     { }
    Foo(int v) : this() { }
}

这和你的代码都会创建一个stackoverflow(!),因为递归永远不会结束。因此,您的代码将被忽略,因为它永远无法执行。

换句话说,编译器无法决定将错误代码放在何处,因为它可以告诉递归从不触底。我认为这是因为它必须把它放在只调用一次的地方,但是构造函数的递归本质使得这是不可能的。

构造函数意义上的递归在构造函数体中创建自身的实例对我有意义,因为例如可用于实例化每个节点指向其他节点的树。但是通过这个问题说明的那种预构造函数的递归不可能触底,所以如果不允许那样对我有意义。

答案 3 :(得分:0)

我认为这是允许的,因为你可以(仍然)可以捕获异常并做一些有意义的事情。

初始化将永远不会运行,并且几乎肯定会抛出StackOverflowException。但这仍然是想要的行为,并不总是意味着该过程应该崩溃。

如此处https://stackoverflow.com/a/1599236/869482

所述