Разрешение перегрузки и виртуальные методы

Рассмотрим следующий код (он немного длинный, но, надеюсь, вы сможете понять):

class A
{
}

class B : A
{
}

class C
{
    public virtual void Foo(B b)
    {
        Console.WriteLine("base.Foo(B)");
    }
}

class D: C
{
    public override void Foo(B b)
    {
        Console.WriteLine("Foo(B)");
    }

    public void Foo(A a)
    {
        Console.WriteLine("Foo(A)");
    }
}

class Program
{
    public static void Main()
    {
        B b = new B();
        D d = new D ();
        d.Foo(b);
    }
}

Если вы думаете, что результатом работы этой программы будет "Foo(B)", то вы будете в той же лодке, что и я: совершенно неправильно! На самом деле выводит "Foo(A)"

Если я удалю виртуальный метод из класса C, он будет работать так, как ожидалось: "Foo(B)" будет выводом.

Почему компилятор выбирает версию, которая принимает A, когда B является более производным классом?


person Dean Harding    schedule 09.09.2010    source источник
comment
Хотя я не буду писать такой код, меня это тоже немного удивляет :) Хороший вопрос.   -  person leppie    schedule 09.09.2010
comment
Привет. Я добавил конструктор к обоим и поместил его в Foo(A a) a.GetType().Name, и он говорит, что это тип B.   -  person Kieran    schedule 09.09.2010
comment
Не могли бы вы немного рассказать об абстрактном методе из класса C? Вы его уже удалили?   -  person Brian Rasmussen    schedule 09.09.2010
comment
Бьет меня. Интересный вопрос. Кстати, я предполагаю, что слово «абстрактный» действительно должно было быть виртуальным? Если я удалю этот метод из C и удалю соответствующее ключевое слово override из D, вывод станет "Foo(B)".   -  person Fredrik Mörk    schedule 09.09.2010
comment
@Brian, @Fredrik: извините, я изменил код, чтобы немного упростить его (виртуальные методы работают так же хорошо, как и абстрактные для этой проблемы), и забыл изменить текст внизу :)   -  person Dean Harding    schedule 09.09.2010
comment
@leppie: я бы тоже не хотел писать такой код, но это унаследованный проект :-)   -  person Dean Harding    schedule 09.09.2010
comment
@leppie: ты бы не стал писать такой код? Почему бы и нет, что в этом плохого? Предположим, что Foo(A) выполняет какое-то преобразование для создания B из A, а затем вызывает Foo(B). Упс.... переполнение стека! И если пользователь вызывает Foo(new B()), он действительно вызывает Foo(A), поэтому Foo(A) должен проверить, действительно ли это B? ... какой беспорядок, и никаких предупреждений компилятора...   -  person Qwertie    schedule 29.11.2013


Ответы (5)


Ответ находится в спецификации C# раздел 7.3 и раздел 7.5.5.1

Я разбил шаги, используемые для выбора метода для вызова.

  • Сначала создается набор всех доступных членов с именем N (N=Foo), объявленных в T (T=class D), и базовых типов T (class C). Объявления, содержащие модификатор override, исключаются из набора (D.Foo(B) is exclude)

    S = { C.Foo(B) ; D.Foo(A) }
    
  • Создается набор методов-кандидатов для вызова метода. Начиная с набора методов, связанных с M, которые были найдены при предыдущем поиске элемента, набор сокращается до тех методов, которые применимы по отношению к списку аргументов AL (AL=B). Сокращение набора состоит в применении следующих правил к каждому методу T.N в наборе, где T (T=class D) — это тип, в котором объявлен метод N (N=Foo):

    • Если N неприменим в отношении AL (Раздел 7.4.2.1), то N удаляется из набора.

      • C.Foo(B) is applicable with respect to AL
      • D.Foo(A) применяется в отношении AL

        S = { C.Foo(B) ; D.Foo(A) }
        
    • Если N применим по отношению к AL (раздел 7.4.2.1), то все методы, объявленные в базовом типе T, удаляются из набора. C.Foo(B) удален из набора

          S = { D.Foo(A) }
      

В итоге победителем становится D.Foo(A).


Если абстрактный метод удален из C

Если абстрактный метод удален из C, начальный набор равен S = { D.Foo(B) ; D.Foo(A) }, а правило разрешения перегрузки должно использоваться для выбора лучший член функции в этом наборе.

В этом случае победителем является D.Foo(B).

