C#方法重载解析未选择具体的泛型覆盖

46
这个完整的C#程序展示了这个问题:
public abstract class Executor<T>
{
    public abstract void Execute(T item);
}

class StringExecutor : Executor<string>
{
    public void Execute(object item)
    {
        // why does this method call back into itself instead of binding
        // to the more specific "string" overload.
        this.Execute((string)item);
    }

    public override void Execute(string item) { }
}

class Program
{
    static void Main(string[] args)
    {
        object item = "value";
        new StringExecutor()
            // stack overflow
            .Execute(item); 
    }
}

我遇到了一个StackOverlowException,我追踪到这个调用模式,其中我试图转发调用到一个更具体的重载函数。令我惊讶的是,该调用没有选择更具体的重载函数,而是调用了自身。显然,这与基类型是泛型有关,但我不明白为什么它不会选择Execute(string)重载函数。 请问有人对此有什么见解吗?
上面的代码被简化以显示模式,实际结构略微复杂,但问题是相同的。

1
我并不确定决定方法解析的规范,但暂时的解决方法是 ((Executor<string>)this).Execute((string)item)。这种写法非常丑陋。 - Rob
根据这个问题的回答,应该选择最具体的重载方法。 - Steve
@Steve,这与此处呈现的情况不同,因为它没有涉及覆盖,而C#规范特别处理了这种特殊情况。 - David L
@DavidL 看来是这样。我不知道,非常有趣。 - Steve
3个回答

33
看起来这是在C#规范5.0,7.5.3重载决议中提到的:
重载决策在以下不同的C#上下文中选择要调用的函数成员: - 调用在调用表达式(§7.6.5.1)中命名的方法 - 在对象创建表达式(§7.6.10.1)中命名的实例构造函数的调用。 - 通过元素访问(§7.6.6)调用索引器访问器 - 在表达式中引用的预定义或用户定义运算符(§7.3.3和§7.3.4)
每个上下文以自己独特的方式定义候选函数成员集合和参数列表,如上面列出的部分中详细描述。例如,方法调用的候选集不包括标记为override(§7.4)的方法,并且如果派生类中的任何方法适用,则基类中的方法不是候选方��(§7.6.5.1)。
当我们看7.4时:
处理名称为N且具有K个类型参数的类型T的成员查找如下所示: - 首��确定名为N的可访问成员的集合: - 如果T是类型参数,则该集合是指定为T的主要约束或次要约束(§10.1.5)之一中每个类型中命名为N的可访问成员集合的并集,以及命名为N在对象中的可访问成员集合。 - 否则,该集合由T中所有可访问(§3.5)的成员名称为N的成员组成,包括继承的成员和对象中名称为N的可访问成员。如果T是构造类型,则通过按§10.3.2所述替换类型参数来获取成员的集合。从集合中排除包括override修改符的成员。
如果删除override,则编译器在将项转换时选择Execute(string)重载。

3
当然,我的后续问题会是:为什么?我相信这其中有一个合理的原因,但这个行为对我来说还是有些出乎意料。 - MarkPflug
14
@Mark Jon Skeet的文章解释了函数重载的原因:“这样做是为了减少基类脆弱性问题的风险,即向基类中引入一个新方法可能会对继承它的类的使用者造成问题。” - khargoosh
@khargoosh Skeet的解释涉及到一个不同的问题(即为什么父类中的方法即使是更好的匹配也会被忽略)。脆弱基类问题显然不适用于此处,因为我们处理的是添加到子类而不是基类的函数。将行为更改为同时考虑同一类中的重写方法,根本不会改变向基类添加方法时的行为(这正是脆弱基类问题所涉及的)。 - Voo
Jon事实上在此之后指出了所提到的问题,但没有提及任何原因,并称这种行为是“特别令人惊讶”的。 - Voo
1
@Voo:这可能令人惊讶,但这是有意设计的。请看我的回答。 - Eric Lippert
我发现这个设计中的一个好处是,this.Execute 暗示着递归到 "this" 类中定义的重写方法,而调用较少本地实现则需要使用 base.Execute 或者 ((Executor<T>)this).Execute(对于 T 进行显式类型转换)。简而言之,调用基础实现不太可能是有意的,因此需要更明确的语句。更隐含的语句会导致更容易出现意外情况(在我看来)。 - Michael Hoffmann

