Существует определенный набор возможностей в любом языке программирования для понимания которых нужно просто знать, как они реализованы. Вот, например, замыкания; это не сверх сложная концепция, но знание того, как этот зверь устроен позволяет делать определенные выводы относительно поведения замыканий с переменными цикла. Тоже самое касается вызова виртуальных методов в конструкторе базового класса: здесь нет одного правильного решения и нужно просто знать, что именно решили разработчики языка и будет ли вызываться метод наследника (как в Java или C#), или же «полиморфное» поведение в конструкторе не работает и будет вызываться метод базового класса (как в С++).
Еще одним типом проблемы у которой нет идеального решения, является совмещение перегрузки методов (overloading) и переопределения (overriding) метода. Давайте рассмотрим следующий пример. Предположим, у нас есть пара классов, Base и Derived, с виртуальным методом Foo(int) и невиртуальным методом Foo(object) в классе Derived:
class Base
{
public virtual void Foo(int i)
{
Console.WriteLine("Base.Foo(int)");
}
}
class Derived : Base
{
public override void Foo(int i)
{
Console.WriteLine("Derived.Foo(int)");
}
public void Foo(object o)
{
Console.WriteLine("Derived.Foo(object)");
}
}
Вопрос заключается в том, какой метод вызовется в следующем случае:
int i = 42;
Derived d = new Derived();
d.Foo(i);
Первым, и вполне разумным предположением является то, что вызовется метод Derived.Foo(int), ведь 42 – это int, а класс Derived содержит метод Foo(int). Однако на самом деле это не так и будет вызван метода Derived.Foo(object).
Конечно, умный народ сразу полезет в спецификацию и даст следующее заключение: компилятор, дескать, трактует объявление и переопределение метода по разному и он, чертяка, вначале ищет подходящий метод в классе текущей переменной (т.е. в классе Derived) и если подходящая перегрузка будет найдена (даже если понадобится неявное приведение типов), то он на этом и успокоится и рассматривать базовые классы (т.е. класс Base) не будет, даже если там есть более подходящая версия метода, переопределяемая наследником.
Однако в данном случае интересен не просто факт того, что объявление и переопределение методов трактуется по разному и что методы базового класса являются «методами» второго сорта, и компилятор анализирует их во вторую очередь, сколько причины того, что компилятор (точнее его разработчики) решили реализовать именно такое поведение.
Чтобы ответить на вопрос о том, насколько текущее поведение логично давайте сделаем шаг назад и рассмотрим такой случай. Предположим, что в нашей иерархии классов есть лишь один метод Foo(object), и расположен он в классе Derived:
class Base {}
class Derived : Base
{
public void Foo(object o)
{
Console.WriteLine("Derived.Foo(object)");
}
}
Да, не сильно полезная иерархия классов, но тем не менее. Самое главное в ней то, что ни у кого не вызовет вопросов, какой вызов Foo будет вызван в следующем случае (вариант-то всего один): new Derived().Foo(42).
Но давайте предположим, что разработкой классов Base и Derived занимаются разные организации или хотя бы разные разработчики. Поскольку разработчик класса Base не очень-то знает о том, что именно делает разработчик класса Derived, то в одни прекрасный момент он может добавить метод Foo в базовый класс без ведома разработчиков класса наследника:
class Base
{
public virtual void Foo(int i)
{
Console.WriteLine("Base.Foo(int)");
}
}
Если следовать здравому смыслу, который говорил в нас при ответе на исходный вопрос, то у нас появляется более подходящая перегрузка метода Foo и следующий код: new Derived().Foo(42) теперь должен приводить к вызову метода базового класса и выводить Base.Foo(int). Однако насколько логично, что без ведома разработчика класса Derived после изменений в базовом классе хорошо протестированный код вдруг перестанет работать? Конечно, можно было бы сказать, что давайте в этом случае не будем вызывать метод базового класса и будем вызывать его только при наличии перегрузки в классе Derived. Но это поведение будет еще более странным.
Данная проблема известна в широких кругах читателей спецификации языка C# и блога Эрика Липперта, как проблема «хрупких базовых классов» (brittle base classes syndrome), которую большинство разработчиков языков программирования стараются как-то решить. В данном конкретном случае она решается тем, что компилятор вначале анализирует методы непосредственно объявленные в классе используемой переменной и лишь при отсутствии подходящего метода рассматривает методы, объявленные в базовых классах.
А как насчет других языков программирования?
Да, было бы очень интересным узнать о том, как эта проблема решается в других языках программирования, например, в C++, Java или, может быть, в Eiffel-е (по мнению многих самом навороченном ОО языке программирования).
Давайте я начну с конца, поскольку так будет немного проще. В Eiffel-е проблема решается очень просто: несмотря на множество тру ОО-шных фишек в Eiffel-е просто нет перегрузки методов и вы не можете объявить в наследнике метод с тем же именем, что и метод базового класса. Это значит, что диагностика этой проблемы переносится на время компиляции и просто не существует во время исполнения. (Кстати, хотя это звучит смешно, но это весьма эффективный способ борьбы со многими проблемами; тот же Eiffel успешно решает ряд нетривиальных проблем просто тем, что он их не допускает. И хотя такой подход далеко не идеален, иногда он вполне может применяться к решению многих проблем доменной области: иногда проще запретить некоторую возможность для пользователя нежели убить полгода на ее решение).
ПРИМЕЧАНИЕ
На самом деле подобный трюк используется не только в языке Eiffel; так, например, в языке C# существует некоторая проблема с виртуальными событиями, которая решается в VB.NET весьма элегантно – виртуальные события в нем просто запрещены.
В Java и С++ дела обстоят несколько иначе и связано это, прежде всего с тем, каким образом в этих языках декларируется переопределение метода в классе наследнике. В этих языках применяется разный подход к виртуальности по умолчанию (в Java все методы по умолчанию виртуальные, а в С++ виртуальность метода нужно явно декларировать явно), но изначально в них применялся один и тот же подход к переопределению виртуальных методов в классе наследнике:
class Object {};
class Integer : public Object {};
class Base
{
public:
virtual void Foo(Integer& i) { cout<<"Base::Foo(Integer&)"<<endl; }
};
class Derived : public Base
{
public:
// Аналогично:
// virtual void Foo(Integer& i)
// или
// void Foo(Integer& i) override // C++11
// Никаких дополнительных ключевых слов!
void Foo(Integer& i) { cout<<"Derived::Foo(Integer)"<<endl; }
void Foo(Object& o) {cout<<"Derived::Foo(Object)"<<endl; }
};
Для переопределения метода в языках Java и C++ не требуется использования каких-либо дополнительных ключевых слов: достаточно в классе наследнике реализовать метод с той же самой сигнатурой. А поскольку с точки зрения синтаксиса переопределяемый метод никак не отличается от объявления нового метода (сравните два метода класса Derived), то и поведение здесь будет не таким, как в языке C#:
Integer i;
Derived *pd = new Derived;
pd->Foo(i);
В данном случае, как и ожидали мы изначально, будет вызван метод Foo(Integer&).
В языке Java и в языке C++ позднее появилась возможность у программиста более точно передавать свои намерения с точки зрения переопределения методов в наследнике. В Java, начиная с 5-й версии появилась специальная аннотация - @Override, а в С++11 появилось новое ключевое слово “override”. Однако, по понятным причинам, поведение в этих языках осталось неизменным.
ПРИМЕЧАНИЕ
Кстати, за подробностями о том, что нового появилось в С++11 по сравнению с предыдущим стандартом, можно найти в переводе FAQ-а Бьярне Страуструпа: C++11 FAQ.
Правда на этом схожесть языков Java и С++ заканчиваются. Если закомментировать метод Foo(Integer&) в классе Derived, то в С++ будет вызван Derived::Foo(Object&) (т.е. более подходящий метод базового класса не будет рассматриваться в качестве кандидата), а в Java – Base.Foo(Integer).
Заключение
Разрешение перегрузки методов (overload resolution) – это довольно интересная штука сама по себе (вот один из этюдов Nikov-а в качестве подтверждения), но она еще усложняется если добавить к ней наследование. С одной стороны текущее поведение в языке C# может показаться неверным, но если взвесить все «за» и «против», то оно окажется вполне логичным и не таким уж и плохим.
В любом случае, не зависимо от используемого языка программирования, совет будет один: по возможности лучше просто не смешивать перегрузку методов и их переопределение (вспомните, какой зоопарк поведения мы получили в трех довольно популярных языках программирования).
Прикольно. Откуда ты выкапываешь такие вещи? В жизни наверное не очень полезно (я пока не встречал в коде методов с одинаковыми названиями и разными параметрами(тип, но не количество)). Однако мозги - вставляет... Спасибо :)
ОтветитьУдалитьProgramming Stuff: Перегрузка И Наследование >>>>> Download Now
Удалить>>>>> Download Full
Programming Stuff: Перегрузка И Наследование >>>>> Download LINK
>>>>> Download Now
Programming Stuff: Перегрузка И Наследование >>>>> Download Full
>>>>> Download LINK pa
Пару дней назад на кухне в офисе один из камрадов такой вопрос задал. Я сразу ответил как и все, что вызовется Derived.Foo(int), потом подумал об этом чуток позже и пришел к выводу, что такое поведение вполне даже ок (о чем позже нашел подтверждение в книге C# Programming Language и на блоге Эрика, с фразой о brittle base классах).
ОтветитьУдалить"Да, было бы очень интересным узнать о том, как эта проблема решается в других языках программирования"
ОтветитьУдалитьВо многих языках - путем полного отказа от ООП. Взять к примеру множество языков функционального программирования (Haskell, Erlang, ...) или к примеру Google Go. В результате код становится намного проще и понятнее, а выразительность и удобство почему-то не страдают.
@afiskon: согласен, отсутствие наследование - это еще один способ решения этой проблемы как таковой. Хотя в данном случае сравнение с функциональными языками, как по мне, менее честное, поскольку мы рассматриваем чисто ОО-шную проблему.
ОтветитьУдалитьВ C# такое поведение ничуть не кажется странным для тех кто читал Рихтера, а (не)виртуальность всех методов в C++ & Java упоминается у Макконнелла практически в самом начале книги
ОтветитьУдалитьЯ давно не читал хороших статей.. видимо и не прочту :)
Просто эта статья действительно интересна будет лишь новичкам, имхо
P.S.: про не смешивать логику очевидно согласен )
@Alexandr:
ОтветитьУдалитьА чем поможет добрый старина Рихтер в понимании поведения компилятора и логике команды разработчиков языка? (я не тролю, а правда не помню, чтобы у меня после прочтения его книги остались по этому поводу какие-то воспоминания).
И при чем здесь виртуальность и невиртуальность С++ и Java и Макконнелл в придачу?
У разработчика любого ОО языка есть проблема: в каком порядке анализировать методы классов и что важнее - точный тип переменной и точность аргументов. У каждого из этих подходов есть свои "за" и "против". Вот о них, собственно, и шла речь.
З.Ы. Исходя из комментария я не вполне уверен, что я правильно своим постом донес до Вас ее суть.
В реальности я таких проблем не видел, да и как мне кажется дизайн кривоватый, если из public либы торчит класс с виртуальными методами да еще и перегруженными.
ОтветитьУдалитьТ.е. хочешь переопределить имплементацию надо переопределять всё, а это не хорошо, совсем нехорошо.
В реальности я таких проблем не видел, да и как мне кажется дизайн кривоватый, если из public либы торчит класс с виртуальными методами да еще и перегруженными.
ОтветитьУдалитьТ.е. хочешь переопределить имплементацию надо переопределять всё, а это не хорошо, совсем нехорошо.
@Slava: вопрос был не в том, что в библиотеке более одного виртуального метода, как раз наоборот. Что если в базовом классе библиотеки не было никакого метода с именем Foo. И мы отнаследовались от библиотечного класса и добавили новый метод Derived.Foo(object).
ОтветитьУдалитьТогда, если бы при добавлении нового виртуального метода в класс Base.Foo(int) начал бы вызываться другой метод, то добавление любого метода в базовый класс было бы вполне вероятным "критическим изменением" (breaking change).
Для пущей убедительности, вместо int и object можно рассмотреть классы парсинга xml-документов, когда в наследнике вместо object-а будет XObject, а вместо int-а - XElement.
Так что, с одной стороны, да, это кривоватый дизайн, а с другой стороны - вполне валидный кейс.
Отличная статья, Сергей! Никогда не слышал, что это проблема называется "brittle base classes syndrome" и никогда не задумывался как она решается в других языках, было интересно!
ОтветитьУдалитьp.s. В комментариях какое-то школоло :)
@Пельмешко: Саш, спасибо.
ОтветитьУдалитьКстати, ты на itjam в Киев собираешься? А то это отличная возможность встретиться лично!
да, .net иногда удивляет
ОтветитьУдалить@Salavat: Мне кажется, что у этой проблемы просто не существует решения, интуитивно понятного для всех.
ОтветитьУдалитьА разве решения в других языках более логичны?
это, кстати, хорошо, что вы рассказали про другие языки. не только c# грешен ).
ОтветитьУдалитьу меня после прочтения ритхера, долго оставался осадок из-за того, что .net, очень многое делая за меня, не дает той безганичной свободы, которая есть в с++.
но рамки задают верное направление. например, мне нравится, что нет множественного наследования, это очень упростило жизнь.
Этот комментарий был удален автором.
ОтветитьУдалитьВозможно, стоит особняком упомянуть еще и C++\CLI. Вопреки моим ожиданиям, в этом языке сохранена преемственность поведения от стандартного C++, т.е. для первого примера C#-компилятор сгенерит callvirt instance void Derived::Foo(object), а C++\CLI-компилятор сгенерит соответственно callvirt instance void Derived::Foo(int32)
ОтветитьУдалитьБудь моя воля — запретил бы public virtual вообще, только protected virtual.
ОтветитьУдалитьПодобных проблем бы просто не возникло, да и архитектура в массе была бы куда лучше.
Ну, не знаю. ИМХО подобные запреты - это уже перебор. Хотя, вот, Вячеслав недавно поделился подобными же мыслями.
ОтветитьУдалитьЯ тут заметил что если написать вот такую конструкцию в derived то все работает :
ОтветитьУдалитьPublic new void Foo(int)
{
base.Foo(i);
}
Programming Stuff: Перегрузка И Наследование >>>>> Download Now
ОтветитьУдалить>>>>> Download Full
Programming Stuff: Перегрузка И Наследование >>>>> Download LINK
>>>>> Download Now
Programming Stuff: Перегрузка И Наследование >>>>> Download Full
>>>>> Download LINK 29