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


82

看完网络研讨会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进行反编译。


3
他是怎么想念这个视频的?
罗伊·纳米尔

7
很好的问题。
丹尼斯

2
那里是不错的字段初始化器:int a = null; int b = AppDomain.CurrentDomain; int c = "string to int"; int d = NonExistingMethod(); int e = Invalid<Method>Name<<Indeeed();应该做一个测验:“在什么情况下这些字段声明可以?” (有关于未使用字段的警告,但是您可以通过阅读实例构造函数之一(或其他地方)的主体内部的每个字段来摆脱该警告。)
Jeppe Stig Nielsen

4
出于同样的原因,我认为这是允许的。
2013年

4
字段初始化将放入所有调用基本构造函数的构造函数中。结果,如果没有构造函数调用基本构造函数,则字段初始化不会放在任何地方。至少那部分对我来说很有意义。这并不是因为编译器无法确定将其放置在何处,而是因为编译器注意到它不必将其放置在任何地方。

Answers:


11

有趣的发现。

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

  1. 一个实例构造函数,它使用语法链接另一个相同类型的实例构造函数: this( ...)
  2. 一个实例构造函数,它链接基类的实例构造函数。这是实例构造函数,其中未指定chainig,因为这: base()是默认值。

(我忽略了它的实例构造函数System.Object是一个特例。System.Object它没有基类!但是System.Object也没有字段。)

该类中可能存在的实例字段初始化程序需要复制到上面所有类型2的所有实例构造函数的主体的开头,而类型1的实例构造函数不需要字段分配代码。

因此,显然,C#编译器无需对类型1的构造函数进行分析,即可查看是否存在循环。

现在,您的示例给出了所有实例构造函数均为1类型的情况。在这种情况下,不需要将字段初始化代码放在任何地方。因此,似乎没有对其进行非常深入的分析。

事实证明,当所有实例构造函数的类型均为1时,您甚至可以从没有可访问构造函数的基类派生。但是,基类必须是非密封的。例如,如果您编写一个仅包含private实例构造函数的类,那么如果人们使派生类中的所有实例构造函数均为上述类型1,则仍然可以从您的类派生。但是,新的对象创建表达式将永远不会完成。要创建派生类的实例,必须“作弊”并使用诸如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)重载operator <,则表达式

Invalid < Method > Name << Indeeed()

是合法且有意义的。


我认为,在这种情况下,编译器发出警告会更好。例如,它可以说unreachable code detected并指向永远不会转换为IL的字段初始化程序的行号和列号。


1
我不明白...在ctor之前不会调用字段实例化吗?
罗伊·纳米尔

2
@RoyiNamir是的。但是,如果您看一下IL,它的工作原理就像asker写道:“众所周知,字段初始化是由编译器移到构造函数中的。” 这意味着什么,假设您使用C#:编写了此类class Example { int field = 42; internal Example() { /* some code here */ field = 100; } },然后由它产生的IL将42分配值放到实例构造函数中,就像其他情况一样,就像您编写的一样:class Example { int field; internal Example() { field = 42; /* some code here */ field = 100; } }
Jeppe Stig Nielsen

5

我认为是因为语言规范仅排除了直接调用要定义的同一构造函数的可能性。

从10.11.1开始:

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

...

  • 形式的实例构造函数初始值设定项会导致从类本身中调用实例构造函数……如果实例构造函数声明包括一个调用构造函数本身的构造函数初始值设定项,则会发生编译时错误this(argument-listopt)

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

Foo() : this() {}

是非法的。


我承认-我看不出允许它的具体原因。当然,在IL级别,这样的构造是允许的,因为我相信可以在运行时选择不同的实例构造器-因此,只要终止,您就可以进行递归。


我认为它不对此进行标记或警告的另一个原因是,它不需要检测这种情况。想象一下遍历数百个不同的构造函数,只是想看看是否确实存在一个循环-在相当严重的情况下,任何尝试的使用都会在运行时迅速(如我们所知)爆炸。

在为每个构造函数进行代码生成时,它只考虑constructor-initializer,字段初始化器和构造函数的主体-它不考虑任何其他代码:

  • 如果constructor-initializer是类本身的实例构造函数,则不发出字段初始化器-发出constructor-initializer调用,然后发出主体。

  • 如果constructor-initializer是直接基类的实例构造函数,则它将发出字段初始化器,然后发出constructor-initializer调用,然后发出正文。

在两种情况下都不需要去寻找其他地方-因此,它不是“无法”决定将字段初始化程序放置在何处的情况-它只是遵循一些仅考虑当前构造函数的简单规则。


2
可是你知道的事实,它可以让这样的编译行:int e = Invalid<Method>Name<<Indeeed();。我说那是编译器错误。
马修·沃森

@MatthewWatson可以将其解释为int e = Invalid < Method > Name << Indeed();使用二进制运算符“小于”,“大于”和“左移”。从语法上讲这是可以的,但是如果要使用强类型的输入使其变得还可以,那就是操作员的一些疯狂的重载。
Jeppe Stig Nielsen

1
@JeppeStigNielsen Aye,但是如果您保留删除递归构造函数代码的相同代码,则它将无法编译。这就是为什么我认为这是一个错误。
马修·沃森

4
@MatthewWatson由于类不完整,因此在解析时无法检测到错误。(也许您的类将定义名为Invalidetc的成员以使其有效。)该错误通常在代码生成时检测到,但是您找到了一种编写从未生成的代码的方法。您在编译器中发现了一个偷偷摸摸的漏洞(一种编写永远不会被编译的代码的方法),但是这并不是一个严重的漏洞,因为有问题的代码始终无法到达。
Raymond Chen

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(!),因为递归永远不会触底。因此,您的代码将被忽略,因为它永远无法执行。

换句话说,编译器无法决定将错误代码放在何处,因为它可以告诉您递归永远不会触底。我认为这是因为它必须将其放置在仅被调用一次的位置,但是构造函数的递归性质使其无法实现。

就构造函数而言,在构造函数的主体内创建自身实例的意义上的递归对我来说是有意义的,因为例如可以将其用于实例化每个节点指向其他节点的树。但是,通过此问题所说明的预构造函数进行的递归永远不会触底反弹,因此如果不允许这样做对我来说很有意义。


1
是的,我同意,这就是我提出这个问题的原因。为什么编译器无法决定将初始化逻辑放在何处,因此,为什么编译器根本不允许递归调用?是否有一个原因?
伊利亚·伊万诺夫

在我看来,编译器无法决定将错误代码放在何处,因为它可以告诉您递归永远不会触底。为什么这是一个谜?
随机

如果C#无法确定要调用的方法,它将抛出错误ambiguous method call,并且不会跳过此类方法调用。如果我将成为编译器,那么在这种情况下我也会抛出错误。
Ilya Ivanov

1
不好的是,答案收到了很多反对票,我没有对其中的任何一个投票(只是这样)。在这种情况下,它也无法决定将初始化逻辑放在哪里。所以我的主要问题是,为什么要完全允许递归调用?这背后有原因吗?也许我缺少了一些东西
Ilya Ivanov

3
@IlyaIvanov-我认为更相关的问题是-为什么编写一个循环检测器来检测编译器中的递归构造函数调用?
Damien_The_Unbeliever

0

我认为这是允许的,因为您仍然可以(可以)捕获Exception并对其进行有意义的处理。

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

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.