Множественное наследование

Множественное наследование (Multiple inheritance)— наследование от нескольких базовых классов одновременно.

Зачем оно может использоваться

  1. Интерфейсы
    Интерфейс — специальные классы, описывающие набор методов, но не имеющие данных и реализации. Например интерфейсами являются классы:


    Тогда класс JPGPicture будет наследоваться от этих двух интерфейсов, т. е. методы обоих интерфейсов будут реализованы в классе JPGPicture.
    Интерфейсы — наиболее популярное применение множественного наследования. Возможность использования множественного наследования в виде интерфейсов есть во всех классических ООП-языках. В некоторых языках программирования (Java, C#) есть только такой вид множественного наследования.

  2. Наследование от нескольких полноценных классов
    Допустим у нас есть классы Cow и Sniper, а мы хотим получить класс, который обладает данными и методами обоих классов. Назовем его CowSniper. Таким образом у нас есть возможность «скрещивать» классы.
    img

    class CowSniper: public Cow, public Sniper {
    };
    Таким образом синтаксически множественное наследование почти не отличается от обычного.
    Заметим , что важен порядок перечисления предков, потому как в зависимости от него:

    img

Проблемы при множественном наследовании

  1. Перекрытие имен функций
    эта проблема есть как и в обычном наследовании, так и в множественном.
    Пусть в классах Cow и Sniper были методы sleep(). img Тогда код:

    CowSniper cs;
    cs.sleep(); // 2

    В строке 2 произойдет ошибка компиляции, т. к. компилятор не может выбрать какой метод sleep() ему вызывать (от Cow или от Sniper). Поэтому необходимо сообщимть ему правильный выбор: cs.Cow::sleep();

  2. Перекрытие виртуальных функций
    Теперь рассмотрим тот случай, если метод sleep() виртуальный в классах-предках.

    class Cow {
    public:
        virtual void sleep() = 0;
    };
    class Sniper {
    public:
        virtual void sleep() = 0;
    };
    class CowSniper: public Cow, public Sniper {
    public:
        virtual void sleep() {}
    };

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

    Cow & c = cs;
    Sniper & s = cs;
    c.sleep(); //3
    s.sleep(); //4

    В строках 3 и 4 вызовется один и тот же метод CowSniper::sleep();
    С одной стороны это удобно, т. к. если методы называются одинаково, то скорее всего они делают что-то похожее. Тогда перегрузив один метод мы перегрузим сразу оба.
    С другой стороны может возникнуть проблема, если класс-потомок должен реализовывать один и тот же виртуальный метод от нескольких базовых классов по-разному. Тогда такая перегрузка будет вредна, но стандартно по другому не поступить. В таких случаях используется следующий способ обойти это ограничение.
    Если есть иерархия классов A,B,C. И в классах A и B есть некоторый виртуальный метод f().
    img
    Добавим в эту иерархию еще два класса A1 и B1. В классах A1 и B1 создадим методы fA() и fB() соотвественно. Теперь в C будут методы fA() и fB(), которые необходимо перегрузить, причем код в них разный ;) img

    class A{
    public:
        virtual void f();
    };
    class B{
    public:
        virtual void f();
    };
    class A1: public A{
    public:
        virtual void f() {fA();}
        virtual void fA() = 0;
    };
    class B1: public B{
    public:
        virtual void f() {fB();}
        virtual void fB() = 0;
    };
    class C: public A1, public B1 {
    public:
        virtual void fA();
        virtual void fB();
    };

    Теперь использовать эти классы можно так:

    C  c;
    A & a = c;
    B & b = c;
    a.f(); //5
    b.f(); //6

    В строке 5 вызовется C::fA(), а в строке 6 - C::fB(). Обратите внимание на то, как перегружен метод f() в классах A1 и B1, которые ко всему прочему теперь являются абстрактными и невозможно создать их экземпляры. Цель достигнута.

  3. Представление объекта в памяти
    а) Как мы помним, в случае линейного наследования распределение полей классов для объекта класса наследника в памяти будет такое: imgimg
    При этом если мы создадим три указателя:

    C * = new C();
    B * b = c;
    A * a = c;

    То указывать они все будут на начало объекта в памяти: img
    и при этом все хорошо.

    б) Теперь перейдем к рассмотрению простейшего примера множественного наследования:
    img
    При этом если создать объект класса C, то в памяти он будет выглядеть так:
    img
    Опять попробуем создать три указателя как и в случае линейного наследования:

    C * = new C();
    B * b = c;
    A * a = c;


    Однако теперь указатели распределятся следующим образом:
    img
    Мы видим, что классы A и B фактически разделены, но ведь если они имеют виртуальные методы, то должны вызываться перегруженные виртуальные методы класса C. Для того, чтобы это происходило в каждом из них есть ссылка на таблицу виртуальных функций. Тогда получается что в классе C будет сразу два указателя на две различные таблицы виртуальных функций (кол-во указателей = кол-ву полиморфных предков). Получается, что таблиц виртуальных функций две. Это не совсем так, т. к. они просто лежат рядом в памяти, т. е. фактически таблица одна, но в ней могут быть повторения, например, виртуальный деструктор:
    img

    в) Теперь рассмотрим множественного наследования, если в графе наследования есть цикл:
    img
    В памяти объект класса D будет представлен так:
    img
    Возникает проблема дублирования полей класса A, т.к. фактически будет два разных объекта типа A. Значит при компиляции следующего кода произойдет ошибка, т.к. непонятно к какому полю обращаться.
    img

    D * d = new D();
    A * a = d;
    
    Для того, чтобы указать, какой из классов A выбрать следует написать так:
    D * d = new D();
    A * a = static_cast<B*> (d);
    
    При этом если мы хотим использовать методы класса A, так же необходимо явно указывать которого их них. Вследствие этого даные в двух объектах A в пределах одного D могут стать различными.

    Рассмотрим два случая такого наследования и посмотрим на возможные проблемы с наличием двойного объекта.
    1) Такой эффект может быть полезен, если класс A является файловым потоком, а B и C это writer и reader соответственно. А класс D читает из С и пишет в B. Очевидно, что у B и C файлы могут быть различны (скорее всего так и есть).
    2) Рассмотрим умный указатель (A = LinkCounter), который внутри себя сдержит счетчик. В таком случае в классе D возникает два счетчика, что может привести к печальным последствием, если в одном месте работать с одним из них, а в другом с другим.

    Вообще такое наследование (с циклами в графе родства) называется бриллиантовым (diamond inheritance) или Ромбовидным.

  4. Виртуальное наследование.
    Рассмотрим его на примере следующей иерархии
    img
    Синтаксически виртуальное наследование почти не отличается от множественного:

    class A{
        int k;
    };
    
    class B: public virtual A {
    };
    
    class C: public virtual A {
    };
    
    class D: public B, public C {
    };
    

    При этом следующий код будет прекрасно работать:
    D * d = new D();
    A * a = d;
    
    А все потому, что классы будут выглядеть следующим образом:
    img
    Здась может появиться проблема преобразования указателя на D к указателю на C. Но этой проблемы нет. Необходимо разобраться каким образом это реализуется.
    Дело в том, что при виртуальном наследовании добавляется виртуальная функция, возвращающая указатель на A. Фактически для программиста она не видна. Для пояснения обозначим ее за getA(). Стоит заметить что она будет различна в классах B,C и D.
    Теперь при обращении к полю из A (допустим в нем поле int k) код вида k = 10; будет автоматически преобразован в getA()->k = 10;.

    Теперь рассмотрим очередность вызова конструкторов.
    Логично предположить, что вызов конструкторов пройдет так img
    Но возникает проблема, ведь конструкторы B и C могут вызывать различные конструкторы A и с различными параметрами.
    Для определенности было введено следующее правило: Конструктор A должен быть явно вызван в конструкторе D, при этом в конструкторах B и C вызов конструктора A опустится.

    В связи с этим есть замечание: Нужно следить и понимать, что при виртуальном наследовании в конструкторах B и C может не вызваться конструктор A с разными параметрами.

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

Возможной заменой множественного наследования не от интерфейсов является агрегация:

class CowSniper {
private:
    Cow c;
    Sniper s;

};