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

C# Method overload resolution not selecting concrete generic override

这个完整的 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,我追溯到这个调用模式,我试图将调用转发到更具体的重载。令我惊讶的是,调用并不是 select 更具体的重载,而是回调自身。它显然与通用的基类型有关,但我不明白为什么它不会 select Execute(string) 重载。

有没有人对此有任何见解?

上面的代码为了展示模式进行了简化,实际结构有点复杂,但问题是一样的。

看起来 C# 规范 5.0、7.5.3 重载解决方案中提到了这一点:

Overload resolution selects the function member to invoke in the following distinct contexts within C#:

  • Invocation of a method named in an invocation-expression (§7.6.5.1).
  • Invocation of an instance constructor named in an object-creation-expression (§7.6.10.1).
  • Invocation of an indexer accessor through an element-access (§7.6.6).
  • Invocation of a predefined or user-defined operator referenced in an expression (§7.3.3 and §7.3.4).

Each of these contexts defines the set of candidate function members and the list of arguments in its own unique way, as described in detail in the sections listed above. For example, the set of candidates for a method invocation does not include methods marked override (§7.4), and methods in a base class are not candidates if any method in a derived class is applicable (§7.6.5.1).

当我们看 7.4 时:

A member lookup of a name N with K type parameters in a type T is processed as follows:

• First, a set of accessible members named N is determined:

  • If T is a type parameter, then the set is the union of the sets of
    accessible members named N in each of the types specified as a primary constraint or secondary constraint (§10.1.5) for T, along with the set of accessible members named N in object.

  • Otherwise, the set consists of all accessible (§3.5) members named N in T, including inherited members and the accessible membersnamed N in object. If T is a constructed type, the set of members is obtained by substituting type arguments as described in §10.3.2. Members that include an override modifier are excluded from the set.

如果您删除 override,编译器会在您投射项目时选择 Execute(string) 重载。

如 Jon Skeet 的 article on overloading 中所述,当调用 class 中的方法时,该方法也覆盖基 class 中具有相同名称的方法,编译器将始终采用in-class 方法而不是重写,无论 "specificness" 类型如何,前提是签名是 "compatible".

Jon 继续指出,这是避免跨继承边界重载的一个很好的论据,因为这正是可能发生的意外行为。

正如其他答案所指出的,这是设计使然。

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

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) 在解析重载时,接收者的类型比任何参数的类型都重要。

我希望清楚为什么一定是这样。编写派生 class 的人比编写基 class 的人拥有更多的知识,因为编写派生 class 的人使用了基 class,反之亦然.

Giraffe 的人说 "I have a way for a Giraffe to eat any food" 这需要长颈鹿消化内部结构的特殊知识。该信息不存在于基础 class 实现中,它只知道如何吃苹果。

因此重载决策应始终优先选择派生 class 的适用方法而不是选择基 class 的方法, 无论参数类型转换的优劣如何.

(2) 选择覆盖或不覆盖虚拟方法不是 class 的 public 表面区域的一部分。这是一个私有的实现细节。因此,在执行重载决策时不必做出任何决定,重载决策会根据方法是否被覆盖而改变。

过载解析绝不能说"I'm going to choose virtual Animal.Eat(Apple) because it was overridden".

现在,您可能会说 "OK, suppose I am inside Giraffe when I am making the call." 代码 inside Giraffe 拥有私有实现细节的所有知识,对吧?所以它可以决定在面对 giraffe.Eat(apple) 时调用虚拟 Animal.Eat(Apple) 而不是 Giraffe.Eat(Food),对吗?因为它知道有一个实现可以理解吃苹果的长颈鹿的需求。

这是一种比疾病更糟糕的治疗方法。现在我们遇到了 相同 代码具有 不同 行为的情况,具体取决于它 运行 的位置!您可以想象在 class 之外调用 giraffe.Eat(apple),重构它以使其在 class 内部,然后可观察到的行为突然发生变化!

或者,你可能会说,嘿,我意识到我的 Giraffe 逻辑实际上足够通用,可以移动到基数 class,但不能移动到 Animal,所以我要重构我的 Giraffe代码为:

class Mammal : Animal 
{
  public void Eat(Food f) { ... } 
  public override void Eat(Apple a) { ... }
}
class Giraffe : Mammal
{
  ...
}

现在所有对 giraffe.Eat(apple) inside Giraffe 的调用在重构后突然有 不同的 重载解析行为?那将是非常意外的!

C# 是一种成功的语言;我们非常希望确保简单的重构(例如更改层次结构中方法被重写的位置)不会导致行为发生细微变化。

总结:

  • 重载解决方案使接收器优先于其他参数,因为调用了解接收器内部结构的专用代码比调用不了解接收器内部结构的更通用代码要好。
  • 在重载决策期间不考虑方法是否被覆盖以及在何处被覆盖;出于重载决议的目的,所有方法都被视为 从未 被覆盖。这是一个实现细节,而不是类型表面的一部分。
  • 过载分辨率问题已解决——当然是模可访问性! -- 无论代码哪里出现问题,方法都是一样的。我们没有一种算法可以解决接收方是包含代码类型的问题,也没有另一种算法可以解决调用处于不同 class.
  • 时的问题

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