Композиция и наследование в C++
Вы уже знаете, что такое класс и наследование (ООП в C++).
Здесь — как связать классы между собой: через наследование (отношение "является") или через композицию (отношение "содержит").
От выбора зависит, насколько код будет гибким и понятным через полгода.
Два способа "собрать" программу из частей
Представьте, что вы проектируете игру или бухгалтерскую систему. Большую задачу делят на части — классы. Связать части можно двумя основными способами:
| Способ | Русское имя | Английский термин | Смысл |
|---|---|---|---|
| Наследование | "является" | is-a | Circle является разновидностью Shape |
| Композиция | "содержит" | has-a | Car содержит Engine |
Наследование записывается так: class Car : public Vehicle { ... }; — "машина — это вид транспорта".
Композиция — когда внутри класса лежит другой объект (или умный указатель на него): class Car { Engine engine_; }; — "у машины есть двигатель".
Оба механизма помогают не дублировать код и разбить ответственность. Частая ошибка новичка — наследовать "просто чтобы взять чужие методы", хотя достаточно положить готовый объект в поле. Так раздувается иерархия, ломается принцип подстановки Лисков (LSP) и усложняется отладка.
- Контекст сложности ПО — Сложность и ООП
- Синтаксис наследования в C++ — ООП в C++
- Когда иерархия раздувается — паттерны GoF, Observer и Strategy
Бьёрн Страуструп, создатель C++, рекомендует по возможности связывать классы через поля, а не через дерево наследования. Наследование даёт полиморфизм и общий контракт, но усложняет сопровождение: каждый новый потомок зависит от базового класса.
Практический смысл формулы "предпочитайте композицию" простой. Если нужен чужой код — положите готовый объект в поле и вызывайте его методы. Иерархию public-наследования строят, когда тип действительно подставляется вместо базового (полиморфизм), а не ради одного удобного метода.
Наследование — когда уместно
Используйте 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_; }
};
Разбор:
Shapeзадаёт абстрактный контракт: методarea()обязателен для всех наследников из-за= 0.virtual ~Shape() = default;гарантирует корректное разрушение через указатель на базовый тип.Circle : public Shapeреализует отношениеis-a, поэтому объектCircleможно передавать какShape.explicit Circle(double r)защищает от неявных преобразований числа в объект.overrideзаставляет компилятор проверить, что сигнатура метода совпадает с виртуальным методом базы.
Разбор по строкам:
| Строка | Что происходит |
|---|---|
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, а конкретная площадь считается в наследнике.
Композиция — когда предпочтительнее
Композиция — "собрать объект из других объектов". Подтип для каждой комбинации деталей не нужен.
Композиция и агрегация
Оба варианта описывают отношение "содержит" (has-a на английском). Класс целиком включает другой тип как часть себя, но не обязан наследоваться от него. Разница в том, кто владеет частью и как долго она живёт.
Композиция — часть хранится прямо внутри целого, обычно как поле-объект:
- в C++ это запись вида
Engine engine_; - целое владеет частью
- часть создаётся и уничтожается вместе с целым
- пример из предметной области: у самолёта есть двигатель, но самолёт не "является" двигателем — двигатель логичнее сделать полем, а не базовым классом
Агрегация — целое ссылается на часть через указатель или ссылку:
- в C++ это
Engine* engine_,std::shared_ptr<Engine>или ссылкаEngine& - целое пользуется частью, но не обязано быть единственным владельцем
- часть может существовать до и после целого (сняли двигатель с одного самолёта, поставили на другой)
- при
shared_ptrвладение может быть общим у нескольких объектов
В повседневном коде чаще говорят просто "композиция". Термин агрегация пригождается, когда важно явно обсудить владение и возможность "отсоединить" часть.
// Композиция — двигатель живёт ровно столько, сколько самолёт
class Airplane {
Engine engine_;
public:
explicit Airplane(Engine e) : engine_(std::move(e)) {}
};
// Агрегация — двигатель может существовать отдельно
class AirplaneRef {
std::shared_ptr<Engine> engine_;
public:
explicit AirplaneRef(std::shared_ptr<Engine> e) : engine_(std::move(e)) {}
};
Engine engine_— объект двигателя лежит внутри самолёта; при уничтоженииAirplaneавтоматически вызовется~Engine()std::shared_ptr<Engine>— самолёт хранит счётчик владения; двигатель удалится, когда на него не останется ни одногоshared_ptrstd::moveв конструкторе переносит значение в поле без лишнего копирования — идиомы C++
Связанные темы:
| Ситуация | Решение через композицию |
|---|---|
| Нужна чужая реализация без подстановки типа | поле Logger log_, методы класса вызывают log_.write(...) |
| Часть можно заменить (движок, стратегия) | std::unique_ptr<Engine> engine_ — см. паттерн Strategy |
| Несколько независимых ролей | класс содержит несколько объектов, а не наследует всё сразу |
Хрупкая база с 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_показывает композицию:CarсодержитEngine, а не наследуется от него.- Конструктор с
std::moveэффективно переноситmodelиengineво внутренние поля без лишних копий. - Метод
start()делегирует работу объектуengine_, сохраняя единый публичный интерфейсCar. const std::string& model() constвозвращает ссылку для чтения без копирования строки.- Такая структура упрощает замену реализации двигателя без перестройки иерархии классов.
Разбор:
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инкапсулируетstd::vector<int>и открывает только операции стека (push/pop/empty).- Метод
pop()сначала читает верхний элемент черезback(), затем удаляет егоpop_back(). - Клиентский код не видит лишние методы
vector(insert,eraseв середине), значит абстракция не протекает. constуempty()фиксирует, что проверка состояния не изменяет объект.- Это типичный пример делегирования: поведение строится на готовом контейнере без наследования.
Если бы Stack наследовал vector, клиент мог бы вызвать insert в середину — это нарушило бы смысл абстракции "стек". Делегирование оставляет снаружи только push, pop, empty.
Паттерн близок к Adapter и Decorator — см. структурные паттерны.
Интерфейсы без наследования реализации
В C++ нет ключевого слова interface. Роль контракта выполняет класс с чисто виртуальными методами и виртуальным деструктором. Реализация — в производных классах; композиция связывает контейнер с виджетами:
Код ITЗагрузка примера кода…
Разбор:
Drawableработает как интерфейс:draw()— чисто виртуальный метод, реализация переносится в наследников.virtual ~Drawable() = default;делает удаление черезstd::unique_ptr<Drawable>безопасным.std::vector<std::unique_ptr<Drawable>>хранит полиморфные объекты с автоматическим управлением временем жизни (RAII).add(std::unique_ptr<Drawable> w)принимает владение объектом, аstd::move(w)переносит его в контейнер.- В
redraw()срабатывает динамический полиморфизм: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 уровней без доменной необходимости? Часто пора упростить через композицию.
Реальный сценарий выбора в проекте
Представьте backend сервиса уведомлений. На старте есть один канал — email. Через месяц добавляются push и SMS.
Если изначально сделать class EmailSender — public BaseSender, а затем пытаться "донаследовать" логику ретраев, шаблонов и маршрутизации, иерархия быстро превращается в сеть зависимостей. Изменение политики ретраев начинает цеплять много классов.
Композиция даёт более устойчивую схему:
RetryPolicyотвечает только за повторы;TemplateRendererотвечает только за подготовку текста;Transportинкапсулирует отправку в конкретный канал;NotificationServiceсвязывает всё вместе.
В такой архитектуре новые каналы добавляются заменой Transport, без каскадной правки базовых классов. Это снижает связанность и делает код удобнее для тестов.
Миграция от "перенаследования" к композиции
Если в коде уже есть глубокая иерархия, переход делают по шагам:
- Выделите стабильный контракт в небольшой интерфейс (
virtual-база из 2–4 методов). - Вынесите изменяемые части в отдельные компоненты-поля.
- Переведите внешних клиентов на работу через контракт.
- Удалите промежуточные классы, которые дублируют поведение.
Полезная техника — добавить временный адаптер, который "подкладывает" новую композицию под старый API. Это даёт безопасный этап миграции без большого разового переписывания.
Сравнение в одной таблице
| Критерий | Наследование | Композиция |
|---|---|---|
| Отношение | is-a | has-a |
| Полиморфизм через базовый указатель | да | да, если член — полиморфный тип |
| Связанность | выше (подкласс зависит от базы) | ниже (зависимость от интерфейса члена) |
| Риск хрупкой базы | высокий | ниже |
| Замена реализации | новый подкласс | новый объект в поле |
Типичные ошибки новичков
| Ошибка | Почему больно | Что делать |
|---|---|---|
class MyList : public std::vector | Клиент видит весь API вектора | Композиция + делегирование |
| Глубокая иерархия "на всякий случай" | Сложно менять базу | Плоские интерфейсы + композиция |
| Наследование ради одного поля | Лишняя связанность | Поле нужного типа |
Забыли virtual ~Base() | Утечки при delete через базовый указатель | Виртуальный деструктор в полиморфной базе |
Связанные материалы
- ООП в C++ — наследование, virtual, abstract
- struct и union — агрегаты без иерархий
- this, static, friend — детали класса
- RTTI — как работать с типами в runtime
- Функции и лямбды — делегирование поведения через callable-объекты
- SOLID — особенно Liskov и Interface Segregation
- GRASP — Low Coupling, Protected Variations