person Julien Hoarau    schedule 09.09.2010
comment
Однако это не удаляет метод, найденный в базовом типе, который является точным совпадением с учетом списка аргументов. Это скорее следующий текст: Если N применим по отношению к A (раздел 7.4.2.1), то все методы, объявленные в базовом типе T, удаляются из набора. (здесь: msdn.microsoft.com/en-us/library/aa691356(VS .71).aspx), в котором описывается причина такого поведения. Поскольку невиртуальный метод в D является совпадением, методы из базовых типов удаляются. - person Fredrik Mörk; 09.09.2010
comment
@Fredrik, согласно спецификациям, разрешение перегрузки здесь даже не произойдет. Соответствующие разделы: 7.5.5.1 (msdn.microsoft. com/en-us/library/aa691356(v=VS.71).aspx), где рассказывается о вызовах методов. Таким образом, набор методов-кандидатов создается с использованием раздела 7.3, а затем может применяться разрешение перегрузки (7.4.2) для уменьшения набора. В этом случае поиск в соответствии с 7.3 вызовет только один (невиртуальный) метод - нет необходимости выполнять разрешение перегрузки. - person VinayC; 09.09.2010
comment
Да, это то, что я хотел сказать, но гораздо более красноречиво. - person Kieran; 09.09.2010
comment
@VinayC: да, это было моей точкой зрения. Поскольку существует только один метод сопоставления, когда кандидаты были идентифицированы компилятором (учитывая, как я интерпретирую документ, на который мы оба ссылаемся), разрешение перегрузки не требуется. Согласно спецификациям, более подходящий, но виртуальный метод в базовом классе был удален из списка в пользу соответствующего невиртуального метода в имеющемся типе. - person Fredrik Mörk; 09.09.2010
comment
@ Фредрик, я перечитал и понял твою точку зрения. Вы говорите, что правила из 7.3 фактически выдают два метода, но правила сокращения в 7.5.5.1 удаляют базовый метод. У меня сложилось впечатление, что 7.3 не будет использовать два метода, но на самом деле я ошибался. - person VinayC; 09.09.2010
comment
Ах да, теперь это имеет смысл... именно override вызывает удаление переопределения Foo() в D. - person Dean Harding; 09.09.2010
comment
Фу! Я сейчас в этой ситуации и думаю о том, чтобы поместить public abstract void Foo(A a) в базовый класс и sealed override void Foo(A a) в производный класс, просто чтобы исправить разрешение перегрузки. Но что, если бы мне не разрешили изменить базовый класс, что бы я сделал? Я мог бы добавить динамическую проверку в Foo(A a), чтобы проверить, действительно ли A является B, но это неэффективно (и обратите внимание, что если это B, я не могу передать его Foo(B)! Переполнение стека!). Я мог бы переименовать Foo(A a) в FooWithA(A a), но это какая-то ерунда... - person Qwertie; 29.11.2013

Почему компилятор выбирает версию, которая принимает A, когда B является более производным классом?

Как уже отмечали другие, компилятор делает это, потому что так говорит спецификация языка.

Это может быть неудовлетворительный ответ. Естественным продолжением было бы: «Какие принципы проектирования лежат в основе решения определить язык таким образом?»

Это часто задаваемый вопрос как в StackOverflow, так и в моем почтовом ящике. Краткий ответ: «Этот дизайн смягчает семейство ошибок Brittle Base Class».

Описание функции и почему она разработана именно так, см. в моей статье на эту тему:

http://blogs.msdn.com/b/ericlippert/archive/2007/09/04/future-breaking-changes-part-three.aspx

Дополнительные статьи на тему того, как разные языки решают проблему хрупкого базового класса, смотрите в моем архиве статей на эту тему:

http://blogs.msdn.com/b/ericlippert/archive/tags/brittle+base+classes/

Вот мой ответ на тот же вопрос прошлой недели, который очень похож на этот.

Почему подписи объявлены в базовом классе проигнорировано?

И вот еще три актуальных или дублирующих вопроса:

разрешение перегрузки C#?

Метод перегружает разрешение и головоломки Джона Скита

Почему это работает? Перегрузка метода + переопределение метода + полиморфизм

