Программа курса← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Особенности наследования в C++

Наследование в 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();     // явно

Ромб

Классическая проблема: две ветви наследуют одну базу, а третий класс – обе ветви.

A B C D

Без дополнительных мер внутри 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-деструктор
Нужен «интерфейс» абстрактный класс без полей
Нужно переиспользовать код, а не подставлять тип композиция, а не наследование
Иерархия глубже трёх уровней почти наверняка что-то пошло не так