Сложное наследование: практики и подводные камни

Множественное наследование, ромбовидные иерархии, виртуальные базовые классы, порядок вызовов конструкторов и деструкторов. Плюс — приёмы, которые позволяют избежать 90% проблем на практике.

Множественное наследование Diamond problem virtual base Best practices

Три уровня сложности

В C++ наследование может быть простым, множественным и «ромбовидным». Разберём каждый сценарий и покажем, что именно может пойти не так.

Порядок вызова конструкторов и деструкторов

Одно из самых неочевидных правил C++. При наследовании конструкторы вызываются от базового класса к производному, деструкторы — в обратном порядке.

Конструкторы (сверху вниз)

1 Базовый класс — Base()
2 Промежуточные базы (в порядке объявления)
3 Производный класс — Derived()

Деструкторы (снизу вверх)

1 Производный класс — ~Derived()
2 Промежуточные базы
3 Базовый класс — ~Base()
Почему так. К моменту вызова тела конструктора производного класса базовый класс уже должен быть готов — иначе производный не сможет использовать унаследованные поля. С деструкторами наоборот: сначала «разбираем» надстройку, потом фундамент.

Ромбовидное наследование: проблема и её решение

Самая известная ловушка. Разберём её подробно, потому что она иллюстрирует, зачем вообще существует виртуальное наследование.

Проблема (без virtual)

иерархия
class Animal {          // A
public:
    int age;
};

class Mammal : public Animal { };   // B
class Bird   : public Animal { };   // C

class Bat : public Mammal, public Bird { };   // D

int main() {
    Bat b;
    b.age = 5;    // ← ОШИБКА КОМПИЛЯЦИИ!
                  // Bat содержит ДВА экземпляра Animal
                  // b.Mammal::age и b.Bird::age
}

Bat содержит две копии полей Animal: одну от Mammal, другую от Bird. Компилятор не знает, к какой обращаться. Это и есть diamond problem.

Решение: виртуальное наследование

иерархия
class Animal {          // A
public:
    int age;
};

class Mammal : virtual public Animal { };   // virtual!
class Bird   : virtual public Animal { };   // virtual!

class Bat : public Mammal, public Bird { };

int main() {
    Bat b;
    b.age = 5;    // OK: одна общая копия Animal
}

Ключевое слово virtual при наследовании заставляет компилятор хранить одну общую копию Animal для всех потомков, а не по копии на каждую ветвь.

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

Порядок инициализации при множественном наследовании

При множественном наследовании базовые классы инициализируются в порядке объявления, а не в порядке списка инициализации. Это ещё одна частая ловушка.

ловушка
class A { public: A() { cout << "A "; } };
class B { public: B() { cout << "B "; } };
class C { public: C() { cout << "C "; } };

class Derived : public A, public B, public C {
public:
    // Список инициализации — в ДРУГОМ порядке!
    Derived() : C(), B(), A() {}
};

// При создании Derived выведется: A B C — не C B A!
// Порядок определяется ОБЪЯВЛЕНИЕМ класса,
// а не списком инициализации.

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

Особенно опасно, когда один базовый класс использует данные другого. Если вы думаете, что B уже инициализирован, а он ещё нет — UB.

Лучшие практики: как избежать проблем

Собрано из реального опыта — эти правила сильно упрощают жизнь.

Виртуальный деструктор в базовом классе

Если через указатель на базовый класс удаляется объект производного класса, базовый деструктор обязан быть виртуальным. Иначе будет вызван только деструктор базы — и память, выделенная производным классом, утечёт.

правильно
class Base {
public:
    virtual ~Base() {}   // ← обязательно, если есть наследники
};

Предпочитайте композицию наследованию

Классический принцип: «has-a» предпочтительнее «is-a», если только вы не уверены, что отношение действительно является наследованием. Композиция проще, гибче, не создаёт проблем с ромбами и не требует виртуальных деструкторов.

