Композиция и наследование в C++
:::tip О чём эта статья Вы уже знаете, что такое класс и наследование (ООП в C++). Здесь — как связать классы между собой: через наследование (отношение «является») или через композицию (отношение «содержит»). От выбора зависит, насколько код будет гибким и понятным через полгода. :::
Два способа «собрать» программу из частей
Представьте, что вы проектируете игру или бухгалтерскую систему. Большую задачу делят на части — классы. Связать части можно двумя основными способами:
| Способ | Русское имя | Английский термин | Смысл |
|---|---|---|---|
| Наследование | «является» | is-a | Circle является разновидностью Shape |
| Композиция | «содержит» | has-a | Car содержит Engine |
Наследование записывается так: class Car : public Vehicle { ... }; — «машина — это вид транспорта».
Композиция — когда внутри класса лежит другой объект (или умный указатель на него): class Car { Engine engine_; }; — «у машины есть двигатель».
Оба механизма помогают не дублировать код и разбить ответственность. Частая ошибка новичка — наследовать «просто чтобы взять чужие методы», хотя достаточно положить готовый объект в поле. Так раздувается иерархия, ломается подстановка типов (принцип Лисков, LSP) и усложняется отладка. Контекст сложности ПО: Сложность и ООП. Базовое наследование в C++: 14.
Наследование — когда уместно
Используйте public наследование, когда одновременно верно:
- Производный тип можно подставить везде, где ожидается базовый (полиморфизм).
- Базовый класс задаёт контракт — что должен уметь любой наследник (часто через
virtualили= 0). - Иерархия стабильна: новые подтипы добавляются, базовый код редко меняется.
Пример — фигуры на экране
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
};
class Circle : public Shape {
double radius_;
public:
explicit Circle(double r) : radius_(r) {}
double area() const override { return 3.141592653589793 * radius_ * radius_; }
};
Разбор по строкам:
| Строка | Что происходит |
|---|---|
class Shape | Базовый тип «фигура» — общий интерфейс |
virtual ~Shape() = default | Виртуальный деструктор: при удалении через указатель на Shape вызовется правильный деструктор Circle |
virtual double area() const = 0 | Чисто виртуальный метод: у Shape нет своей площади, её обязан дать наследник |
class Circle : public Shape | Circle является Shape для компилятора и для алгоритмов |
explicit Circle(double r) | Конструктор с одним параметром: explicit запрещает неявное преобразование Circle c = 3.14; |
override | Явно помечаем переопределение area() — опечатка в имени метода станет ошибкой компиляции |
Функция void render(const Shape& s) может принять и Circle, и любую другую фигуру: она работает с контрактом Shape, а конкретная площадь считается в наследнике.
Композиция — когда предпочтительнее
Композиция — «собрать объект из других объектов». Подтип для каждой комбинации деталей не нужен.
| Ситуация | Решение через композицию |
|---|---|
| Нужна чужая реализация без подстановки типа | поле Logger log_, методы класса вызывают log_.write(...) |
| Часть можно заменить (движок, стратегия) | std::unique_ptr<Engine> engine_ |
| Несколько независимых ролей | класс содержит несколько объектов, а не наследует всё сразу |
Хрупкая база с protected полями | детали спрятаны в члене, снаружи — узкий публичный API |
Пример — автомобиль и двигатель
class Car {
Engine engine_;
std::string model_;
public:
Car(std::string model, Engine engine)
: model_(std::move(model)), engine_(std::move(engine)) {}
void start() { engine_.ignite(); }
const std::string& model() const { return model_; }
};
Разбор:
Engine engine_— машина владеет двигателем как частью себя (has-a).void start() { engine_.ignite(); }— публичный метод делегирует работу вложенному объекту.- Сменить тип двигателя можно, заменив поле или указатель, без нового класса
ElectricCar : public Car.
Делегирование вместо наследования
Иногда нужен почти тот же API, что у стандартного типа, но отношение is-a отсутствует. Классический пример — стек на базе std::vector: стек использует вектор внутри, но не является вектором для внешнего кода.
class Stack {
std::vector<int> data_;
public:
void push(int x) { data_.push_back(x); }
int pop() {
int v = data_.back();
data_.pop_back();
return v;
}
bool empty() const { return data_.empty(); }
};
Если бы Stack наследовал vector, клиент мог бы вызвать insert в середину — это нарушило бы смысл абстракции «стек». Делегирование оставляет снаружи только push, pop, empty.
Паттерн близок к Adapter и Decorator — см. структурные паттерны.
Интерфейсы без наследования реализации
В C++ нет ключевого слова interface. Роль контракта выполняет класс с чисто виртуальными методами и виртуальным деструктором. Реализация — в производных классах; композиция связывает контейнер с виджетами:
class Drawable {
public:
virtual void draw() const = 0;
virtual ~Drawable() = default;
};
class Button : public Drawable { /* ... */ };
class Screen {
std::vector<std::unique_ptr<Drawable>> widgets_;
public:
void add(std::unique_ptr<Drawable> w) {
widgets_.push_back(std::move(w));
}
void redraw() const {
for (const auto& w : widgets_) w->draw();
}
};
Screen содержит виджеты (has-a). Он не наследует Button и Label — иначе экран «стал бы» кнопкой, что бессмысленно.
std::unique_ptr<Drawable> хранит полиморфный объект: в рантайме это может быть Button, Label и т.д. Подробнее про владение: память.
Множественное наследование
Когда классу нужны две роли (Flyable + Swimmable), в C++ возможно множественное наследование — см. ромбовидная проблема в 14. Альтернативы:
- композиция двух объектов-стратегий;
- один интерфейс + несколько реализаций через члены;
std::variant/std::functionдля редких комбинаций.
Правило для начала: если для каждого базового класса нет чёткого is-a — рассмотрите композицию.
Как выбрать на практике (чек-лист)
- Спросите: «Можно ли честно сказать, что
B— это разновидностьA?» Если да — наследование возможно. - Нужен ли полиморфизм (
Shape* p = new Circle)? Наследование +virtualобычно уместны. - Нужен ли только кусок чужого кода? Композиция или делегирование.
- Иерархия глубже 2–3 уровней без доменной необходимости? Часто пора упростить через композицию.
Сравнение в одной таблице
| Критерий | Наследование | Композиция |
|---|---|---|
| Отношение | is-a | has-a |
| Полиморфизм через базовый указатель | да | да, если член — полиморфный тип |
| Связанность | выше (подкласс зависит от базы) | ниже (зависимость от интерфейса члена) |
| Риск хрупкой базы | высокий | ниже |
| Замена реализации | новый подкласс | новый объект в поле |
Типичные ошибки новичков
| Ошибка | Почему больно | Что делать |
|---|---|---|
class MyList : public std::vector | Клиент видит весь API вектора | Композиция + делегирование |
| Глубокая иерархия «на всякий случай» | Сложно менять базу | Плоские интерфейсы + композиция |
| Наследование ради одного поля | Лишняя связанность | Поле нужного типа |
Забыли virtual ~Base() | Утечки при delete через базовый указатель | Виртуальный деструктор в полиморфной базе |
Связанные материалы
- ООП в C++ — наследование, virtual, abstract
- struct и union — агрегаты без иерархий
- this, static, friend — детали класса
- SOLID — особенно Liskov и Interface Segregation
- GRASP — Low Coupling, Protected Variations
См. также
Другие статьи этого же раздела в боковом меню (как на странице «О разделе»). C++ как язык системного программирования - ключевые принципы, область применения и инженерные требования к коду. Экосистема приложений на C++ - области применения языка от системного ПО до высоконагруженных вычислений. C++ — это мощный язык программирования общего назначения, который обеспечивает прямой доступ к аппаратным ресурсам компьютера при сохранении высокой производительности. Гайд по установке и настройке с написанием первой программы и её запуском. Директива препроцессора include используется для подключения заголовочных файлов в исходный код. Она сообщает компилятору вставить содержимое указанного файла в текущее место перед началом компиляции. Конфигурация — это набор правил и переменных, которые управляют процессом превращения исходного текста в исполняемый продукт. Примеры простых и полезных консольных приложений с демонстрацией концепций языка. Минимальный кроссплатформенный проект C++17 с CMake — структура, сборка и разбор CMakeLists построчно. Модульные тесты с GTest и Catch2 в CMake-проекте — зачем отдельный target, примеры и запуск ctest. Набор мини-проектов для закрепления C++ — консоль, RAII, CMake, Qt, ranges и опционально Vulkan. Набор советов, правил, принципов и обычаев в разработке на этом языке. Типизация, набор правил определения типа данных значений языка.C++ - язык системного программирования
Экосистема приложений на C++
Что требуется знать перед началом изучения языка программирования C++
Первая программа на C++
Начало работы с C++
Конфигурация и сборка в C++
Простые приложения на C++
CMake — первая программа
Google Test и Catch2 в C++
Практические задания по C++
Рекомендации по разработке на C++
Типы данных в C++