Перегрузка и наследование

в 18:29, , рубрики: .net, c++, Cpp, csharp, метки: ,

Существует определенный набор возможностей в любом языке программирования для понимания которых нужно просто знать, как они реализованы. Вот, например, замыкания; это не сверх сложная концепция, но знание того, как этот зверь устроен позволяет делать определенные выводы относительно поведения замыканий с переменными цикла. Тоже самое касается вызова виртуальных методов в конструкторе базового класса: здесь нет одного правильного решения и нужно просто знать, что именно решили разработчики языка и будет ли вызываться метод наследника (как в 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 Base
{
public:
	virtual void Foo(Integer& i) {}
};

class Derived : public Base
{
public:
	// Аналогично: 
	// virtual void Foo(Integer& i)
	// или
	// void Foo(Integer& i) override // C++11
	// Никаких дополнительных ключевых слов!
	void Foo(Integer& i) {}

	void Foo(Object& o) {}
};

Для переопределения метода в языках 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# может показаться неверным, но если взвесить все «за» и «против», то оно окажется вполне логичным и не таким уж и плохим.
В любом случае, не зависимо от используемого языка программирования, совет будет один: по возможности лучше просто не смешивать перегрузку методов и их переопределение (вспомните, какой зоопарк поведения мы получили в трех довольно популярных языках программирования).

Автор: SergeyT

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js