Перейти к основному содержимому

Композиция и наследование в C++

Разработчику Архитектору
О чём эта статья

Вы уже знаете, что такое класс и наследование (ООП в C++).

Здесь — как связать классы между собой: через наследование (отношение "является") или через композицию (отношение "содержит").

От выбора зависит, насколько код будет гибким и понятным через полгода.


Два способа "собрать" программу из частей

Представьте, что вы проектируете игру или бухгалтерскую систему. Большую задачу делят на части — классы. Связать части можно двумя основными способами:

СпособРусское имяАнглийский терминСмысл
Наследование"является"is-aCircle является разновидностью Shape
Композиция"содержит"has-aCar содержит Engine

Наследование записывается так: class Car : public Vehicle { ... }; — "машина — это вид транспорта".

Композиция — когда внутри класса лежит другой объект (или умный указатель на него): class Car { Engine engine_; }; — "у машины есть двигатель".

Оба механизма помогают не дублировать код и разбить ответственность. Частая ошибка новичка — наследовать "просто чтобы взять чужие методы", хотя достаточно положить готовый объект в поле. Так раздувается иерархия, ломается принцип подстановки Лисков (LSP) и усложняется отладка.

Сначала композиция, потом наследование

Бьёрн Страуструп, создатель C++, рекомендует по возможности связывать классы через поля, а не через дерево наследования. Наследование даёт полиморфизм и общий контракт, но усложняет сопровождение: каждый новый потомок зависит от базового класса.

Практический смысл формулы "предпочитайте композицию" простой. Если нужен чужой код — положите готовый объект в поле и вызывайте его методы. Иерархию public-наследования строят, когда тип действительно подставляется вместо базового (полиморфизм), а не ради одного удобного метода.


Наследование — когда уместно

Используйте public наследование, когда одновременно верно:

  1. Производный тип можно подставить везде, где ожидается базовый (полиморфизм).
  2. Базовый класс задаёт контракт — что должен уметь любой наследник (часто через virtual или = 0).
  3. Иерархия стабильна: новые подтипы добавляются, базовый код редко меняется.

Пример — фигуры на экране

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 ShapeCircle является 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_ptr
  • std::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 — рассмотрите композицию.


Как выбрать на практике

  1. Спросите: "Можно ли честно сказать, что B — это разновидность A?" Если да — наследование возможно.
  2. Нужен ли полиморфизм (Shape* p = new Circle)? Наследование + virtual обычно уместны.
  3. Нужен ли только кусок чужого кода? Композиция или делегирование.
  4. Иерархия глубже 2–3 уровней без доменной необходимости? Часто пора упростить через композицию.

Реальный сценарий выбора в проекте

Представьте backend сервиса уведомлений. На старте есть один канал — email. Через месяц добавляются push и SMS.

Если изначально сделать class EmailSender — public BaseSender, а затем пытаться "донаследовать" логику ретраев, шаблонов и маршрутизации, иерархия быстро превращается в сеть зависимостей. Изменение политики ретраев начинает цеплять много классов.

Композиция даёт более устойчивую схему:

  • RetryPolicy отвечает только за повторы;
  • TemplateRenderer отвечает только за подготовку текста;
  • Transport инкапсулирует отправку в конкретный канал;
  • NotificationService связывает всё вместе.

В такой архитектуре новые каналы добавляются заменой Transport, без каскадной правки базовых классов. Это снижает связанность и делает код удобнее для тестов.


Миграция от "перенаследования" к композиции

Если в коде уже есть глубокая иерархия, переход делают по шагам:

  1. Выделите стабильный контракт в небольшой интерфейс (virtual-база из 2–4 методов).
  2. Вынесите изменяемые части в отдельные компоненты-поля.
  3. Переведите внешних клиентов на работу через контракт.
  4. Удалите промежуточные классы, которые дублируют поведение.

Полезная техника — добавить временный адаптер, который "подкладывает" новую композицию под старый API. Это даёт безопасный этап миграции без большого разового переписывания.


Сравнение в одной таблице

КритерийНаследованиеКомпозиция
Отношениеis-ahas-a
Полиморфизм через базовый указательдада, если член — полиморфный тип
Связанностьвыше (подкласс зависит от базы)ниже (зависимость от интерфейса члена)
Риск хрупкой базывысокийниже
Замена реализацииновый подклассновый объект в поле

Типичные ошибки новичков

ОшибкаПочему больноЧто делать
class MyList : public std::vectorКлиент видит весь API вектораКомпозиция + делегирование
Глубокая иерархия "на всякий случай"Сложно менять базуПлоские интерфейсы + композиция
Наследование ради одного поляЛишняя связанностьПоле нужного типа
Забыли virtual ~Base()Утечки при delete через базовый указательВиртуальный деструктор в полиморфной базе

Связанные материалы