可能是 Visual Studio 2015 中的 C# 编译器错误

Maybe a C# compiler bug in Visual Studio 2015

我认为这是一个编译器错误。

以下控制台应用程序在使用 VS 2015 编译时可以完美地编译和执行:

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            var x = MyStruct.Empty;
        }

        public struct MyStruct
        {
            public static readonly MyStruct Empty = new MyStruct();
        }
    }
}

但现在它变得很奇怪:这段代码可以编译,但在执行时会抛出 TypeLoadException

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            var x = MyStruct.Empty;
        }

        public struct MyStruct
        {
            public static readonly MyStruct? Empty = null;
        }
    }
}

你遇到过同样的问题吗?如果是这样,我将向 Microsoft 提出问题。

代码看起来毫无意义,但我用它来提高可读性和消除歧义。

我有不同重载的方法,比如

void DoSomething(MyStruct? arg1, string arg2)

void DoSomething(string arg1, string arg2)

这样调用方法...

myInstance.DoSomething(null, "Hello world!")

...不编译。

通话中

myInstance.DoSomething(default(MyStruct?), "Hello world!")

myInstance.DoSomething((MyStruct?)null, "Hello world!")

有效,但看起来很丑。我更喜欢这样:

myInstance.DoSomething(MyStruct.Empty, "Hello world!")

如果我将 Empty 变量放入另一个 class,一切正常:

public static class MyUtility
{
    public static readonly MyStruct? Empty = null;
}

奇怪的行为,不是吗?


更新 2016-03-29

我在这里开了工单:http://github.com/dotnet/roslyn/issues/10126


更新 2016-04-06

这里开了一张新工单:https://github.com/dotnet/coreclr/issues/4049

这不是 2015 年的错误,但可能是 C# 语言错误。下面的讨论涉及为什么 实例成员 不能引入循环,以及为什么 Nullable<T> 会导致这个错误,但不应该应用于静态成员。

我会将其作为语言错误提交,而不是编译器错误。


在 VS2013 中编译此代码会出现以下编译错误:

Struct member 'ConsoleApplication1.Program.MyStruct.Empty' of type 'System.Nullable' causes a cycle in the struct layout

快速搜索 this answer 指出:

It's not legal to have a struct that contains itself as a member.

不幸的是,用于值类型的可空实例的 System.Nullable<T> 类型也是一种值类型,因此必须具有固定的大小。人们很容易将 MyStruct? 视为引用类型,但事实并非如此。 MyStruct? 的大小基于 MyStruct 的大小...这显然在编译器中引入了循环。

举个例子:

public struct Struct1
{
    public int a;
    public int b;
    public int c;
}

public struct Struct2
{
    public Struct1? s;
}

使用System.Runtime.InteropServices.Marshal.SizeOf()你会发现Struct2是16字节长,说明Struct1?不是引用而是一个长4字节(标准填充大小)的结构比 Struct1.


这里发生了什么

作为对 Julius Depulla 的回答和评论的回应,以下是当您访问 static Nullable<T> 字段时 实际上 发生的情况。来自这段代码:

public struct foo
{
    public static int? Empty = null;
}

public void Main()
{
    Console.WriteLine(foo.Empty == null);
}

这是从 LINQPad 生成的 IL:

IL_0000:  ldsflda     UserQuery+foo.Empty
IL_0005:  call        System.Nullable<System.Int32>.get_HasValue
IL_000A:  ldc.i4.0    
IL_000B:  ceq         
IL_000D:  call        System.Console.WriteLine
IL_0012:  ret         

第一条指令获取静态字段foo.Empty的地址并将其压入堆栈。此地址保证为非空,因为Nullable<Int32> 是结构而非引用类型。

接下来调用 Nullable<Int32> 隐藏成员函数 get_HasValue 以检索 HasValue 属性 值。这不会导致空引用,因为如前所述,值类型字段的地址必须是非空的,无论地址中包含什么值。

剩下的就是将结果与0进行比较,并将结果发送到控制台。

在此过程中的任何时候都不可能 'invoke a null on a type' 任何意思。值类型没有空地址,因此对值类型的方法调用不能直接导致空对象引用错误。这就是我们不称它们为引用类型的原因。

首先,在分析这些问题时,重要的是做一个最小的复制器,这样我们就可以缩小问题的范围。在原始代码中有三个转移注意力的问题:readonlystaticNullable<T>。 None 是重现问题所必需的。这是一个最小的复制:

struct N<T> {}
struct M { public N<M> E; }
class P { static void Main() { var x = default(M); } }

这在当前版本的 VS 中编译,但在 运行 时抛出类型加载异常。

  • 使用E不会触发异常。它由任何访问类型 M 的尝试触发。 (正如人们在类型加载异常的情况下所期望的那样。)
  • 异常重现字段是静态还是实例,只读与否;这与该领域的性质无关。 (但是它必须是一个字段!如果它是一个方法,则该问题不会重现。)
  • 异常与"invocation"没有任何关系;最小复制中没有任何内容 "invoked"。
  • 该异常与成员访问运算符“.”无关。它没有出现在最小复制中。
  • 该异常与可空值无关;最小复制中没有任何内容可以为空。

