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

    Существует определенный набор возможностей в любом языке программирования для понимания которых нужно просто знать, как они реализованы. Вот, например, замыкания; это не сверх сложная концепция, но знание того, как этот зверь устроен позволяет делать определенные выводы относительно поведения замыканий с переменными цикла. Тоже самое касается вызова виртуальных методов в конструкторе базового класса: здесь нет одного правильного решения и нужно просто знать, что именно решили разработчики языка и будет ли вызываться метод наследника (как в Java или C#), или же «полиморфное» поведение в конструкторе не работает и будет вызываться метод базового класса (как в С++).

    Еще одним типом проблемы у которой нет идеального решения, является совмещение перегрузки методов (overloading) и переопределения (overriding) метода. Давайте рассмотрим следующий пример на языке C#. Предположим, у нас есть пара классов, 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# может показаться неверным, но если взвесить все «за» и «против», то оно окажется вполне логичным и не таким уж и плохим.
    В любом случае, не зависимо от используемого языка программирования, совет будет один: по возможности лучше просто не смешивать перегрузку методов и их переопределение (вспомните, какой зоопарк поведения мы получили в трех довольно популярных языках программирования).
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 13

      +6
      Мне кажется если в реальном коде встречается такая ситуация, то этот код — плохой.
        +5
        да 90% вопросов на собеседовании типа что выведет этот код или как сделать используя наследование такой вывод в консоль- это отвратительный код который в жизни если встретился то надо удти убивать автора сразу.

        Просто тут сейчас это вопрос на понимание.
          0
          Ну да — это одна из тех вещей, которые надо знать, чтобы лучше понимать принципы работы той платформы, на которой ты пишешь, но которую лучше в коде не использовать.
      • UFO just landed and posted this here
          –2
          Достаточно запустить исходный пример, чтобы увидеть, что это не так.
          • UFO just landed and posted this here
          +3
          >Однако насколько логично, что без ведома разработчика класса Derived после изменений в базовом классе хорошо протестированный код вдруг перестанет работать?

          Скажите, а насколько логично, что без ведома разработчика класса Derived в нём появится переопределённый метод

          public override void Foo(int i) 
              {
                  Console.WriteLine("Derived.Foo(int)");
              }

          ?

          Вы рассматриваете две абсолютно разных ситуации, и одной из них пытаетесь объяснить вторую.

          Рассматриваемая ситуация абсурдна. В классе наследнике существует более подходящий метод. Неважно, что он переопределяет метод базового класса — метод явно объявлен в классе наследнике, а не просто скрыто унаследован от базового. По всей логике должен быть вызван именно он.
            0
            Примечание к примечанию:
            Если закомментировать метод Foo(Integer&) в классе Derived, то в С++ будет вызван Derived::Foo(Object&) (т.е. более подходящий метод базового класса не будет рассматриваться в качестве кандидата)

            Если все-таки хочется, чтобы в данном случае позвался метод базового класса, то в Derived нужно добавить:
            class Derived : public Base
            {
            using Base::Foo;
            ...
            };
            
              –3
              по-моему, в C# все реализовано правильно.
              Вы показали пример в вакууме.
              если перейти на более реальный пример, то:

              class Program
              {
                  static void Main(string[] args)
                  {
                      TextNode d = new TextNode();
                      d.Foo(d);
              
                      Node node = new TextNode();
                      node.Foo(node);
                  }
              }
              
              class Node
              {
                  public virtual void Foo(Node node)
                  {
                      Console.WriteLine("Node.Foo(Node)");
                  }
              }
              
              class TextNode : Node
              {
                  public override void Foo(Node node)
                  {
                      Console.WriteLine("TextNode.Foo(Node)");
                  }
              
                  public void Foo(TextNode textNode)
                  {
                      Console.WriteLine("TextNode.Foo(TextNode)");
                  }
              }
              


              выведет:

              TextNode.Foo(TextNode)
              TextNode.Foo(Node)

              т.к. мы создаем экземпляры TextNode, то будут вызваны именно его методы.
              а метод с object — не самый удачный пример для показа иерархии классов, где концепция виртуальных методов отрабатывает правильно в C#.
                +3
                Здесь еще не хватает вызова node.Foo(d); который выведет не то, на что можно бы рассчитывать.
                Вообще, если честно, я не понимаю зачем перегружать функцию аргументами наследуемыми один от другого. Попахивает проблемой, которая только и ждет того, чтобы всплыть.

                Кроме того, не помню в какой из книг, вроде как у банды четырех и было — есть рекомендация избегать глубоких иерархий наследования. Должен быть либо интерфейс и классы его реализующие, либо абстрактный класс. Иерархия имеет смысл среди интерфейсов и абстрактных классов, чтоб позволить разделить уровни использования конкретных классов.
                Изменения в базовом классе, который по сценарию автора «живет своей жизнью», практически гарантировано «убивают» всех наследников, и сабжевый пример тут не влияет совершенно. Если же надо все же связать такие отдельно живущие классы, то та же банда рекомендует предпочесть делегирование наследованию. Если же нужна при этом общая точка доступа — интерфейс.
                Тогда изменения в «базовом» классе будут существенно более очевидные и легкоисправимые ошибки вызывать.
                  0
                  Не совсем правильно написал, ведь в базовом классе в этом примере всего один метод.
                  Пример обратен примеру автора. Здесь совпадает направление наследования у классов и аргументов перегруженного метода. У автора оно противоположное.
                  А посему нет проблемы, что нужный метод найдется в наследованном классе раньше, чем в базовом.
                    –1
                    >Здесь еще не хватает вызова node.Foo(d); который выведет не то, на что можно бы рассчитывать.

                    node.Foo(d) выведет
                    TextNode.Foo(Node)

                    что вполне логично, т.к. мы имеем дело с конкретным наследником.

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

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

                    с чем полностью согласен.
                    поэтому необходимо либо использовать правильную архитектуру с паттернами GoF, либо использовать принцип декомпозиции вместо иерархии.
                  +3
                  Мне нравится как перегрузка с наследованием решена в C++. В перегрузке участвуют только методы того типа, у которого мы их вызываем, имена методов базового класса скрываются (hide) независимо от виртуальности. Если разработчик хочет, чтобы имя в производном классе было перегружено совместно с именем из базового класса, он это указывает явно, внесением имени в область видимости производного класса: using base::foo; (unhide). Вот и все, четко и понятно, никаких неожиданностей. Тема перегрузки и тема замещения виртуальной функции в C++ не пересекаются. Могли бы для шарпа что-то подобное придумать.

                  Пример:
                  struct base
                  {
                      void foo(int);
                  };
                  
                  struct derived : base
                  {
                      // using base::foo; // раскомментить для перегрузки с foo(int)
                      void foo(double);
                  };
                  

                  Only users with full accounts can post comments. Log in, please.