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

Наследование

This lesson is not translated yet – the Russian original is below.

Наследование – механизм, позволяющий одному классу получить данные и поведение другого. В C++ он устроен прямолинейнее, чем в большинстве языков, и именно поэтому требует аккуратности.

Синтаксис

class Person {
public:
    Person(const std::string& name) : name_(name) {}
    const std::string& Name() const { return name_; }

protected:
    std::string name_;
};

class Student : public Person {
public:
    Student(const std::string& name, int year)
        : Person(name)      // базовую часть инициализируем явно
        , year_(year)
    {}

    int Year() const { return year_; }

private:
    int year_;
};

Объект Student содержит внутри себя полноценный объект Person:

часть Person name_ собственное year_ объект Student

Базовая часть лежит в начале, поэтому адрес объекта Student совпадает с адресом его части Person – на этом и держится подстановка одного вместо другого.

Модификаторы наследования

class Derived : public Base { };      // публичное
class Derived : protected Base { };   // защищённое
class Derived : private Base { };     // приватное
Наследование Что происходит с публичными членами базы
public остаются публичными: снаружи Derived является Base
protected становятся защищёнными: видны наследникам, не видны снаружи
private становятся приватными: снаружи связь с Base не видна

Умолчание зависит от наследника: у struct – public, у class – private.

struct Student : Person { };        // публичное
class Student : Person { };         // приватное – почти наверняка не то, что имелось в виду

Приватное наследование выражает не «is-a», а «реализован через». Разница практическая: Person* p = &student; при приватном наследовании не скомпилируется, потому что снаружи связи нет. В 99% случаев нужен public – пишите его явно, даже там, где он и так по умолчанию.

Конструирование и разрушение

Конструктор наследника обязан сначала построить базовую часть. Если не указать базу в списке инициализации, вызовется её конструктор по умолчанию; если такого нет – ошибка компиляции.

Student::Student(const std::string& name, int year)
    : Person(name)      // явно: у Person нет конструктора по умолчанию
    , year_(year)
{}

Порядок жёсткий:

  1. конструктор базы;
  2. поля наследника в порядке объявления;
  3. тело конструктора наследника.

Разрушение – строго в обратном порядке: тело деструктора наследника, его поля, деструктор базы.

В конструкторе базы производная часть ещё не существует, в деструкторе базы – уже нет. Отсюда важное следствие: вызов виртуального метода из конструктора или деструктора уходит в версию текущего класса, а не наследника. Это не баг, а единственно возможное поведение – переопределённый метод работал бы с полями, которых ещё нет. Подробнее – в уроке про виртуальные методы.

Доступ из наследника

class Person {
public:
    std::string name_;      // видно всем
protected:
    int age_;               // видно наследникам
private:
    std::string passport_;  // видно только Person
};

class Student : public Person {
    void Show() {
        std::cout << name_;      // ок
        std::cout << age_;       // ок
        std::cout << passport_;  // ошибка компиляции
    }
};

protected – это компромисс, и злоупотреблять им не стоит: защищённое поле фактически становится публичным интерфейсом для всех будущих наследников, и менять его так же тяжело, как публичное. Обычно правильнее держать поля приватными, а наследникам давать protected-методы.

Сокрытие имён

Здесь C++ ведёт себя неожиданно: метод наследника с тем же именем скрывает все перегрузки базового, даже с другими параметрами.

class Base {
public:
    void Print(int value);
    void Print(const std::string& text);
};

class Derived : public Base {
public:
    void Print(double value);      // скрывает обе версии Base::Print
};

Derived d;
d.Print(42);                        // вызовется Print(double)! int преобразуется в double
d.Print("hello");                   // ошибка компиляции: Print(const string&) не виден

Поиск имени идёт по областям видимости снизу вверх и останавливается на первой, где имя нашлось. Только потом среди найденного выбирается перегрузка.

Вернуть скрытые перегрузки можно объявлением using:

class Derived : public Base {
public:
    using Base::Print;      // все версии Base::Print снова видны
    void Print(double value);
};

Подстановка

Публичное наследование даёт главное: объект наследника можно использовать там, где ждут базовый.

void Greet(const Person& p) {
    std::cout << "Здравствуйте, " << p.Name() << '\n';
}

Student s("Иван", 2);
Greet(s);                   // работает: Student есть Person

Работает это для ссылок и указателей. А вот копирование в объект базового класса «обрезает» наследника:

Person p = s;               // срезка: скопирована только часть Person

Это срезка объекта (object slicing) – одна из самых коварных ошибок C++, потому что она не даёт ни ошибки, ни предупреждения. Разберём её подробно в уроке про особенности наследования.

Когда наследование не нужно

Наследование – самая сильная связь между классами: наследник знает про базу всё, что доступно protected, и ломается при любом изменении её устройства. Прежде чем наследовать, проверьте отношение вслух.

  • «Студент есть человек» – наследование.
  • «Машина есть двигатель» – нет, машина содержит двигатель. Здесь нужно поле.
  • «Стек есть вектор» – нет, стек использует вектор. Иначе стеку достанется insert в середину, и инвариант «доступ только к вершине» рухнет.
class Engine { /* ... */ };

class Car {
public:
    void Start() { engine_.Start(); }   // композиция: делегируем, что нужно

private:
    Engine engine_;
};

Композиция скучнее, но она не тащит наружу чужой интерфейс и переживает изменения. Правило, которое стоит держать в голове: наследуйтесь ради подстановки, переиспользуйте – композицией.