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

Jep*_*sen 12 c# compiler-construction generics overriding overload-resolution

我们相信这个例子在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"));
    }
  }
}
Run Code Online (Sandbox Code Playgroud)

这个想法很简单,就是创建一个类Base<T, S>具有两个虚拟方法,其签名的"邪恶"的选择后,将成为同TS.该类Conflict只重载其中一个虚方法,并且由于存在Intermediate<,>,它应该被明确定义哪一个!

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

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

Eri*_*ert 21

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

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

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

预期的行为是什么?有两个"虚拟插槽".第一个输出应该是在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,有四种可能性:

  • 可能性一: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是正确的.

在任何情况下,可能性都是正确的.

我承认,这两个中的哪一个是正确的,这不是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

  • 也许还有另一种选择.修改C#规范以在C#级别进行此未定义的行为.然后,具有实现定义的行为或编译时错误的IL转换都是可接受的.这种变化将反映出现实情况. (3认同)