12

我们相信这个例子展示了 C# 编译器中的一个错误(如果我们错了,请取笑我)。这个错误可能是众所周知的:毕竟,我们的示例是对这篇博文中描述的内容的简单修改。

using System;

namespace GenericConflict
{
  class Base<T, S>
  {
    public virtual int Foo(T t)
    { return 1; }
    public virtual int Foo(S s)
    { return 2; }

    public int CallFooOfT(T t)
    { return Foo(t); }
    public int CallFooOfS(S s)
    { return Foo(s); }
  }

  class Intermediate<T, S> : Base<T, S>
  {
    public override int Foo(T t)
    { return 11; }
  }

  class Conflict : Intermediate<string, string>
  {
    public override int Foo(string t)
    { return 101;  }
  }


  static class Program
  {
    static void Main()
    {
      var conflict = new Conflict();
      Console.WriteLine(conflict.CallFooOfT("Hello mum"));
      Console.WriteLine(conflict.CallFooOfS("Hello mum"));
    }
  }
}

这个想法只是创建一个具有两个虚拟方法的类,其签名将在“邪恶”选择andBase<T, S>后变得相同。该类只重载了其中一种虚方法,而且由于 的​​存在,应该明确定义哪一种!TSConflictIntermediate<,>

但是当程序运行时,输出似乎表明错误的重载被覆盖了。

当我们阅读 Sam Ng 的后续帖子时,我们得到了该错误未修复的表达,因为他们认为总是会抛出类型加载异常。但在我们的示例中,代码编译并运行没有错误(只是意外输出)。


2020 年的新增内容:这已在更高版本的 C# 编译器(Roslyn?)中得到纠正。当我问这个问题时,输出是:

11
101

截至 2020 年,tio.run给出以下输出

101
2
4

1 回答 1

21

我们认为此示例显示 C# 编译器中的错误。

让我们在出现编译器错误时做我们应该做的事情:仔细对比预期和观察到的行为。

观察到的行为是程序分别产生 11 和 101 作为第一和第二输出。

预期的行为是什么?有两个“虚拟插槽”。第一个输出应该是调用Foo(T)槽中的方法的结果。第二个输出应该是调用Foo(S)槽中的方法的结果。

这些插槽中有什么?

在方法的实例中Base<T,S>进入槽,方法进入槽。return 1Foo(T)return 2Foo(S)

在一个实例中Intermediate<T,S>return 11方法进入Foo(T)槽,return 2方法进入Foo(S)槽。

希望到目前为止你同意我的看法。

在 的实例中Conflict,有四种可能性:

  • 可能性一:return 11方法入Foo(T)槽,return 101方法入Foo(S)槽。
  • 可能性二:return 101方法入Foo(T)槽,return 2方法入Foo(S)槽。
  • 可能性三:该return 101方法适用于两个插槽。
  • 可能性四:编译器检测到程序有歧义,报错。

根据规范的第 10.6.4 节,您希望这里会发生两件事中的一件。任何一个:

  1. 编译器会判断 in 的方法Conflict覆盖了 in 的方法Intermediate<string, string>,因为中间类中的方法是先找到的。在这种情况下,可能性二是正确的行为。或者:
  2. 编译器将确定 in 中的方法对于它覆盖的原始Conflict声明是模棱两可的,因此可能性四是正确的。

在这两种情况下,可能性都不是正确的。

我承认,这不是 100% 清楚,这两者中哪一个是正确的。我个人的感觉是,更明智的行为是将覆盖方法视为中间类的私有实现细节;我想到的相关问题不是中间类是否覆盖基类方法,而是它是否声明了具有匹配签名的方法。在这种情况下,正确的行为是选择可能性四。

编译器实际所做的是您所期望的:它选择了可能性二。因为中间类有一个匹配的成员,所以我们选择它作为“要覆盖的东西”,而不管中间类中没有声明该方法的事实。编译器确定这Intermediate<string, string>.Foo是被 覆盖的方法Conflict.Foo,并相应地发出代码。它不会产生错误,因为它判断程序没有错误。

因此,如果编译器正确地分析了代码,选择了可能性二并且没有产生错误,那么为什么在运行时编译器似乎选择了可能性一,而不是可能性二?

因为在泛型构造下制作一个导致两种方法统一的程序是运行时的实现定义的行为。在这种情况下,运行时可以选择做任何事情!它可以选择给出类型加载错误。它可能会产生可验证性错误。它可以选择允许程序,但根据自己选择的某些标准填充插槽。事实上,后者就是它所做的。运行时会查看 C# 编译器发出的程序,并自行决定分析该程序的正确方法。

所以,现在我们有一个相当哲学的问题,这是否是编译器错误;编译器遵循规范的合理解释,但我们仍然没有得到我们期望的行为。从这个意义上说,它在很大程度上是一个编译器错误。编译器的工作是将用 C# 编写的程序翻译成用 IL 编写的完全等效的程序。编译器没有这样做;它将用 C# 编写的程序转换为用 IL 编写的程序,该程序具有实现定义的行为,而不是 C# 语言规范指定的行为。

正如 Sam 在他的博客文章中清楚地描述的那样,我们很清楚 C# 语言赋予哪些类型拓扑具有特定含义与 CLR 赋予哪些拓扑具有特定含义之间的这种不匹配。C# 语言相当清楚,可能性 2 可以说是正确的一种,但是没有任何代码可以让 CLR 做到这一点,因为CLR 从根本上具有实现定义的行为,只要两个方法统一以具有相同的签名。因此,我们的选择是:

  • 没做什么。允许这些疯狂的、不切实际的程序继续具有与 C# 规范不完全匹配的行为。
  • 使用启发式。正如 Sam 所指出的,我们可以更聪明地使用元数据机制来告诉 CLR 哪些方法会覆盖哪些其他方法。但是……这些机制使用方法签名来消除歧义,现在我们又回到了以前的状态;我们现在使用具有实现定义行为的机制来消除具有实现定义行为的程序的歧义!这是一个非首发。
  • 导致编译器在可能发出其行为由运行时实现定义的程序时产生警告或错误。
  • 修复 CLR,以使导致方法在签名中统一的类型拓扑的行为得到明确定义并与 C# 语言的行为相匹配。

最后一个选择非常昂贵。支付这笔费用给我们带来了微乎其微的用户利益,并直接从解决用户编写明智程序所面临的实际问题中抽走了预算。无论如何,这样做的决定完全不在我的掌控之中。

因此,我们 C# 编译器团队选择了第一种和第三种策略的组合;有时我们会为这种情况产生警告或错误,有时我们什么也不做,让程序在运行时做一些奇怪的事情。

由于在实践中这类程序很少出现在现实的业务线编程场景中,所以我对这些极端情况并不感到很糟糕。如果它们便宜且易于修复,那么我们会修复它们,但它们既不便宜也不易于修复。

如果您对此主题感兴趣,请参阅我的文章,了解另一种导致两种方法统一导致警告和实现定义的行为的方式:

http://blogs.msdn.com/b/ericlippert/archive/2006/04/05/odious-ambiguous-overloads-part-one.aspx

http://blogs.msdn.com/b/ericlippert/archive/2006/04/06/odious-ambiguous-overloads-part-two.aspx

于 2012-04-16T17:52:53.137 回答