This lesson is not translated yet – the Russian original is below.
Наследование в C++ устроено ближе к железу, чем в языках со сборщиком мусора, и из этого следует несколько особенностей, которых там просто нет. Все они – про то, что объект здесь значение, а не ссылка.
Срезка объекта
Самая коварная ошибка модуля, потому что не даёт ни ошибки, ни предупреждения.
class Shape {
public:
virtual double Area() const { return 0; }
};
class Circle : public Shape {
public:
double Area() const override { return 3.14159 * r_ * r_; }
private:
double r_ = 2;
};
Circle c;
Shape s = c; // срезка
std::cout << s.Area(); // 0, а не 12.56
s имеет тип Shape и размер Shape – полю r_ в ней негде лежать. Копируется базовая часть,
vptr выставляется на таблицу Shape, и объект перестаёт быть кругом.
Особенно легко наступить на это в параметрах и контейнерах:
void Draw(Shape s); // срезка при каждом вызове
void Draw(const Shape& s); // правильно
std::vector<Shape> shapes; // хранит Shape, срезает всё, что кладут
std::vector<std::unique_ptr<Shape>> ok;// правильно
Правило: полиморфные объекты передают и хранят только по ссылке или через указатель. Чтобы срезка стала невозможной, базовый класс делают некопируемым: Shape(const Shape&) = delete; – тогда Shape s = c; перестанет компилироваться.
Множественное наследование
C++ разрешает наследовать от нескольких классов сразу:
class Serializable { /* ... */ };
class Loggable { /* ... */ };
class Document : public Serializable, public Loggable { };
Пока базы – чистые интерфейсы (без полей), проблем нет. Как только у баз появляются данные, начинаются вопросы: чей метод вызывается при совпадении имён, в каком порядке идут конструкторы, где что лежит в памяти.
Совпадение имён разрешают квалификацией:
class A { public: void Run(); };
class B { public: void Run(); };
class C : public A, public B { };
C c;
c.Run(); // ошибка: неоднозначность
c.A::Run(); // явно
Ромб
Классическая проблема: две ветви наследуют одну базу, а третий класс – обе ветви.
Без дополнительных мер внутри D окажутся две копии A:
class A { public: int value_; };
class B : public A { };
class C : public A { };
class D : public B, public C { };
D d;
d.value_ = 1; // ошибка: какое из двух?
d.B::value_ = 1; // приходится уточнять
Решение – виртуальное наследование:
class B : virtual public A { };
class C : virtual public A { };
class D : public B, public C { };
D d;
d.value_ = 1; // теперь один экземпляр A, неоднозначности нет
Цена заметная: раскладка объекта усложняется (доступ к полям A идёт через смещение, известное
только во время работы), а инициализирует виртуальную базу самый производный класс – D, а не
B и C.
Множественное наследование данных – та причина, по которой Java и C# от него отказались, оставив только интерфейсы. В C++ оно осталось, но в живом коде почти всегда сводится к безопасному варианту: одна база с данными плюс сколько угодно чистых интерфейсов.
final
C++11 позволяет запретить дальнейшее расширение:
class Widget final { }; // от Widget наследовать нельзя
class Base {
public:
virtual void Draw();
};
class Derived : public Base {
public:
void Draw() override final; // переопределять дальше нельзя
};
Помимо выражения намерения, final даёт компилятору право на девиртуализацию: если наследника
быть не может, фактический тип известен на этапе компиляции, и косвенный вызов превращается в
прямой, а то и встраивается.
Виртуальный конструктор
Конструктор виртуальным быть не может: чтобы выбрать реализацию через таблицу, нужен объект, а
его ещё нет. Но задача «создать копию, не зная точного типа» встречается, и решается идиомой
Clone:
struct Shape {
virtual Shape* Clone() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
explicit Circle(double r) : r_(r) {}
Shape* Clone() const override { return new Circle(*this); }
double r_;
};
void Copy(const Shape& s) {
Shape* copy = s.Clone(); // полная копия, тип выбран объектом
// ...
delete copy;
}
Внутри Clone тип уже конкретен, поэтому работает обычный конструктор копирования.
Заметьте, что Circle::Clone возвращает Shape*, хотя мог бы Circle*, – C++ разрешает сузить возвращаемый тип переопределённого метода до наследника (ковариантный возврат). Это удобно, когда конкретный тип у вызывающего известен.
Что стоит держать в голове
| Ситуация | Что делать |
|---|---|
| Полиморфный объект передают в функцию | только const T& или T*, никогда по значению |
| Класс предназначен для наследования | виртуальный деструктор |
| Класс не предназначен для наследования | final или невиртуальный protected-деструктор |
| Нужен «интерфейс» | абстрактный класс без полей |
| Нужно переиспользовать код, а не подставлять тип | композиция, а не наследование |
| Иерархия глубже трёх уровней | почти наверняка что-то пошло не так |