【问题标题】:Why does a recursive constructor call make invalid C# code compile?为什么递归构造函数调用会使无效的 C# 代码编译?
【发布时间】:2013-05-14 18:14:30
【问题描述】:

看完网络研讨会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; 这样的字段,你将在 all 构造函数中拥有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 构造函数调用。当然,不能创建任何对象,如果您尝试创建Foo 的实例,您将始终以StackOverflowException 结束。

我有两个问题:

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

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


一些注意事项:ReSharperPossible cyclic constructor calls 警告您。此外,在 Java 中,此类构造函数调用不会进行事件编译,因此 Java 编译器在这种情况下更具限制性(Jon 在网络研讨会上提到了此信息)。

这让这些问题变得更有趣,因为就 Java 社区而言,C# 编译器至少更现代。

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

【问题讨论】:

  • 他**怎么会错过这个视频???
  • 很好的问题。
  • 不错的字段初始值设定项:int a = null; int b = AppDomain.CurrentDomain; int c = "string to int"; int d = NonExistingMethod(); int e = Invalid&lt;Method&gt;Name&lt;&lt;Indeeed(); 应该做一个小测验:“这些字段声明在什么情况下可以?” (有一个关于未使用字段的警告,但您可以通过读取其中一个实例构造函数(或其他地方)的主体内的每个字段来消除该警告。)
  • 我相信这是允许的due to the same reason
  • 字段初始化被放入所有调用基本构造函数的构造函数中。因此,如果没有调用基本构造函数的构造函数,则字段初始化不会放在任何地方。至少那部分对我来说很有意义。并不是编译器不知道把它放在哪里,而是因为编译器注意到它没有必须把它放在任何地方。

标签: c#


【解决方案1】:

有趣的发现。

看来实例构造函数真的只有两种:

  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())

因为左移运算符&lt;&lt; 的优先级高于小于运算符&lt; 和大于运算符&gt;。后两个运算符具有相同的优先级,因此由左结合规则评估。如果类型是

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

如果MySpecialType 引入了(MySpecialType, int)(MySpecialType, int) 重载,则表达式

Invalid < Method > Name << Indeeed()

合法且有意义。


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

【讨论】:

  • 我不明白...在 ctor 之前没有调用字段实例化吗?
  • @RoyiNamir 是的。但是如果你看一下 IL,它就像提问者写的那样工作:“我们可能都知道,字段初始化被编译器移到构造函数中。” 意思是,假设你写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; } }
【解决方案2】:

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

从 10.11.1 开始:

所有实例构造函数(除了类object 的构造函数)在构造函数主体之前隐式包含对另一个实例构造函数的调用。隐式调用的构造函数由构造函数初始化器确定

...

  • this(argument-listopt) 形式的实例构造函数初始化器导致调用来自类本身的实例构造函数...如果实例构造函数声明包含调用构造函数本身的构造函数初始化程序,则会发生编译时错误

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

Foo() : this() {}

是非法的。


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


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

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

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

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

在这两种情况下,它都不需要去别处寻找——所以它不是“无法”决定在哪里放置字段初始值设定项——它只是遵循一些只考虑当前构造函数的简单规则。

【讨论】:

  • 但是它让这样的行编译的事实如何:int e = Invalid&lt;Method&gt;Name&lt;&lt;Indeeed();。我说这是一个编译器错误。
  • @MatthewWatson 它可能被解释为int e = Invalid &lt; Method &gt; Name &lt;&lt; Indeed();,带有二元运算符“小于”、“大于”和“左移”。这在语法上是可以的,但是如果要让它在强类型中可以正常使用,那将是一些非常疯狂的运算符重载。
  • @JeppeStigNielsen 是的,但是如果您将代码保持不变,除了删除递归构造函数代码之外,它将无法编译。这就是为什么我认为这是一个错误。
  • @MatthewWatson 解析时无法检测到错误,因为类不完整。 (也许您的类将定义名为Invalid 等的成员,使其有效。)通常在代码生成时检测到错误,但您找到了一种编写永远不会生成的代码的方法。您在编译器中发现了一个偷偷摸摸的漏洞(一种编写永远不会被编译的代码的方法),但不是一个严重的漏洞,因为有问题的代码无论如何都无法访问。
【解决方案3】:

你的例子

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

换句话说,编译器无法决定将错误代码放在哪里,因为它可以判断递归永远不会触底。我认为这是因为它必须把它放在只会被调用一次的地方,但是构造函数的递归性质使得这不可能。

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

【讨论】:

  • 是的,我同意,这就是我创建这个问题的原因。为什么编译器无法决定将初始化逻辑放在哪里,因此为什么它完全允许递归调用?这是有原因的吗?
  • 我似乎很清楚编译器无法决定将错误代码放在哪里,因为它可以判断递归永远不会触底。为什么这是一个谜?
  • 如果 C# 不能决定调用什么方法,它会抛出错误ambiguous method call,它不会跳过这样的方法调用。如果我是编译器,我也会在这种情况下抛出错误。
  • 很糟糕,答案收到如此多的反对票,我没有反对任何一个(只是案例)。在这种情况下,它也无法决定将初始化逻辑放在哪里。所以我的主要问题是为什么完全允许递归调用?这背后有什么原因吗?也许我错过了什么
  • @IlyaIvanov - 我认为更相关的问题是 - 为什么要编写循环检测器来检测编译器中的递归构造函数调用?
【解决方案4】:

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

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

正如这里所解释的https://stackoverflow.com/a/1599236/869482

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2022-01-15
    • 2023-03-11
    • 1970-01-01
    • 2010-11-28
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-10-12
    相关资源
    最近更新 更多