Три уровня сложности
В C++ наследование может быть простым, множественным и «ромбовидным». Разберём каждый сценарий и покажем, что именно может пойти не так.
Порядок вызова конструкторов и деструкторов
Одно из самых неочевидных правил C++. При наследовании конструкторы вызываются от базового класса к производному, деструкторы — в обратном порядке.
Конструкторы (сверху вниз)
Base()Derived()Деструкторы (снизу вверх)
~Derived()~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», если только вы не уверены, что отношение действительно является наследованием. Композиция проще, гибче, не создаёт проблем с ромбами и не требует виртуальных деструкторов.
class Car : public Engine {};
// «Машина является двигателем» — абсурд
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 — почти всегда
ошибка проектирования. Либо применяйте виртуальное наследование, либо перестройте
иерархию так, чтобы ромб не возникал.
Задание
- Постройте иерархию
Shape → Circle,Shape → Square. СделайтеShapeабстрактным с чистым виртуальным методомarea(). Создайте массивShape*и заполните его разными фигурами. Выведите площади. - Добавьте в
Shapeвиртуальный деструктор. Уберите его — что произойдёт при удалении через указатель на базу? - Постройте ромб:
A→B,A→C,B+C→D. Безvirtual. Попробуйте обратиться к полюAчерез объектD— какую ошибку покажет компилятор? - Добавьте
virtualк наследованию отA. Убедитесь, что ошибка ушла. - Спроектируйте интерфейсы
ComparableиPrintableбез данных. Реализуйте оба в классеMoney. - Найдите антипаттерн в этом коде и предложите рефакторинг:
class Logger : public std::vector<std::string> { // «Логгер является вектором строк» // — используем вектор, чтобы хранить сообщения };