Наследование — это способ организовывать иерархии классов. При этом класс-наследник приобретает поля и функции базового класса, модифицируя их область видимости.
Язык C++ — один из немногих языков с множественным наследованием: у класса может быть несколько базовых классов. Множественное наследование считается сложным (и не всегда оправданным). Мы не будем его здесь рассматривать. В этом параграфе мы познакомимся с публичным одиночным наследованием.
Наследование
Сначала приведём синтаксические детали. Пусть есть некоторый класс A
.
class A {
private:
int x;
public:
void Func1();
void Func2();
};
В нашем классе A
для примера объявлено приватное поле x
и публичные функции Func1
и Func2
. Пусть их реализация написана где-то отдельно. Публично унаследуем от этого класса новый класс B
:
class B: public A {
private:
int y;
public:
void Func2();
void Func3();
};
Технически класс B
включает в себя подобъект класса A
. Класс B
приобретает поля и функции базового класса A
, возможно, меняя их уровень доступа.
int main() {
B b;
b.Func1(); // унаследована от A
b.Func2(); // переопределена в классе B
b.A::Func2(); // версия Func2 из класса A
b.Func3(); // определена в классе B
}
Приватное поле x
в функциях класса B
оказывается недоступным (как и в любом другом месте кода), однако оно хранится внутри объекта типа B
и может быть изменено функциями из A
:
#include <iostream>
int main() {
std::cout << sizeof(A) << "\n"; // 4 байта (x)
std::cout << sizeof(B) << "\n"; // 8 байт (x и у)
}
Самое заманчивое, что тип B
может быть приведён к типу A
. Поэтому объект класса B
может использоваться везде, где ожидается A
:
void DoSomething(const A&);
int main() {
B b;
DoSomething(b); // ok
}
Отнаследуемся от класса Logger
из предыдущего параграфа, чтобы посмотреть, как рождается и умирает объект базового класса.
#include <iostream>
class InheritedLogger: public Logger {
public:
InheritedLogger() {
std::cout << "InheritedLogger()\n";
}
~InheritedLogger() {
std::cout << "~InheritedLogger()\n";
}
};
int main() {
InheritedLogger x;
}
Вывод программы будет таким:
Logger(): 1 InheritedLogger() ~InheritedLogger() ~Logger(): 1
Здесь перед входом в тело конструктора инициализируется неявный объект базового класса, а затем уже выполняется код конструктора класса-наследника. В деструкторе всё происходит наоборот. Это очень похоже на поведение класса OuterLogger
, в котором объект типа Logger
хранился как поле. В самом деле, наследование чисто технически можно свести к композиции.
Наследование и композиция
Сравним наследование с композицией — использованием класса в качестве типа поля другого класса. Напишем класс C
, который будет вместо наследования от A
использовать композицию.
class C {
private:
A a; // используем явное поле типа A
int y;
public:
void Func1() { // эмулируем наследование Func1 от A
return a.Func1();
}
void Func2();
void Func3();
const A& GetA() const {
return a;
}
};
Наш класс хранит фактически те же данные, что и класс B
, только поле x
теперь спрятано внутрь поля a
. Нам пришлось явно написать функцию Func1
, которая просто вызывает аналогичную функцию у поля a
. К тому же, больше нет возможности использовать класс C
вместо класса A
. Для этого пришлось написать функцию GetA
, которая возвращает константную ссылку на поле a
.
int main() {
C c;
c.Func1(); // вызывает Func1 у поля a
c.Func2(); // определена в классе C
c.Func3(); // определена в классе C
DoSomething(c.GetA()); // нет явного приведения к типу A
}
Мы видим синтаксические различия между публичным наследованием и композицией. Публичное наследование автоматически приносит в интерфейс класса-наследника функции из базового класса и позволяет наследнику притворяться объектом базового типа. Напротив, композиция приносит объект исходного класса как именованное поле, а для предоставления доступа к его функциям требуется писать обёртки.
Теперь давайте обсудим семантические различия. Считается, что композиция реализует отношение has-a между объектами C
и A
. Перевести это можно как «A
— часть C
», или «C
реализован с помощью A
». Наоборот, публичное наследование обозначает отношение is-a: «B
является A
», или «B
— особый случай A
».
Пусть, например, мы разрабатываем классы для моделирования транспортного средства (Vehicle
), автомобиля (Car
) и двигателя (Engine
). Тогда класс Car
следовало бы унаследовать от Vehicle
(автомобиль является транспортным средством), а Engine
сделать полем внутри Car
(двигатель — часть автомобиля).
Подробнее
Иногда бывает сложно сделать правильный выбор между композицией и наследованием. Тогда помогает так называемый принцип подстановки Барбары Лисков. Надо рассмотреть все возможные сценарии использования базового класса A
(например, все функции, использующие A
). Принцип требует, чтобы поведение таких функций не изменялось, если вместо объекта базового класса A
вдруг будет подставлен объект производного класса B
. Если это не так, то наследование — неправильный выбор.
Пусть, например, мы рассматриваем классы «Квадрат» (Square
) и «Прямоугольник» (Rectangle
). Кажется, что квадрат — частный случай прямоугольника. Должен ли Square
быть наследником Rectangle
? Ответ зависит от того, какой интерфейс у этих классов. Если Rectangle
позволяет через публичный интерфейс независимо изменять стороны прямоугольника, то наследование использовать нельзя: такая операция над квадратом нарушит его инвариант.
Важная особенность наследования — возможность заменять поведение функций базового класса в классах-наследниках. Она называется полиморфизмом.
Параметрический полиморфизм
Обычно про полиморфизм рассказывают на хрестоматийном примере с иерархией животных. Мы не будем отходить от этой традиции. Пусть нам для разработки некоторой игры требуются классы для кошки и собаки. В классах должно храниться имя животного и должна быть функция Voice
. Напишем эти классы по отдельности.
#include <string>
class Cat {
private:
std::string name;
public:
Cat(const std::string& n): name(n) {
}
const std::string& GetName() const {
return name;
}
std::string Voice() const {
return "Meow!";
}
};
class Dog {
private:
std::string name;
public:
Dog(const std::string& n): name(n) {
}
const std::string& GetName() const {
return name;
}
std::string Voice() const {
return "Woof!";
}
};
Подробнее
Мы возвращаем голос из функции, а не храним его в константном поле. Это более гибкое решение: представьте, например, что голос может зависеть от времени суток.
Эти два класса похожи друг на друга, но никак не связаны. Использоваться они будут примерно так:
#include <iostream>
void Process(const Cat& creature) {
std::cout << creature.GetName() << ": " << creature.Voice() << "\n";
}
void Process(const Dog& creature) {
std::cout << creature.GetName() << ": " << creature.Voice() << "\n";
}
int main() {
Cat c("Tom");
Dog d("Buffa");
Process(c); // Tom: Meow!
Process(d); // Buffa: Woof!
}
В этом коде сразу заметно дублирование. Уберём его с помощью шаблонов:
template <typename Creature>
void Process(const Creature& creature) {
std::cout << creature.GetName() << ": " << creature.Voice() << "\n";
}
Код функции main
не изменится. В каком-то смысле это уже полиморфизм: у нас есть разные классы с однотипным интерфейсом, а также набор одноименных перегруженных функций для их обработки. Вызов такой функции синтаксически никак не зависит от того, какой тип животного используется, хотя функции на самом деле разные. Выбор нужной функции происходит во время компиляции. Такой подход называется параметрическим полиморфизмом.
Дублирования в самих классах Cat
и Dog
можно было бы избежать с помощью дополнительного поля, описывающего тип:
#include <string>
enum class AnimalType {
Cat,
Dog,
};
class Animal {
private:
AnimalType type;
std::string name;
public:
Animal(AnimalType t, const std::string& n):
type(t), name(n)
{
}
const std::string& GetName() const {
return name;
}
std::string Voice() const {
switch (type) {
case AnimalType::Cat:
return "Meow!";
case AnimalType::Dog:
return "Woof!";
default:
return "Unknown creature type";
}
}
};
int main() {
Animal c(AnimalType::Cat, "Tom");
Animal d(AnimalType::Dog, "Buffa");
Process(c); // Tom: Meow!
Process(d); // Buffa: Woof!
}
Однако такой подход плохо масштабируется. Представьте, что у нас много типов животных и много функций, подобных Voice
. В каждой из таких функций пришлось бы писать код, который рассматривает все варианты типов. Для добавления нового типа животного к такой иерархии потребовалось бы расширить перечисление CreatureType
и изменить код всех таких функций. Если бы такой класс Creature
поставлялся бы со сторонней библиотекой, в код которой мы не можем вносить изменения, то добавить новый тип в иерархию было бы просто невозможно.
Полиморфизм через наследование
Организуем наши классы в иерархию наследования с базовым классом Animal
. Это позволит избавиться от дублирования поля name
и функции GetName
в коде, а также позволит по-своему определить функцию Voice
.
Конструктор у каждого класса-наследника всё-таки придётся написать свой. Он будет просто вызывать конструктор базового класса.
#include <string>
class Animal {
private:
std::string name;
public:
Animal(const std::string& n): name(n) {
}
const std::string& GetName() const {
return name;
}
std::string Voice() const {
return "Generic creature voice";
}
};
class Cat: public Animal {
public:
Cat(const std::string& n): Animal(n) {
}
std::string Voice() const {
return "Meow!";
}
};
class Dog: public Animal {
public:
Dog(const std::string& n): Animal(n) {
}
std::string Voice() const {
return "Woof!";
}
};
Со старым кодом функции Process
всё работает:
#include <iostream>
template <typename Creature>
void Process(const Creature& creature) {
std::cout << creature.GetName() << ": " << creature.Voice() << "\n";
}
int main() {
Cat c("Tom");
Dog d("Buffa");
Process(c); // Tom: Meow!
Process(d); // Buffa: Woof!
}
Как мы помним, классы-наследники могут быть автоматически приведены к типу базового класса. Попробуем убрать шаблон в функции Process
, чтобы передавать в неё аргумент базового класса Animal
.
#include <iostream>
void Process(const Animal& creature) {
std::cout << creature.GetName() << ": " << creature.Voice() << "\n";
}
int main() {
Cat c("Tom");
Dog d("Buffa");
Process(c); // Tom: Generic creature voice
Process(d); // Buffa: Generic creature voice
}
Наша программа компилируется, но животные внезапно теряют свой голос. Вместо переопределённых версий функции Voice
вызывается версия Animal::Voice
из базового класса. В самом деле, функция Process
работает с параметром типа Animal
и ничего не знает о том, что у этого класса могут быть наследники со своими версиями функций. Решить эту проблему можно, объявив функцию Voice
в базовом классе Animal
виртуальной.
Виртуальные функции
В предыдущем примере выбор функции Voice
осуществлялся на этапе компиляции программы. Компилятор выбирал её исходя из формального типа аргумента creature
— внутри функции это был const Animal&
. Этот стандартный для C++ подход называется ранним связыванием. Имеется в виду, что ещё до запуска программы адрес выбранной функции Animal::Voice
в памяти был заранее привязан к этому месту кода.
Виртуальные функции класса позволяют осуществить позднее связывание. В этом случае выбор подходящей функции будет происходить уже во время выполнения программы.
Функция должна быть объявлена виртуальной в базовом классе:
#include <string>
class Animal {
public:
// ...
virtual std::string Voice() const {
return "Generic creature voice";
}
};
В классах-наследниках её следует переопределять с дополнительной пометкой override
:
class Cat: public Animal {
public:
// ...
std::string Voice() const override {
return "Meow!";
}
};
class Dog: public Animal {
public:
// ...
std::string Voice() const override {
return "Woof!";
}
};
Подробнее
Слово override
писать не обязательно, но желательно. При его наличии компилятор сможет проверить, действительно ли в базовом классе есть виртуальная функция с такой сигнатурой. Это позволит заранее найти вот такие ошибки:
class Dog: public Animal {
public:
// ...
std::string Voice() override { // забыли const!
return "Woof!";
}
};
Здесь виртуальная функция базового класса имеет другую сигнатуру. Без слова override
компилятор бы решил, что мы просто хотим создать новую перегруженную функцию в классе-наследнике.
Теперь в функции Process
выбор нужной функции происходит динамически, во время исполнения программы. Он зависит от реального типа аргумента, который был приведён к базовому классу Animal
:
#include <iostream>
void Process(const Animal& creature) {
std::cout << creature.GetName() << ": " << creature.Voice() << "\n";
}
int main() {
Cat c("Tom");
Dog d("Buffa");
Process(c); // Tom: Meow!
Process(d); // Buffa: Woof!
}
Как это работает
Если в базовом классе объявлена хотя бы одна виртуальная функция, то компилятор добавляет к классу ещё одно неявное поле — указатель на таблицу виртуальных функций. Конструкторы базового класса и классов-наследников меняют этот указатель, чтобы он указывал на таблицу виртуальных функций именно этого класса. Чем-то это напоминает подход с перечислением AnimalType
и полем type
в базовом классе, но только вместо перечисления здесь указатель. Для выбора нужной функции надо сначала перейти по этому указателю на таблицу, а потом уже вызвать функцию. Это медленнее, чем обычный вызов функции. Виртуальные функции имеют свои накладные расходы, и поэтому функции класса не являются виртуальными по умолчанию.
Заметим, что мы нигде явно не создаём объекты базового класса Animal
. Поэтому функцию Animal::Voice
можно объявить чисто виртуальной вот так:
class Animal {
public:
// ...
virtual std::string Voice() const = 0; // чисто виртуальная функция
};
Разработчики C++ очень не хотели вводить для чисто виртуальных функций новое ключевое слово, и поэтому придумали такой странный синтаксис.
У чисто виртуальной функции даже не обязательно должна быть реализация. Если в классе есть такая функция, то класс считается абстрактным. Создать объект такого класса (например, написать Animal a
) не получится, так как класс считается не полностью определенным. Назначение чисто виртуальных функций — потребовать, чтобы классы-наследники переопределили это поведение, иначе они тоже будут считаться абстрактными.
Полиморфизм и контейнеры
Давайте устроим зоопарк и сложим наших животных в контейнер. У стандартных контейнеров должен быть явно указан тип элемента. Попробуем указать в качестве такого типа базовый класс Animal
:
#include <vector>
int main() {
std::vector<Animal> zoo;
zoo.push_back(Cat("Tom"));
zoo.push_back(Dog("Buffa"));
// Тут можно было бы написать цикл,
// но мы напишем два вызова для наглядности
Process(zoo[0]); // Tom: Generic creature voice!
Process(zoo[1]); // Buffa: Generic creature voice!
}
Внезапно виртуальное поведение сломалось: мы снова видим результат работы базовой функции Animal::Voice
. Это произошло потому, что в векторе хранятся копии переданных в push_back
объектов, и эти копии имеют тип Animal
.То же самое бы случилось, если функция Process
принимала бы свой параметр по значению, а не по константной ссылке.
Чтобы виртуальные функции заработали, в вектор можно положить указатели на Animal
:
#include <vector>
int main() {
std::vector<Animal*> zoo;
// Создадим пока что объекты на стеке
Cat c("Tom");
Dog d("Buffa");
// Кладём в вектор адреса этих объектов
zoo.push_back(&c);
zoo.push_back(&d);
// Для разыменования нужна звёздочка
Process(*zoo[0]); // Tom: Meow!
Process(*zoo[1]); // Buffa: Woof!
}
Такой пример работает, однако он не очень интересен. Мы положили в вектор адреса автоматических объектов на стеке. Эти объекты разрушаются при выходе из блока. Поэтому будет плохо возвращать такой вектор из функции — все указатели в нём станут сразу невалидными.
Попробуем создать объекты в динамической памяти:
#include <vector>
int main() {
std::vector<Animal*> zoo;
zoo.push_back(new Cat("Tom"));
zoo.push_back(new Dog("Buffa"));
Process(*zoo[0]); // Tom: Meow!
Process(*zoo[1]); // Buffa: Woof!
}
Мы создали объекты в динамической памяти, но не освободили эту память в конце. Это грубая ошибка. Мы должны были вызвать delete
, когда эта память станет ненужной. Ещё важно, чтобы для животных вызывался правильный деструктор — ~Cat
для кошки, ~Dog
для собаки. Поэтому, если в базовом классе есть виртуальные функции, и с ним предполагается полиморфная работа с созданием объектов в динамической памяти, то деструктор базового класса необходимо объявить виртуальным:
class Animal {
public:
// ...
virtual ~Animal() {
}
};
// ...
int main() {
// ...
for (Animal* animal : zoo) {
delete animal; // вызов виртуального деструктора и освобождение памяти
}
}
Заметим, что такая работа с динамическими объектами очень опасна. Мы можем по какой-то причине не дойти до цикла с уничтожением объектов. Более правильным было бы использовать вектор умных указателей std::vector<std::unique_ptr<Animal>>
. Тогда писать цикл с вызовами delete
было бы не нужно. Об этом будет рассказано в параграфе «Идиома RAII и умные указатели».
Напишем окончательную иерархию классов с учётом всех исправлений.
#include <iostream>
#include <string>
class Animal {
private:
std::string name;
public:
Animal(const std::string& n): name(n) {
}
const std::string& GetName() const {
return name;
}
virtual std::string Voice() const = 0;
virtual ~Animal() {
}
};
class Cat: public Animal {
public:
Cat(const std::string& n): Animal(n) {
}
std::string Voice() const override {
return "Meow!";
}
};
class Dog: public Animal {
public:
Dog(const std::string& n): Animal(n) {
}
std::string Voice() const override {
return "Woof!";
}
};
void Process(const Animal& creature) {
std::cout << creature.GetName() << ": " << creature.Voice() << "\n";
}
Основная программа теперь может выглядеть так
#include <memory>
#include <vector>
int main() {
// Подробности в параграфе 3.6
std::vector<std::unique_ptr<Animal>> zoo;
zoo.emplace_back(std::make_unique<Cat>("Tom"));
zoo.emplace_back(std::make_unique<Dog>("Buffa"));
Process(*zoo[0]); // Tom: Meow!
Process(*zoo[1]); // Buffa: Woof!
}
Можно ли было сложить животных в контейнер, не используя базовый класс? Да. Для этого подойдут шаблонный класс std::variant
и функция std::visit
из стандартной библиотеки.
Подробности
Класс std::variant
позволяет хранить значение одного из указанных типов. При этом он не использует динамическое выделение памяти. От типов не требуется, чтобы они имели общий базовый класс — они могут быть независимыми, как в разделе про статический полиморфизм. Вот как выглядит эта магия:
#include <iostream>
#include <variant>
#include <vector>
// Как-то определены, не обязательно наследуются от базового класса
class Cat;
class Dog;
template <typename Creature>
void Process(const Creature& creature) {
std::cout << creature.GetName() << ": " << creature.Voice() << "\n";
}
int main() {
std::vector<std::variant<Cat, Dog>> zoo; // либо кошки, либо собаки
zoo.push_back(Cat("Tom"));
zoo.push_back(Dog("Buffa"));
for (const auto& animal : zoo) {
std::visit(
[](const auto& creature) { // шаблонная лямбда-функция
Process(creature);
},
animal
);
}
}
Это тоже динамический полиморфизм, так как выбор нужной функции происходит во время работы программы. Но он не использует наследование. Недостатком этого подхода является то, что при объявлении вектора надо сразу указать, какие типы могут храниться в std::variant
. Полиморфизм, который реализован через наследование, лишён этого недостатка.
Подробности можно прочитать здесь.