JavaRush /Курсы /C++ SELF /“has‑a” vs “is‑a”: инструмент дизайна

“has‑a” vs “is‑a”: инструмент дизайна

C++ SELF
50 уровень , 2 лекция
Открыта

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-ами и указателями.
Композиция не требует динамики по умолчанию. Очень часто начинающий код становится сложным не потому, что задача сложная, а потому что автор решил «сразу сделать как в большом проекте» и добавил лишние уровни косвенности. Если компонент всегда существует — храните по значению. Если компонент обязателен, но внешний — храните ссылку. Всё остальное добавляйте только когда появится конкретная необходимость.

1
Задача
C++ SELF, 50 уровень, 2 лекция
Недоступна
Чертёж прямоугольника
Чертёж прямоугольника
1
Задача
C++ SELF, 50 уровень, 2 лекция
Недоступна
Печать с префиксом
Печать с префиксом
1
Задача
C++ SELF, 50 уровень, 2 лекция
Недоступна
Репозиторий фильмов
Репозиторий фильмов
1
Задача
C++ SELF, 50 уровень, 2 лекция
Недоступна
Приложение заметок
Приложение заметок
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