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

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

Разработчику Архитектору

:::tip О чём эта статья Вы уже знаете, что такое класс и наследование (ООП в C++). Здесь — как связать классы между собой: через наследование (отношение «является») или через композицию (отношение «содержит»). От выбора зависит, насколько код будет гибким и понятным через полгода. :::

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

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

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

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

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

Оба механизма помогают не дублировать код и разбить ответственность. Частая ошибка новичка — наследовать «просто чтобы взять чужие методы», хотя достаточно положить готовый объект в поле. Так раздувается иерархия, ломается подстановка типов (принцип Лисков, LSP) и усложняется отладка. Контекст сложности ПО: Сложность и ООП. Базовое наследование в C++: 14.


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

Используйте 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_; }
};

Разбор по строкам:

СтрокаЧто происходит
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, а конкретная площадь считается в наследнике.


Композиция — когда предпочтительнее

Композиция — «собрать объект из других объектов». Подтип для каждой комбинации деталей не нужен.

СитуацияРешение через композицию
Нужна чужая реализация без подстановки типаполе 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 — рассмотрите композицию.


Как выбрать на практике (чек-лист)

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

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

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

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

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

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


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).