1. Введение
Когда вы пишете маленькую программу из 30 строк, архитектура не нужна: всё помещается в голове, а главное зло — забыть ; или перепутать == и =. Но как только у вас появляется программа «чуть больше одной функции», мозг начинает требовать порядка: где хранятся данные, кто их меняет, кто печатает, кто разбирает команды.
И вот тут возникает базовый вопрос дизайна: ваш объект является чем-то (is‑a) или он содержит/имеет что-то (has‑a)?
Представьте, что вы делаете простое консольное todo‑приложение. У вас есть задачи, есть хранилище задач, есть логика команд ("add", "done", "list"), есть консоль. В какой-то момент хочется написать один «главный класс», который всё это объединит. И на развилке вы можете сделать две вещи: «слепить наследованием» (мы это слово пока держим на безопасной дистанции) или «собрать из компонентов» (композиция). Сегодня мы выбираем второе — потому что оно почти всегда проще, надёжнее и дружелюбнее к новичку.
2. Is‑a: объект является другим объектом
Фраза «is‑a» звучит как грамматика английского, но в программировании это очень практичная проверка на здравый смысл. «X is a Y» означает: объект X можно честно назвать объектом Y, и при этом не будет ощущения, что вы врёте пользователю вашего кода.
Очень важно: здесь мы говорим именно про смысл, а не про технический механизм. Мы учимся формулировать отношения так, чтобы код получался читаемым.
Примеры, которые обычно «честные»:
- «Кот является животным» (Cat is an Animal) — звучит нормально.
- «Круг является фигурой» (Circle is a Shape) — тоже.
- «Студент является человеком» — логично.
А теперь примеры, которые звучат как плохая шутка на собеседовании:
- «TodoApp является TaskRepository».
- «Парсер команд является вектором задач».
- «Консоль является логгером» (иногда бывает, но чаще — нет).
То есть is‑a — это когда вы хотите, чтобы объект везде мог выступать в роли другого объекта без сюрпризов. Если это не так — значит, вы пытаетесь притвориться.
Небольшая проверка на честность, которую можно проговаривать вслух: если вы в разговоре с человеком сказали бы «наш объект X — это Y», и вам не хочется после этого извиниться, то, возможно, это is‑a.
3. Has‑a: композиция вместо притворства
Композиция — это когда вы строите сложный объект как набор более простых объектов, которые выполняют свои роли. По-человечески: «приложение имеет хранилище», «CLI имеет доступ к потокам ввода/вывода», «репозиторий имеет список задач».
Слово «имеет» здесь не обязательно про владение «как собственность» — это отдельная тема про время жизни. Но оно почти всегда означает, что один объект использует другой как компонент. И это золотой стандарт в прикладном C++: если сомневаетесь — выбирайте has‑a.
Зафиксируем разницу в одной таблице:
| Вопрос | is‑a | has‑a |
|---|---|---|
| Что означает связь? | «X — это Y» | «X содержит/использует Y» |
| Как звучит по смыслу? | X можно подставить туда, где ждут Y | X не обязан быть Y, но внутри у него есть Y |
| Типичный риск | «притворство» и странные обязанности | чуть больше кода, но ясные роли |
| Для новичка | легко ошибиться | обычно безопаснее |
Главная идея: композиция показывает устройство объекта, а не пытается выдать его за другой объект.
Почему «TodoApp is a TaskRepository» почти всегда плохо
На этом месте полезно сделать паузу и честно ответить: «А почему мне вообще нельзя назвать приложение репозиторием? Оно же работает с задачами!»
Проблема в том, что репозиторий — это роль хранения и поиска. Приложение же делает гораздо больше: читает команды, пишет ответы, связывает действия пользователя с операциями хранилища. Если вы скажете «приложение является репозиторием», вы тем самым обещаете, что приложение — это просто хранилище задач. А оно не просто хранилище. Оно ещё и «мозг», и «рот», и «уши».
Здесь важный психологический момент: новичкам очень хочется «сделать один главный объект, который умеет всё». И это нормально — мы все так начинали. Но следующий шаг взросления кода — научиться делать «главный объект», который координирует, а не «выполняет всё лично».
4. Пример todo: раскладываем приложение на компоненты
Сейчас мы соберём пример. Он будет «учебно‑простым»: без файлов, без сложной обработки ошибок, без наворотов. Наша цель — увидеть, как композиция раскладывает приложение на понятные детали.
Модель: Task
Начнём с модели задачи. Модель — это данные. Она никого не читает и никому не печатает. Она просто «что-то такое, что существует в предметной области».
#include <string>
namespace todo::model {
struct Task {
int id{};
std::string title{};
bool done{false};
};
} // namespace todo::model
Хранилище: TaskRepository
Теперь сделаем репозиторий: он хранит std::vector<Task> и умеет добавлять и спрашивать размер. Здесь композиция выражается буквально полем: репозиторий имеет вектор задач.
#include <cstddef>
#include <utility>
#include <vector>
#include "task.hpp"
namespace todo::storage {
class TaskRepository {
public:
void add(todo::model::Task t) { tasks_.push_back(std::move(t)); }
std::size_t size() const { return tasks_.size(); }
private:
std::vector<todo::model::Task> tasks_;
};
} // namespace todo::storage
Пока репозиторий маленький, но уже видно: он не «является задачей» и не «является приложением». Он просто хранит.
TodoApp как координатор
Очень частая ошибка — пытаться сделать так, чтобы главный объект «был всем»: и репозиторием, и парсером, и принтером. Но если мы выбираем композицию, мы делаем иначе: главный объект имеет репозиторий и делегирует ему работу.
Введём простой класс TodoApp, который содержит репозиторий.
#include <cstddef>
#include <string>
#include "task_repository.hpp"
namespace todo::app {
class TodoApp {
public:
void add_task(int id, const std::string& title) {
repo_.add(todo::model::Task{id, title, false});
}
std::size_t task_count() const { return repo_.size(); }
private:
todo::storage::TaskRepository repo_;
};
} // namespace todo::app
Важная деталь: TodoApp — не репозиторий. У него внутри есть репозиторий. Это делает код честным: читатель видит, что TodoApp управляет репозиторием, а не притворяется им.
Делегирование как продолжение композиции
Делегирование — это когда метод одного объекта «переадресует» работу другому объекту. По сути, композиция почти всегда идёт в паре с делегированием: если вы «собрали объект из частей», вам нужно, чтобы главный объект умел говорить: «эй, репозиторий, добавь задачу», «эй, парсер, разбери строку», «эй, CLI, напечатай сообщение».
Это похоже на менеджера в команде разработки. Менеджер не пишет код ядра, не тестирует всё руками и не деплоит релиз на прод в пятницу вечером (хотя иногда жизнь заставляет). Он координирует: ставит задачу тому, кто компетентен. Хороший дизайн — такой же: каждый компонент делает своё, а «сверху» есть тот, кто соединяет.
5. I/O слой: Printer и CLI
Сейчас важная архитектурная привычка, которая экономит часы жизни: компоненты, которые не должны общаться с консолью, не должны включать <iostream>.
Если репозиторий начинает печатать «OK» или «ERROR» внутри себя, он становится зависимым от консоли, и его уже сложнее использовать в другом контексте. Поэтому обычно <iostream> живёт в CLI‑слое.
Printer: печать в std::ostream
Мы сделаем очень простой «выводчик» (принтер), который печатает в std::ostream. И это тоже композиция: принтер имеет ссылку на поток вывода (невладеющая зависимость).
#include <ostream>
#include <string>
namespace todo::cli {
class Printer {
public:
explicit Printer(std::ostream& out) : out_(out) {}
void print_line(const std::string& s) { out_ << s << '\n'; }
private:
std::ostream& out_;
};
} // namespace todo::cli
Здесь мы пока не углубляемся в тонкости времени жизни, но смысл уже видно: Printer не «является консолью» и не «является ostream». Он просто работает с тем, что ему дали.
TodoCli: оболочка, которая имеет зависимости
Теперь мы сделаем TodoCli, который читает строки и выполняет команды. Пока без полноценного парсинга: для этой лекции важнее увидеть композицию, чем формат команд.
TodoCli будет иметь доступ к репозиторию (ссылка), иметь принтер (ссылка) и иметь поток ввода.
#include <istream>
#include <string>
#include "printer.hpp"
#include "task_repository.hpp"
namespace todo::cli {
class TodoCli {
public:
TodoCli(todo::storage::TaskRepository& repo, Printer& printer, std::istream& in)
: repo_(repo), printer_(printer), in_(in) {}
private:
todo::storage::TaskRepository& repo_;
Printer& printer_;
std::istream& in_;
};
} // namespace todo::cli
Это чистая композиция: CLI имеет зависимости. Он ими пользуется, но не притворяется ни репозиторием, ни потоком ввода.
Добавим маленький пример «командного шага» свободной функцией, чтобы не разрастаться. Пусть команда будет примитивная: если строка равна "count", печатаем количество задач.
#include <istream>
#include <string>
namespace todo::cli {
void run_one(todo::storage::TaskRepository& repo, Printer& printer, std::istream& in) {
std::string line;
if (!std::getline(in, line)) return;
if (line == "count") {
printer.print_line("Tasks: " + std::to_string(repo.size())); // например: Tasks: 3
}
}
} // namespace todo::cli
Как выглядит структура в терминах has‑a
Прежде чем писать ещё код, полезно увидеть картинку, иначе мозг начинает «держать всё в воздухе» и устаёт. Вот упрощённая структура, к которой мы идём:
flowchart TD
App["TodoApp: координатор"]
Repo["TaskRepository: storage"]
Print["Printer: cli helper"]
Cli["TodoCli: читает команды"]
App --> Repo
Cli --> Repo
Cli --> Print
Смысл диаграммы простой: стрелка означает «имеет/использует». Это и есть has‑a.
TodoApp соединяет компоненты
В нормальной архитектуре TodoApp — это координатор. Он создаёт (или получает) компоненты и соединяет их.
В минимальном виде это выглядит так: TodoApp имеет репозиторий по значению, а run() создаёт временный Printer и вызывает run_one.
#include <iostream>
#include "printer.hpp"
#include "task_repository.hpp"
namespace todo::app {
class TodoApp {
public:
void run(std::istream& in, std::ostream& out) {
todo::cli::Printer printer(out);
todo::cli::run_one(repo_, printer, in);
}
private:
todo::storage::TaskRepository repo_;
};
} // namespace todo::app
И снова композиция: приложение состоит из репозитория. Оно не репозиторий.
6. Формы композиции и время жизни
Сейчас композиция начинает пересекаться с вопросом времени жизни объектов. Мы не будем уходить в дебри, но базовую картину зафиксируем, чтобы вы не писали «всё через указатель просто потому что так загадочнее».
Хранение по значению
Это самый простой вариант: если TodoApp содержит TaskRepository repo_;, то репозиторий создаётся вместе с приложением и уничтожается вместе с ним. Обычно это идеальный старт для новичка: меньше шансов сделать «висячую ссылку» и больше предсказуемости.
class TodoApp {
private:
todo::storage::TaskRepository repo_; // живёт вместе с TodoApp
};
Хранение по ссылке
Если компонент должен существовать, но создаётся «внешним миром», можно хранить ссылку. Это удобно для зависимостей вроде std::ostream&.
class Printer {
public:
explicit Printer(std::ostream& out) : out_(out) {}
private:
std::ostream& out_; // out обязан жить дольше Printer
};
Ссылка говорит: «без этого я не работаю». Но она же требует дисциплины: нельзя передать временный объект и надеяться, что всё будет хорошо.
std::unique_ptr
Иногда компонент может отсутствовать, или вы хотите отложить создание, или спрятать детали. Тогда unique_ptr — нормальный инструмент. Но пока держите это как «опционально‑продвинуто», чтобы не усложнять себе жизнь раньше времени.
#include <memory>
class TodoApp {
private:
std::unique_ptr<todo::storage::TaskRepository> repo_;
};
Важно: если вы не понимаете, зачем вам unique_ptr — скорее всего, он вам пока не нужен. Это не «модно», это «есть конкретная причина».
Указатель T*
Указатель полезен, когда зависимость может отсутствовать. Но он требует постоянных проверок и внимательности, поэтому в учебном прикладном коде обычно лучше начинать со ссылки или значения.
7. Типичные ошибки
Ошибка №1: путать «использует» и «является» и пытаться склеить роли в одну сущность.
Обычно это выглядит так: «раз приложение работает с репозиторием, значит приложение и есть репозиторий». В итоге вы получаете объект, который невозможно описать одной ролью, и он начинает тянуть в себя всё подряд. Лечится простой привычкой: проговаривать вслух фразу «X is a Y» и проверять, не звучит ли это как странная ложь.
Ошибка №2: делать компонент, который одновременно хранит данные, парсит команды и печатает в консоль.
Поначалу кажется, что так быстрее. Потом оказывается, что любое изменение формата команд ломает хранилище, любое изменение формата вывода ломает парсер, а любая проверка логики требует «подделывать консоль». Композиция как раз про то, чтобы разделить роли и связывать их явно, а не месить в одном котле.
Ошибка №3: раздавать наружу неконстантные ссылки на внутренности «для удобства».
Очень соблазнительно сделать метод «дай мне std::vector<Task>& и я сам там поправлю». Но тогда вы теряете контроль над инвариантами и превращаете репозиторий в «мешок с данными». Гораздо здоровее выдавать операции: add, remove, mark_done, find_by_id.
Ошибка №4: хранить ссылки на объекты, которые живут меньше, чем ваш объект.
Ссылки в полях — мощная вещь, но они требуют гарантии времени жизни. Если вы храните std::ostream& или TaskRepository&, вы обязаны понимать, кто кого «переживёт». Если уверенности нет, лучше начинать с хранения по значению (для простых компонентов) и только потом переходить к ссылкам, когда появляется явная архитектурная причина.
Ошибка №5: пытаться «угадать будущее» и заранее усложнять композицию unique_ptr-ами и указателями.
Композиция не требует динамики по умолчанию. Очень часто начинающий код становится сложным не потому, что задача сложная, а потому что автор решил «сразу сделать как в большом проекте» и добавил лишние уровни косвенности. Если компонент всегда существует — храните по значению. Если компонент обязателен, но внешний — храните ссылку. Всё остальное добавляйте только когда появится конкретная необходимость.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