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

本文关键字:无效 代码 编译 递归 函数调用 为什么 | 更新日期: 2023-09-27 18:14:26

看完网络研讨会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构造函数调用。当然,不能创建对象,如果您尝试创建Foo的实例,您将始终以StackOverflowException告终。

我有两个问题:

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

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


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

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

使用c# 4.0和c# 5.0编译器编译,并使用dotPeek进行反编译。

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

有趣的发现。

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

  1. 一个实例构造函数,用: this( ...)语法链接另一个相同类型的实例构造函数
  2. 链接基类
  3. 的实例构造函数的实例构造函数。这包括没有指定链接的实例构造函数,因为: base()是默认的。

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

可能出现在类中的实例字段初始化器需要复制到类型2的所有实例构造函数体的开头。上面的,而没有1类型的实例构造函数。需要字段分配代码

显然c#编译器不需要对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的字段初始化器的行号和列号。

我认为是因为语言规范只排除了直接调用正在定义的相同构造函数。

从10.11.1:

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

决定

  • 形式为this( argument-list opt )的实例构造器将导致从类本身调用实例构造器…如果实例构造函数声明包含调用构造函数本身的构造函数初始化项,则会发生编译时错误

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

Foo() : this() {}

是非法的。


我承认——我看不出允许这样做的具体原因。当然,在IL级别这样的构造是允许的,因为我相信可以在运行时选择不同的实例构造函数——所以只要递归终止,就可以使用递归。


我认为它没有标记或警告的另一个原因是因为它不需要检测这种情况。想象一下,遍历数百个不同的构造函数,只是为了看看循环是否存在——当任何尝试使用时,(正如我们所知道的)在运行时很快就会失败,这是一个相当极端的情况。

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

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

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

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

你的例子

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

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

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

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

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

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