С# Разрешение перегрузки метода, не выбирая конкретное общее переопределение

Эта полная программа на С# иллюстрирует проблему:

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

Похоже, это упоминается в спецификации С# 5.0, 7.5.3. Разрешение перегрузки:

Разрешение перегрузки выбирает член функции для вызова в следующих различных контекстах внутри С#:

  • Вызов метода, названного в вызове-выражении (§7.6.5.1).
  • Вызов конструктора экземпляра, названного в выражении объекта-объекта (§7.6.10.1).
  • Вызов индексатора с помощью элемента-доступа (§7.6.6).
  • Вызов предопределенного или определяемого пользователем оператора, указанного в выражении (§7.3.3 и §7.3.4).

Каждый из этих контекстов определяет набор элементов-кандидатов-кандидатов и список аргументов по-своему, как описано в подробно в разделах, перечисленных выше. Например, набор кандидаты на вызов метода не включают методы, отмеченные переопределить (§7.4), а методы в базовом классе не являются кандидатами, если они метод в производном классе применим (§7.6.5.1).

Когда мы посмотрим на 7.4:

Поиск элемента имени N с параметрами типа K в типе T обрабатывается следующим образом:

• Сначала определяется набор доступных элементов с именем N:

  • Если T является параметром типа, то множество является объединением множеств доступных членов с именем N в каждом из типов, указанных как первичное ограничение или вторичное ограничение (§10.1.5) для T, а также набор доступных членов с именем N в объекте.

  • В противном случае набор состоит из всех доступных (§3.5) членов с именем N в T, включая унаследованные элементы и доступные члены с именем N в объекте. Если T - построенный тип, множество членов получается путем подстановки аргументов типа, как описано в п. 10.3.2. Члены, которые включают модификатор переопределения, исключаются из набора.

Если вы удаляете override, компилятор выбирает перегрузку Execute(string), когда вы кладете элемент.

Ответ 2

Как упоминалось в статье Jon Skeet о перегрузке при вызове метода в классе, который также переопределяет метод с тем же именем из базы class, компилятор всегда будет использовать метод in-class вместо переопределения, независимо от "специфичности" типа, при условии, что подпись "совместима".

Джон продолжает указывать на то, что это отличный аргумент, чтобы избежать перегрузки по границам наследования, так как это именно то неожиданное поведение, которое может произойти.

Ответ 3

Как отмечали другие ответы, это по дизайну.

Рассмотрим менее сложный пример:

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) Тип приемника более важен, чем тип любого аргумента при разрешении перегрузок.

Я надеюсь, что это понятно, почему так должно быть. Человек, записывающий производный класс, имеет больше знаний, чем человек, который пишет базовый класс, потому что человек, который пишет производный класс, использовал базовый класс, а не наоборот.

Человек, который написал Giraffe, сказал: "У меня есть способ для Giraffe съесть любую пищу", и это требует специальных знаний о внутреннем пищеварении жирафа. Эта информация отсутствует в реализации базового класса, которая знает только, как есть яблоки.

Таким образом, разрешение перегрузки всегда должно быть приоритетным для выбора применимого метода производного класса при выборе метода базового класса, независимо от того, насколько эффективны преобразования типа аргумента.

(2) Выбор переопределения или не переопределения виртуального метода не является частью общей площади поверхности класса. Это частная реализация. Поэтому при принятии разрешения перегрузки не должно приниматься никаких решений в зависимости от того, переопределен или нет метод.

Разрешение перегрузки никогда не должно говорить "Я собираюсь выбрать виртуальный Animal.Eat(Apple), потому что он был переопределен".

Теперь вы можете сказать "ОК, предположим, что я нахожусь в Жирафе, когда я звоню". Код внутри Giraffe имеет все знания частных деталей реализации, не так ли? Таким образом, решение Animal.Eat(Apple) вместо Giraffe.Eat(Food) вызывать вместо giraffe.Eat(apple), правда? Потому что он знает, что есть реализация, которая понимает потребности жирафов, которые едят яблоки.

Это лекарство хуже, чем болезнь. Теперь у нас есть ситуация, когда идентичный код имеет другое поведение в зависимости от того, где он запущен! Вы можете представить себе вызов giraffe.Eat(apple) вне класса, реорганизовать его так, чтобы он находился внутри класса, и внезапно наблюдаемые изменения поведения!

Или, можно сказать, эй, я понимаю, что моя логика Жирафа на самом деле достаточно общая для перехода к базовому классу, но не к Animal, поэтому я собираюсь реорганизовать мой код Giraffe на:

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

А теперь все вызовы giraffe.Eat(apple) внутри Giraffe неожиданно имеют другое поведение при перегрузке после рефакторинга? Это было бы очень неожиданно!

С# - язык удачного успеха; мы очень хотим убедиться, что простые рефакторинги, такие как изменение места в иерархии, метод переопределен, не вызывают тонких изменений в поведении.

Подведение итогов:

  • Разрешение перегрузки приоритизирует приемники по другим аргументам, потому что вызов специализированного кода, который знает внутренности получателя, лучше, чем вызов более общего кода, который этого не делает.
  • Не учитывается ли и когда метод переопределяется при разрешении перегрузки; все методы рассматриваются так, как если бы они никогда не были переопределены для целей разрешения перегрузки. Это деталь реализации, а не часть поверхности типа.
  • Проблемы с разрешением перегрузки решены - доступность по модулю конечно! - то же самое, независимо от того, где проблема возникает в коде. У нас нет одного алгоритма разрешения, где приемник относится к типу содержащего кода, а другой - к тому, когда вызов находится в другом классе.

Дополнительные мысли по смежным вопросам можно найти здесь: https://ericlippert.com/2013/12/23/closer-is-better/ и здесь https://blogs.msdn.microsoft.com/ericlippert/2007/09/04/future-breaking-changes-part-three/