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:
Базовая часть лежит в начале, поэтому адрес объекта 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)
{}
Порядок жёсткий:
- конструктор базы;
- поля наследника в порядке объявления;
- тело конструктора наследника.
Разрушение – строго в обратном порядке: тело деструктора наследника, его поля, деструктор базы.
В конструкторе базы производная часть ещё не существует, в деструкторе базы – уже нет. Отсюда важное следствие: вызов виртуального метода из конструктора или деструктора уходит в версию текущего класса, а не наследника. Это не баг, а единственно возможное поведение – переопределённый метод работал бы с полями, которых ещё нет. Подробнее – в уроке про виртуальные методы.
Доступ из наследника
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_;
};
Композиция скучнее, но она не тащит наружу чужой интерфейс и переживает изменения. Правило, которое стоит держать в голове: наследуйтесь ради подстановки, переиспользуйте – композицией.