Плохо (наследование не по делу)
не is-a
class Car : public Engine {};
// «Машина является двигателем» — абсурд
Хорошо (композиция)
has-a
class Car {
    Engine engine;   // «у машины есть двигатель»
};

Публичное наследование — только для «is-a»

public-наследование должно означать «производный класс является базовым» и может использоваться везде, где ожидается базовый. Если это не так — используйте композицию или private-наследование (редко, только для переиспользования реализации).

Не наследуйте от конкретных классов

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

абстрактный базовый класс
class Shape {
public:
    virtual double area() const = 0;   // чисто виртуальная
    virtual void draw() const = 0;
    virtual ~Shape() {}                // виртуальный деструктор
};

Избегайте множественного наследования по возможности

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

оправданное множественное наследование
class Serializable {  // интерфейс без данных
public:
    virtual string serialize() const = 0;
    virtual ~Serializable() {}
};

class Printable {     // интерфейс без данных
public:
    virtual void print() const = 0;
    virtual ~Printable() {}
};

class Document : public Serializable, public Printable {
public:
    string serialize() const override { /* ... */ }
    void print() const override { /* ... */ }
};

Всегда используйте override

Ключевое слово override сообщает компилятору: «я намереваюсь переопределить виртуальный метод базового класса». Если вы опечатаетесь в имени или забудете virtual — компилятор сразу скажет.

Опасная опечатка
компилируется!
class Base {
    virtual void print();
};

class Derived : public Base {
    void pritn();   // опечатка — это НЕ переопределение!
};
Компилятор поймал
ошибка компиляции
class Derived : public Base {
    void pritn() override;   // ошибка:
                             // «не переопределяет ни одного
                             //  виртуального метода»
};

Не вызывайте виртуальные функции из конструктора или деструктора

Во время работы конструктора базового класса виртуальная таблица ещё «не дотянулась» до производного класса — будет вызвана версия базового. То же самое с деструктором: он уже «откатился» к базовому уровню. Это классический источник трудно находимых багов.

Антипаттерны — чего избегать

«Глубокие» иерархии

Иерархия из 6–8 уровней — почти всегда признак плохого дизайна. Чем глубже дерево, тем сложнее предсказать, чей метод вызовется, и тем чаще появляются «фантомные» баги. Старайтесь ограничиваться 3–4 уровнями.

Наследование ради переиспользования кода

«У этих двух классов общий код — вынесу-ка я его в базовый и унаследую». Это почти всегда ошибка. Общая реализация — не повод для наследования. Выносите в отдельный вспомогательный класс и используйте композицию.

Невиртуальный деструктор в базовом классе

Утечка памяти, о которой вы узнаете только в момент удаления через указатель на базовый класс. И компилятор об этом молчит. Ошибка особенно опасна тем, что проявляется не всегда — только если базовый класс используется полиморфно.

Ромб без виртуального наследования

Множественное наследование с общим предком без virtual — почти всегда ошибка проектирования. Либо применяйте виртуальное наследование, либо перестройте иерархию так, чтобы ромб не возникал.

Задание

  1. Постройте иерархию Shape → Circle, Shape → Square. Сделайте Shape абстрактным с чистым виртуальным методом area(). Создайте массив Shape* и заполните его разными фигурами. Выведите площади.
  2. Добавьте в Shape виртуальный деструктор. Уберите его — что произойдёт при удалении через указатель на базу?
  3. Постройте ромб: A → B, A → C, B + C → D. Без virtual. Попробуйте обратиться к полю A через объект D — какую ошибку покажет компилятор?
  4. Добавьте virtual к наследованию от A. Убедитесь, что ошибка ушла.
  5. Спроектируйте интерфейсы Comparable и Printable без данных. Реализуйте оба в классе Money.
  6. Найдите антипаттерн в этом коде и предложите рефакторинг:
    class Logger : public std::vector<std::string> {
        // «Логгер является вектором строк»
        // — используем вектор, чтобы хранить сообщения
    };