当替换类型参数后两个方法具有相同的签名时,将覆盖错误的重载

时间:2012-04-16 15:58:56

标签: c# generics compiler-construction override overload-resolution

我们相信这个例子在C#编译器中出现了一个错误(如果我们错了,请取笑我)。这个错误可能是众所周知的:毕竟,我们的例子是对in this blog post所描述内容的简单修改。

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"));
    }
  }
}

这个想法只是创建一个具有两个虚拟方法的类Base<T, S>,在TS的“邪恶”选择之后,这些方法的签名将变得相同。类Conflict只重载其中一个虚方法,并且由于Intermediate<,>的存在,它应该被明确定义哪一个!

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

当我们阅读Sam Ng的follow-up post时,我们得到的结论是该错误没有得到修复,因为他们认为总会抛出类型加载异常。但在我们的示例中,代码编译并运行时没有错误(只是意外的输出)。

1 个答案:

答案 0 :(得分:21)

  

我们相信这个例子在C#编译器中出现了一个错误。

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

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

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

那些插槽里有什么?

Base<T,S>的实例中,return 1方法位于Foo(T)位置,return 2方法位于Foo(S)位置。

Intermediate<T,S>的实例中,return 11方法位于Foo(T)位置,return 2方法位于Foo(S)位置。

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

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

  • 可能性1:return 11方法位于Foo(T)位置,return 101方法位于Foo(S)位置。
  • 可能性二:return 101方法位于Foo(T)位置,return 2方法位于Foo(S)位置。
  • 可能性三:return 101方法在两个插槽中都有。
  • 可能性四:编译器检测到程序不明确并发出错误。

根据规范的第10.6.4节,您希望这里发生两件事之一。之一:

  1. 编译器将确定Conflict中的方法会覆盖Intermediate<string, string>中的方法,因为首先找到中间类中的方法。在这种情况下,可能性2是正确的行为。或者:
  2. 编译器将确定Conflict中的方法对于它覆盖的原始声明是不明确的,因此可能性4是正确的。
  3. 在任何情况下都不可能是正确的。

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

    编译器的实际功能是你所期望的:它选择了两种可能性。因为中间类有一个匹配的成员,所以我们选择它作为&#34;覆盖&#34;的东西,不管该方法在中间类中是否声明。编译器确定Intermediate<string, string>.Foo是由Conflict.Foo覆盖的方法,并相应地发出代码。它不会产生错误,因为它判断程序没有错误。

    因此,如果编译器正确分析代码,选择可能性2,而不是产生错误,那么为什么在运行时 出现 编译器选择了可能性,不是两种可能性?

    因为在一般构造下创建一个导致两个方法统一的程序是运行时的实现定义行为。在这种情况下,运行时可以选择任何!它可以选择给出类型加载错误。它可以提供可验证性错误。它可以选择允许程序,但根据自己选择的一些标准填写插槽。事实上,后者就是它的作用。运行时会查看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