вторник, 7 августа 2012 г.

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

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

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

19 комментариев:

  1. Прикольно. Откуда ты выкапываешь такие вещи? В жизни наверное не очень полезно (я пока не встречал в коде методов с одинаковыми названиями и разными параметрами(тип, но не количество)). Однако мозги - вставляет... Спасибо :)

    ОтветитьУдалить
  2. Пару дней назад на кухне в офисе один из камрадов такой вопрос задал. Я сразу ответил как и все, что вызовется Derived.Foo(int), потом подумал об этом чуток позже и пришел к выводу, что такое поведение вполне даже ок (о чем позже нашел подтверждение в книге C# Programming Language и на блоге Эрика, с фразой о brittle base классах).

    ОтветитьУдалить
  3. "Да, было бы очень интересным узнать о том, как эта проблема решается в других языках программирования"

    Во многих языках - путем полного отказа от ООП. Взять к примеру множество языков функционального программирования (Haskell, Erlang, ...) или к примеру Google Go. В результате код становится намного проще и понятнее, а выразительность и удобство почему-то не страдают.

    ОтветитьУдалить
  4. @afiskon: согласен, отсутствие наследование - это еще один способ решения этой проблемы как таковой. Хотя в данном случае сравнение с функциональными языками, как по мне, менее честное, поскольку мы рассматриваем чисто ОО-шную проблему.

    ОтветитьУдалить
  5. В C# такое поведение ничуть не кажется странным для тех кто читал Рихтера, а (не)виртуальность всех методов в C++ & Java упоминается у Макконнелла практически в самом начале книги
    Я давно не читал хороших статей.. видимо и не прочту :)
    Просто эта статья действительно интересна будет лишь новичкам, имхо

    P.S.: про не смешивать логику очевидно согласен )

    ОтветитьУдалить
  6. @Alexandr:
    А чем поможет добрый старина Рихтер в понимании поведения компилятора и логике команды разработчиков языка? (я не тролю, а правда не помню, чтобы у меня после прочтения его книги остались по этому поводу какие-то воспоминания).

    И при чем здесь виртуальность и невиртуальность С++ и Java и Макконнелл в придачу?

    У разработчика любого ОО языка есть проблема: в каком порядке анализировать методы классов и что важнее - точный тип переменной и точность аргументов. У каждого из этих подходов есть свои "за" и "против". Вот о них, собственно, и шла речь.

    З.Ы. Исходя из комментария я не вполне уверен, что я правильно своим постом донес до Вас ее суть.

    ОтветитьУдалить
  7. В реальности я таких проблем не видел, да и как мне кажется дизайн кривоватый, если из public либы торчит класс с виртуальными методами да еще и перегруженными.
    Т.е. хочешь переопределить имплементацию надо переопределять всё, а это не хорошо, совсем нехорошо.

    ОтветитьУдалить
  8. В реальности я таких проблем не видел, да и как мне кажется дизайн кривоватый, если из public либы торчит класс с виртуальными методами да еще и перегруженными.
    Т.е. хочешь переопределить имплементацию надо переопределять всё, а это не хорошо, совсем нехорошо.

    ОтветитьУдалить
  9. @Slava: вопрос был не в том, что в библиотеке более одного виртуального метода, как раз наоборот. Что если в базовом классе библиотеки не было никакого метода с именем Foo. И мы отнаследовались от библиотечного класса и добавили новый метод Derived.Foo(object).

    Тогда, если бы при добавлении нового виртуального метода в класс Base.Foo(int) начал бы вызываться другой метод, то добавление любого метода в базовый класс было бы вполне вероятным "критическим изменением" (breaking change).

    Для пущей убедительности, вместо int и object можно рассмотреть классы парсинга xml-документов, когда в наследнике вместо object-а будет XObject, а вместо int-а - XElement.

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

    ОтветитьУдалить
  10. Отличная статья, Сергей! Никогда не слышал, что это проблема называется "brittle base classes syndrome" и никогда не задумывался как она решается в других языках, было интересно!

    p.s. В комментариях какое-то школоло :)

    ОтветитьУдалить
  11. @Пельмешко: Саш, спасибо.

    Кстати, ты на itjam в Киев собираешься? А то это отличная возможность встретиться лично!

    ОтветитьУдалить
  12. @Salavat: Мне кажется, что у этой проблемы просто не существует решения, интуитивно понятного для всех.

    А разве решения в других языках более логичны?

    ОтветитьУдалить
  13. это, кстати, хорошо, что вы рассказали про другие языки. не только c# грешен ).
    у меня после прочтения ритхера, долго оставался осадок из-за того, что .net, очень многое делая за меня, не дает той безганичной свободы, которая есть в с++.
    но рамки задают верное направление. например, мне нравится, что нет множественного наследования, это очень упростило жизнь.

    ОтветитьУдалить
  14. Этот комментарий был удален автором.

    ОтветитьУдалить
  15. Возможно, стоит особняком упомянуть еще и C++\CLI. Вопреки моим ожиданиям, в этом языке сохранена преемственность поведения от стандартного C++, т.е. для первого примера C#-компилятор сгенерит callvirt instance void Derived::Foo(object), а C++\CLI-компилятор сгенерит соответственно callvirt instance void Derived::Foo(int32)

    ОтветитьУдалить
  16. Будь моя воля — запретил бы public virtual вообще, только protected virtual.
    Подобных проблем бы просто не возникло, да и архитектура в массе была бы куда лучше.

    ОтветитьУдалить
  17. Ну, не знаю. ИМХО подобные запреты - это уже перебор. Хотя, вот, Вячеслав недавно поделился подобными же мыслями.

    ОтветитьУдалить
  18. Я тут заметил что если написать вот такую конструкцию в derived то все работает :
    Public new void Foo(int)
    {
    base.Foo(i);
    }

    ОтветитьУдалить