24
如Jon Skeet在他的函数重载文章中所述,当在一个类中调用一个方法时,如果该类还覆盖了一个来自基类的同名方法,则编译器总是会选取类内的方法而不是覆盖方法,不论类型的“特定性”如何,只要签名是“兼容”的即可。

Jon接着指出,这正是跨继承边界避免重载的极好理由,因为这种意外行为可能发生。


2
确实,这与通用基类没有任何关系。我可以在不涉及代码方面重现此问题。这对我来说非常令人惊讶。 - MarkPflug
2
正确,通用参数在这种情况下是一个误导。 - David L
1
@Mark:有一些构造重载解析问题的方法,其中泛型或缺乏泛型被用作决胜者,但这不是其中之一。例如,给定 class C { void M(int){} void M<T>(T){} } 和对 c.M(123) 的调用,非泛型将获胜。甚至还有更微妙的决胜者;请参阅规范以获取详细信息。 - Eric Lippert

17

正如其他回答所指出的那样,这是有意设计的。

让我们考虑一个不那么复杂的例子:

class Animal
{
  public virtual void Eat(Apple a) { ... }
}
class Giraffe : Animal
{
  public void Eat(Food f) { ... }
  public override void Eat(Apple a) { ... }
}
问题是为什么giraffe.Eat(apple)解析为Giraffe.Eat(Food)而不是虚拟的Animal.Eat(Apple)
这是由两个规则引起的:
(1)在解决重载时,接收者的类型比任何参数的类型更重要。
我希望这一点很清楚。编写派生类的人比编写基类的人拥有更丰富的知识,因为编写派生类的人使用了基类,而不是相反。
编写的人说:"我有一种方法让吃任何食物",这需要对长颈鹿消化系统的内部有特殊的了解。这些信息在基类实现中不存在,基类只知道如何吃苹果。
因此,无论参数类型转换的好坏如何,重载解析都应始终优先选择派生类的适用方法而不是基类的方法。
(2)选择是否覆盖虚拟方法不属于类的公共表面积。这是一个私有的实现细节。因此,在进行重载解析时,不能做出会随着是否重写方法而改变的决策。
重载解析绝不能说:"我要选择虚拟的Animal.Eat(Apple),因为它被覆盖了"。
现在,你可能会说:"好吧,假设我在调用时在内部。"在内部的代码拥有所有私有实现细节的知识,对吗?所以它可以在面对giraffe.Eat(apple)时决定调用虚拟的Animal.Eat(Apple)而不是Giraffe.Eat(Food),因为它知道有一个实现可以理解吃苹果的长颈鹿的需求。
那样只会让问题更加严重。现在,相同的代码在不同的位置上具有不同的行为!你可以想象一下,将对giraffe.Eat(apple)的调用从类外移到类内部,突然观察到的行为发生了变化!
或者,你可能会说,嘿,我意识到我的逻辑实际上足够通用,可以移动到一个基类,但不能移动到,所以我要重构我的代码:
class Mammal : Animal 
{
  public void Eat(Food f) { ... } 
  public override void Eat(Apple a) { ... }
}
class Giraffe : Mammal
{
  ...
}

现在,在重构后,Giraffe内部所有对 giraffe.Eat(apple) 的调用的重载解析行为都会突然发生不同的变化?这将是非常出乎意料的!

C#是一种成功的坑语言;我们非常希望确保像更改方法在继承层次结构中覆盖的位置这样的简单重构不会导致行为上的微妙变化。

总结:

  • 重载解析会优先考虑接收器而不是其他参数,因为调用了解知接收器的内部的专门代码比调用不知道的更通用的代码更好。
  • 方法是否被覆盖以及覆盖它的位置在重载解析期间不予考虑。 对于重载解析而言,所有方法都视为从未被覆盖过。这是一种实现细节,而不是类型的表面的一部分。
  • 无论问题出现在代码的哪个位置,解决重载解析问题的方式是相同的——当然,除了可访问性! 我们没有一个算法来解析接收器是包含代码类型的情况,另一个算法来处理调用在不同类中的情况。

有关相关问题的其他想法可以在此处找到:https://ericlippert.com/2013/12/23/closer-is-better/https://blogs.msdn.microsoft.com/ericlippert/2007/09/04/future-breaking-changes-part-three/


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接