person Eric Lippert    schedule 09.09.2010
comment
Это ужасное правило. Если вы измените override на new в производном классе, компилятор будет вести себя так, как все ожидают. Я не понимаю, как изменение поведения для public override против public new имеет какое-либо отношение к проблеме хрупкого базового класса. Поскольку это переопределение, автор производного класса четко знает о наличии метода базового класса. Кого это не смущает и не раздражает? (Если вы не сбиты с толку, а? Почему бы и нет?) - person Qwertie; 29.11.2013
comment
@qwertie, если вы не понимаете, почему это смягчает проблему BBC, тогда прочитайте об этом и подумайте, пока не поймете, вот мой совет. - person Eric Lippert; 02.12.2013
comment
@Qwertie: Более того: любые знания, которыми обладает автор производного класса, не имеют отношения к этому вопросу. Соответствующей стороной является клиент автора производного класса; почему этот человек должен знать, переопределен ли метод в конкретном классе, а не в его базовом классе? Это деталь реализации, которая может быть изменена, а не часть контракта класса! - person Eric Lippert; 02.12.2013
comment
ВТФ? Вызывающий (клиент) имеет те же знания, что и автор. Он знает, что вызывает класс D. Вызывающий переходит к объявлению D, нажимает F12 и видит Foo(A a) и Foo(B a). У вызывающей стороны не больше оснований ожидать, что Foo(new B()) вызовет Foo(A a), чем у автора класса! У вызывающей стороны также нет больше оснований ожидать, что override будет вести себя иначе, чем new, чем первоначальный автор. - person Qwertie; 06.12.2013
comment
Я вроде как понимаю, о чем вы говорите, но есть несколько способов, которыми это правило может ударить вас по ноге. Рассматривали ли вы такой сценарий: автор D знает или замечает, что методы производного класса имеют приоритет над методами базового класса, поэтому он понимает, что определение Foo(A a) блокирует доступ к методу базового класса Foo(B b). Поэтому он пишет override Foo(B b) {base.Foo(b);}, чтобы избежать нежелательного поведения. Упс - не сработало. Что теперь? Я был бы удивлен, если бы вы могли найти какой-либо сценарий, в котором такое поведение является хорошим. И это не интуитивно (иначе этот вопрос не задавался бы). - person Qwertie; 06.12.2013
comment
@Qwertie: у заказчика нет таких знаний, как у автора; автор знает, какой класс содержит переопределение и почему. Более того, у автора есть выбор: переопределить в более производном классе или реализовать третий класс, который находится между базовым и производным классами, выполняющими переопределение. Теперь: следует ли при разрешении перегрузки выбирать другой метод, когда переопределение, являющееся деталью реализации, перемещается в сторону более крупного класса в иерархии? Это было бы действительно очень странно! - person Eric Lippert; 06.12.2013
comment
@Qwertie: Чтобы ответить на ваш вопрос: есть много сценариев, в которых такое поведение желательно. Это (1) когда автор производного класса знает больше, чем автор базового класса. Это почти всегда так! Автор производного класса лучше, чем автор базового класса, знает, какой должна быть семантика каждого переопределения для производного класса. - person Eric Lippert; 06.12.2013
comment
@Qwertie: И (2) сценарии хрупкого базового класса, когда автор базового класса добавляет метод после того, как автор производного класса написал производный класс. Очень странно, что этот автор, который знает меньше, чем автор производного класса, поставлен в положение, определяющее поведение производного класса при использовании клиентом, использующим производный класс. У этого автора меньше всего знаний, но вы возлагаете на него ответственность за решение! Дизайн C# смягчает этот класс сбоев. - person Eric Lippert; 06.12.2013
comment
@Qwertie: сделайте шаг назад. Поведение, которое вы порицаете, является следствием двух правил. (1) Методы в производных классах были написаны кем-то, кто знал о желаемом поведении больше, чем автор базового класса, поэтому они имеют приоритет над методами базового класса. То есть, столкнувшись с выбором, компилятор должен по умолчанию предпочесть метод более производного класса. (2) Выбор места в иерархии для переопределения виртуального метода является невидимой деталью реализации, которая может быть изменена, а не частью общедоступной поверхности класса. - person Eric Lippert; 06.12.2013
comment
@Qwertie: Теперь вполне разумно сказать, что эти правила разумны, но последствия совместной работы этих правил приводят к неожиданному выводу, поэтому мы должны отказаться от обоих правил. Все дизайны являются результатом ряда компромиссов между противоречивыми принципами. Команда разработчиков C# считает, что оба эти правила разумны и, более того, они смягчают важный класс сбоев, наблюдаемых на практике в других языках, и поэтому решили, что выгоды превышают затраты. Это не делает это правило ужасным; это делает его разумным правилом. - person Eric Lippert; 06.12.2013

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

У вас есть метод Foo, который не является виртуальным, и, следовательно, этот метод вызывается.

Эта ссылка имеет очень хорошее объяснение http://msdn.microsoft.com/en-us/library/aa645767%28VS.71%29.aspx

person Unmesh Kondolikar    schedule 09.09.2010

Итак, вот как это должно работать согласно спецификации (во время компиляции и с учетом того, что я правильно перемещался по документам):

Компилятор идентифицирует список соответствующих методов из типа D и его базовых типов на основе имени метода и списка аргументов. Это означает, что любой метод с именем Foo, принимающий один параметр типа, к которому происходит неявное преобразование из B, является допустимым кандидатом. Это приведет к следующему списку:

C.Foo(B) (public virtual)
D.Foo(B) (public override)
D.Foo(A) (public)

Из этого списка исключаются любые объявления, включающие модификатор переопределения. Это означает, что список теперь содержит следующие методы:

C.Foo(B) (public virtual)
D.Foo(A) (public)

На данный момент у нас есть список подходящих кандидатов, и теперь компилятор должен решить, что вызывать. В документе 7.5.5.1 Вызовы методов находим следующий текст:

Если N применимо по отношению к A (Раздел 7.4.2.1), то все методы, объявленные в базовом типе T, удаляются из набора.

По сути, это означает, что если в D объявлен применимый метод, любые методы из базовых классов будут удалены из списка. На данный момент у нас есть победитель:

D.Foo(A) (public)
person Fredrik Mörk    schedule 09.09.2010
comment
Да, это немного проясняет ситуацию... теперь я просто должен пообещать себе, что никогда не буду писать такой код :-) - person Dean Harding; 09.09.2010

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

public void Foo(A a){
    Console.WriteLine("Foo(A)" + a.GetType().Name);
    Console.WriteLine("Foo(A)" +a.GetType().BaseType );
}

это предположение, что я не профессионал в .Net

person Kieran    schedule 09.09.2010