4.4. Наследование и полиморфизм

Классы можно выстраивать в иерархии наследования. В этом параграфе мы рассмотрим публичное одиночное наследование классов в C++ и поговорим про виртуальные функции, с помощью которых можно реализовать полиморфное поведение объектов.

Наследование — это способ организовывать иерархии классов. При этом класс-наследник приобретает поля и функции базового класса, модифицируя их область видимости.

Язык 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 и у)
}

C

Самое заманчивое, что тип 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.

C

Конструктор у каждого класса-наследника всё-таки придётся написать свой. Он будет просто вызывать конструктор базового класса.

#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. Полиморфизм, который реализован через наследование, лишён этого недостатка.

Подробности можно прочитать здесь.

Отмечайте параграфы как прочитанные чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф4.3. Жизненный цикл объекта

В этом параграфе мы узнаем подробнее про конструкторы, деструктор и оператор присваивания, проследим эволюцию объекта от создания до уничтожения, поговорим про временные объекты, а также рассмотрим два разных способа создать объект: на стеке или в динамической памяти.

Следующий параграф4.5. Обработка исключений

Некоторые ошибки времени выполнения можно обнаружить заранее с помощью проверок в коде. Механизм исключений позволяет корректно сообщать об ошибках.