现在让我们再做一些实验。如果我们让 NM 类 呢?我会告诉你结果:

  • 仅当两者都是结构时才会重现该行为。

我们可以继续讨论问题是否仅在 M 在某种意义上 "directly" 提及自身时重现,或者 "indirect" 循环是否也重现该错误。 (后者是正确的。)正如科里在他的回答中指出的那样,我们也可以问 "do the types have to be generic?" 否;有一个比这个没有泛型的复制器更小的复制器。

但是我认为我们已经足够完成对复制器的讨论并继续讨论手头的问题,即 "is it a bug, and if so, in what?"

显然这里有些事情搞砸了,我今天没有时间理清应该责备的地方。以下是一些想法:

  • 禁止包含自身成员的结构的规则显然不适用于此处。 (请参阅 C# 5 规范的第 11.3.1 节,这是我手头的那个。我注意到这一节可以受益于考虑到泛型的仔细重写;这里的一些语言有点不精确。)如果E 是静态的,则该部分不适用;如果它不是静态的,那么 N<M>M 的布局都可以计算。

  • 我知道 C# 语言中没有其他规则会禁止这种类型排列。

  • 可能是CLR规范禁止这种类型排列的情况,CLR在这里抛出异常是对的。

那么现在让我们总结一下可能性:

  • CLR 有一个错误。这种类型的拓扑应该是合法的,CLR丢在这里是错误的。

  • CLR 行为是正确的。这种类型的拓扑是非法的,CLR 扔在这里是正确的。 (在这种情况下,可能是 CLR 存在规范错误,因为规范中可能没有充分解释这一事实。我今天没有时间深入研究 CLR 规范。)

让我们假设第二个是正确的。关于 C#,我们现在可以说些什么?一些可能性:

  • C# 语言规范禁止该程序,但实现允许。该实现有一个错误。 (我相信这种情况是错误的。)

  • C# 语言规范并未禁止此程序,但可以以合理的实施成本来禁止此程序。在这种情况下,C# 规范有问题,应该修复它,并且应该修复实现以匹配。

  • C#语言规范并未禁止该程序,但无法以合理的成本在编译时检测问题。几乎任何 运行 时间崩溃都是这种情况;您的程序在 运行 时间崩溃,因为编译器无法阻止您编写有缺陷的程序。这只是另一个有问题的程序;不幸的是,你没有理由知道它有问题。

综上所述,我们的可能性是:

  • CLR 有错误
  • C# 规范有一个错误
  • C# 实现有一个错误
  • 程序有错误

这四个必须有一个为真。我不知道是哪个。如果让我猜,我会选第一个;我看不出为什么 CLR 类型的加载器应该在这个上犹豫不决。但也许有一个我不知道的充分理由;希望 CLR 类型加载语义方面的专家能插话。


更新:

此问题已在此处跟踪:

https://github.com/dotnet/roslyn/issues/10126

总结 C# 团队在那个问题上的结论:

  • 根据 CLI 和 C# 规范,该程序都是合法的。
  • C# 6 编译器允许该程序,但 CLI 的某些 实现抛出类型加载异常。这是这些实现中的错误。
  • CLR 团队知道这个错误,显然很难修复有错误的实现。
  • C# 团队正在考虑 使合法代码产生警告,因为它会在 运行 时在某些(但不是所有)CLI 版本上失败.

C# 和 CLR 团队正在处理这个问题;跟进他们。如果您对这个问题还有任何疑虑,请 post 到跟踪问题,而不是在这里。

现在我们已经就问题的内容和原因进行了长时间的讨论,这里有一种解决问题的方法,而无需等待各个 .NET 团队来追踪问题并确定是否可以采取任何措施关于它。

该问题似乎仅限于以某种方式作为通用参数或静态成员引用回该类型的值类型的字段类型。例如:

public struct A { public static B b; }
public struct B { public static A a; }

呃,我现在觉得很脏。糟糕的 OOP,但它表明问题存在而无需以任何方式调用泛型。

因此,因为它们是值类型,类型加载器确定存在一个循环,由于 static 关键字,应该忽略该循环。 C# 编译器足够聪明,可以解决这个问题。该不该有还是要看规格,我不予置评。

但是,通过将 AB 更改为 class,问题就消失了:

public struct A { public static B b; }
public class B { public static A a; }

因此可以通过使用引用类型来存储实际值并将字段转换为 属性:

来避免该问题
public struct MyStruct
{
    private static class _internal { public static MyStruct? empty = null; }
    public static MyStruct? Empty => _internal.empty;
}

这会慢一些,因为它是一个 属性 而不是一个字段,调用它会调用 get 方法,所以我不会将它用于性能关键代码,但是作为一种解决方法,它至少可以让您完成这项工作,直到找到合适的解决方案。

如果事实证明这没有得到解决,至少我们有一个可以用来绕过它的工具。