Наследование в C++: чуть глубже, чем обычно

Дата: 26th Июль 2010. Автор: Jester. Рубрика: ООП, С++
Метки: , ,

Наследование – это один из механизмов объектно-ориентированного программирования.  Важно заметить, что в наше время практически каждый современный язык поддерживает эту парадигму. Но стоит сказать, что в C++ есть некоторые особенности в возможностях этого механизма.

Обычно под наследованием понимается отношение «является». Т.е. когда вы пишете (на C#):

public class B: A { /*…*/ }

или (на C++)

class B: public A { /*…*/ };

то вы говорите, что класс B является наследником A. Это также означает, что любой объект типа B также является объектом (разновидностью) типа A (но не наоборот!).

При помощи этого механизма строится иерархия классов. Но стоит с осторожностью оперировать им. Например, вы создали класс TextBox. В довесок был также создан класс TextBoxInfo, содержащий различные вспомогательные методы, которые по какой-то причине оказываются  очень полезны основному классу TextBox.  Ну и отлично: унаследуем класс TextBoxInfo для получения доступа к реализации этих самых методов:

class TextBoxInfo
 
{
 
public:
 
    TextBoxInfo(){ /*…*/ }
    virtual ~TextBoxInfo(){ /*…*/ }
 
    virtual void doSmthWithText(){ /*…*/}
    // ...
 
};
 
class TextBox: public TextBoxInfo { /*…*/ };

Все вроде бы так, но не совсем. Это неверно, по крайней мере, с логической точки зрения. Попробуем разобраться. Открытым наследованием мы, по сути, добавляем нового участника «семьи» с какими-то общими данными. Однако в нашем коде мы явно указали, что TextBox – это разновидность некой сущности TextBoxInfo, что в корне неверно. TextBoxInfo – это вспомогательный класс. Даже если он содержит необходимый функционал, то это еще не повод наследовать его.

Абзацем выше я упомянул слово «открытый», когда говорил о наследовании. Разве оно бывает каким-то иным? В C++, по крайней мере, да, бывает. А именно:

  1. Открытое (public) наследование. Как уже писалось, отражает зависимость «являться». Все члены базового класса становятся членами класса-наследника (как интерфейс, так и реализация).
  2. Закрытое (private) наследование. Об этом подробнее.
  3. Пусть имеется класс B, который открыто наследует класс A. Такое наследование дает ряд преимуществ. Одним из них является то, что можно обращаться к классам-наследникам через указатель на базовый класс, т.е.:

    A *some = new B; // все хорошо: A – базовый класс, произойдет неявное преобразование

    Но теперь проделаем небольшую корректировку (а именно заменим public-наследование на private):

    class A { /*…*/ };
     
    class B: private A { /*…*/ };

    Если попытаться написать

    A *some = new B;

    То компилятор выдаст ошибку. Поясню почему: public-наследованием вы явно говорите, что объект одного типа _ является_  разновидностью другого типа, а по поводу private такого сказать нельзя. Private-наследование используется только на этапе реализации, и оно скорее выражает зависимость «реализовано на основе». Т.е. на этапе проектирования private-наследование никоим образом не фигурирует, а появляется только на этапе написания программного кода.

    Вернемся к нашему примеру с классом TextBox. Мы не можем открыто унаследовать TextBoxInfo по логическим и идеологическим соображениям. Но мы можем написать:

    class TextBoxInfo
     
    {
     
    public:
     
        TextBoxInfo(){ /*…*/ }
        virtual ~TextBoxInfo(){ /*…*/ }
     
        virtual void doSmthWithText(){ /*…*/ }
        // …
     
    };
     
    class TextBox: private TextBoxInfo { /*…*/ };

    Таким образом, мы говорим, что класс TextBox _реализован при помощи_ класса TextBoxInfo, но при этом он не является разновидностью этого класса.

    Стоит отметить, что private-наследование в большинстве случаев можно заменить обычным внедрением, т.е. созданием члена-указателя на нужный класс.

  4. Защищенное (protected) наследование – очень редко встречающийся на практике вид наследования. При protected-наследовании все члены с модификатором public наследуются с модификатором protected (т.е. все открытые члены базового класса становятся защищенными членами класса-наследника).

Все, что было описано выше, относится к одиночному наследованию. Однако некоторые языки также поддерживают множественное наследование.  C++ в этом плане более свободный, потому что он позволяет сделать n-ное количество наследований у любого класса или структуры (в то время как, например, в Java или C# множественно наследовать можно только интерфейсы).

С одной стороны множественное наследование позволяет с большим простором строить иерархии классов, но с другой есть ряд опасных моментов. Традиционный (который описывается во всех книжках по ООП):

class A
 
{
 
public:
 
    void doSmth(){ /*…*/ }
    // …
 
};
 
class  B
 
{
 
public:
 
    void doSmth(){ /*…*/ }
    void doSmthElse(){ /*…*/ }
    // …
 
};
 
class C: public A, public B { /*…*/ };

Как вы уже догадались проблема в совпадающих именах методов. При вызове C::doSmth() появится неопределенное поведение, т.к., по идее, не понятно, какой экземпляр метода вызвать: из класса A или класса B. Одно из решений – это явно указать, что вы хотите использовать именно какую-то конкретную реализацию метода:

C some;
 
some.B::doSmth();

Приемлемое решение проблемы, но только в том случае, когда doSmth объявлена как public в интересующем нас классе.

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

Проблема совпадения имен при наследовании – это грубейшая упущение, которое происходит еще на этапе проектирования. При тщательном и грамотном проектировании таких оплошностей, как правило, возникать не должно.

Приведем один из примеров «полезного» применения множественного наследования. Вернемся к нашему классу TextBox. Предположим, что существует класс (интерфейс) ITextArea, который является родителем для всех основных визуальных компонентов, использующих текстовые поля.

class ITextArea
 
{
 
public:
 
    virtual void setText( std::string str ) = 0;
    virtual int getTextSize() = 0;
    // …
 
};
 
class TextBoxInfo{ /*…*/ }; // различные функции, помогающие в реализации интерфейса
 
class TextBox: public ITextArea, private TextBoxInfo{ /*…*/ };

В этом примере мы добились довольно элегантного результата: объединили открытое наследование   _интерфейса_ с закрытым наследованием _реализации_. Данный пример показывает, что множественное наследование не такое вселенское зло, каким его многие считают. При нужном подходе можно сделать всю иерархию, основанную даже на множественном наследовании более понятной и логичной.

Напоследок хотелось бы подчеркнуть важность механизма наследования в рамках парадигмы ООП. Конечно, можно стараться писать проекты без использования наследования. Но при таком раскладе дел можно только усложнить разработку и ухудшить читаемость  кода. А наследование приучает к так называемому «повторному использованию» кода и в некотором смысле ликвидирует его дублирование.

  1. концероген:

    я ни**я не понел!!1

  2. Спасибо за блог, очень интересный подход, подписался на ваш блог, буду заходить чаще.

  3. Panther:

    концероген, неудивительно :)

  4. Dmitry:

    Здравствуйте!
    Как разрешить неоднозначность вызова одноименных функции в производном классе при помощи квалификатора понятно.

    А вот как для разрешения противоречия при множественном наследовании применить виртуальные функции — не понял. Можете привести пример?

    • class A
      {
      int a;
      public:
      void foo(){ a = 0;};
      };

      class B1 : virtual public A
      {
      int b1;
      };

      class B2 : virtual public A
      {
      int b2;
      };
      class C : public B1, public B2
      {
      int c;
      };

      int _tmain(int argc, _TCHAR* argv[])
      {
      С c;
      c.foo();

      return 0;
      }

      Без виртуального наследования вызов c.foo() привел бы к неопределенному поведению, а скорее всего вызвал бы ошибку на этапе компиляции. С виртуальным же наследованием компилятор проведет все необходимые операции по приведению типов и код успешно исполнится.
      Похожая иерархия (называется «ромбовидной») применяется в стандартной библиотеке C++, только вместо A, B1, B2 и C там участвуют шаблонные классы basic_ios, basic_istream, basic_ostream и basic_iostream.