1. Скрытые зависимости вредят
Когда начинаешь писать программы, кажется естественным: если надо что-то напечатать — пишем std::cout, если надо хранить данные — создаём std::vector прямо внутри «главного класса», если надо распарсить команду — делаем std::stringstream где попало. Код работает, компилятор не ругается — жизнь прекрасна. Проблема в том, что такой стиль очень быстро превращает проект в домик из карт: трогаешь одну стену, а отваливается крыша.
Зависимость — это всё, без чего ваш компонент не может работать. Для CLI это обычно входной поток и выходной поток. Для «командного обработчика» — репозиторий задач. Для парсера — знание формата строки. Для печати — опять же поток вывода. И ключевой момент: зависимость бывает скрытой или явной.
Скрытая зависимость выглядит так: внутри метода вы внезапно пишете в std::cout, или внутри конструктора создаёте репозиторий, или используете глобальную переменную. Читатель кода не видит этого в сигнатуре, и поэтому не понимает, что компонент «прибит гвоздями» к конкретному окружению.
Явная зависимость выглядит так: в конструкторе класса есть std::ostream& out, а в методе есть параметр TaskRepository& repo. Это буквально «честный контракт»: чтобы работало — передай мне вот это.
2. Инверсия зависимостей: кто создаёт и кто использует
Слово «инверсия» звучит так, будто сейчас мы будем выворачивать программу наизнанку и читать заклинания на латыни. Но в нашей задаче всё проще: мы меняем направление ответственности.
В «наивном» варианте компонент сам создаёт то, что ему нужно. Например, TodoCli внутри себя создаёт TaskRepository repo; и работает с ним. Кажется удобно: меньше параметров, меньше возни. Но это означает, что TodoCli теперь не просто «CLI», а ещё и «фабрика репозитория» (и часто — фабрика половины проекта).
В «инверсном» варианте компонент не создаёт зависимости, а получает их снаружи. Обычно снаружи — это main() (пока что). main() собирает «кирпичики» и соединяет их. Компоненты становятся проще: они занимаются своим делом и не лезут в чужие обязанности.
Можно представить это как простую схему:
flowchart TD
M["main()"] --> R[TaskRepository]
M --> P[CommandParser]
M --> IO[cin/cout]
M --> C[TodoCli]
C --> R
C --> P
C --> IO
main() здесь — как человек, который собирает мебель по инструкции: он достаёт детали, соединяет, затягивает винты. А TodoCli, TaskRepository и CommandParser — это детали, у которых есть понятные разъёмы.
3. Как передавать зависимости: конструктор и параметры функций
Когда вы начинаете «делать зависимости явными», сразу возникает вопрос: куда именно их передавать? В C++ есть два базовых ответа, и оба правильные, просто для разных ситуаций.
Если зависимость нужна только на время одного вызова, удобнее передать её параметром функции. Например, вы печатаете список задач — и вам нужен std::ostream& out только в этой функции печати. Передали, напечатали, забыли.
Если зависимость нужна всей жизни объекта, логичнее передать её в конструктор и сохранить в поле. Например, CLI почти всегда живёт в цикле чтения команд и постоянно печатает ответы. Значит, std::istream& in и std::ostream& out — типичные «конструкторные» зависимости.
Важно почувствовать разницу: параметр функции — это «дай на секундочку», конструктор — это «я буду этим пользоваться, пока жив».
4. Контракты зависимостей: ссылки, указатели и владение
Как только вы начали передавать зависимости, появляется второй вопрос: в каком виде? И вот тут начинаются типичные ошибки новичков: кто-то всё передаёт по значению (и случайно копирует тяжёлые объекты), кто-то хранит T& на объект, который живёт меньше (и получает «весёлые» падения), кто-то использует T*, но забывает nullptr-проверки.
Давайте держать в голове простую таблицу «контрактов»:
| Как храним/передаём | Это владение? | Может отсутствовать? | Что это говорит читателю |
|---|---|---|---|
|
да | нет | «я владею этим и отвечаю за время жизни» |
|
нет | нет | «мне нужно почитать, не менять» |
|
нет | нет | «мне нужно менять, объект обязан существовать» |
|
нет | да (nullptr) | «зависимость опциональна, проверяй» |
|
да | да (nullptr) | «я владею, могу не иметь объекта» |
В этой лекции мы в основном будем использовать ссылки для обязательных зависимостей (TaskRepository&, std::istream&, std::ostream&). Это самый понятный вариант для учебного приложения: нет владения, но есть требование «объект точно существует».
5. Практический пример: todo-приложение с честными зависимостями
Микро-пример: почему std::cout внутри класса связывает руки
Когда вы пишете вот так, вы делаете зависимость скрытой:
#include <iostream>
#include <string>
class GreeterBad {
public:
void hello(const std::string& name) {
std::cout << "Hello, " << name << "!\n"; // зависимость спрятана внутри
}
};
Проблема не в том, что std::cout плохой. Проблема в том, что теперь GreeterBad нельзя «переключить» на другой вывод. Например, вы захотите писать в файл, или в строку (для проверки результата), или просто в std::cerr. А он такой: «нет, я разговариваю только с cout».
Теперь «хороший» вариант:
#include <ostream>
#include <string>
class Greeter {
public:
explicit Greeter(std::ostream& out) : out_(out) {}
void hello(const std::string& name) {
out_ << "Hello, " << name << "!\n";
}
private:
std::ostream& out_;
};
Теперь ваш Greeter говорит: «мне нужен поток, куда печатать — и всё». И это очень сильное улучшение дизайна, хотя код стал длиннее на… одну строчку.
Небольшой техно-факт: istream/ostream в стандартной библиотеке — это не «один конкретный класс», а семейство потоков (исторически это шаблоны basic_istream, basic_ostream и т.д.), и std::ostream — просто удобное имя для стандартного варианта. Поэтому «передавать поток» как зависимость — это ещё и довольно универсально по стандарту.
Модель: Task не знает ни про консоль, ни про парсер, ни про репозиторий
В этом разделе мы делаем паузу и напоминаем себе: model — это данные. Очень хочется «для удобства» добавить туда печать, ввод, парсинг… но тогда это будет уже не model, а «комбайн».
#include <string>
struct Task {
int id{};
std::string title{};
bool done{false};
};
Всё. Никакого std::cout, никаких команд. Только смысловые поля.
Storage: репозиторий владеет задачами, но не владеет консолью
Когда вы делаете репозиторий, особенно на первых порах, очень тянет прямо из методов печатать «OK» или «NOT FOUND». Это кажется удобным, потому что «а где ещё печатать?». Но это связывает репозиторий с CLI, а CLI и так уже «склейка», ему и так есть чем заняться.
#include <vector>
#include <optional>
#include <utility> // std::move
class TaskRepository {
public:
void add(Task t) {
tasks_.push_back(std::move(t));
}
std::optional<Task> find_by_id(int id) const {
for (const auto& t : tasks_) {
if (t.id == id) return t; // возвращаем копию (пока так)
}
return std::nullopt;
}
private:
std::vector<Task> tasks_;
};
Обратите внимание: репозиторий вообще ничего не знает про std::cin/std::cout. Он умеет хранить и искать. Это и есть его работа.
Parser: парсер возвращает структуру команды, а не делает действие
Здесь мы аккуратно держим границу: парсер — это переводчик. Он не должен менять репозиторий, потому что тогда он уже «исполнитель команд». И он не должен печатать ошибки, потому что иначе он становится «мини-CLI».
Сделаем очень простую модель команды «add»:
#include <string>
struct AddCommand {
int id{};
std::string title{};
};
И очень простую функцию-парсер (упрощённо, без кучи форматов):
#include <optional>
#include <sstream>
#include <string>
#include <string_view>
std::optional<AddCommand> parse_add(std::string_view line) {
std::istringstream in(std::string(line));
std::string word;
AddCommand cmd{};
if (!(in >> word) || word != "add") return std::nullopt;
if (!(in >> cmd.id)) return std::nullopt;
std::getline(in, cmd.title);
if (!cmd.title.empty() && cmd.title.front() == ' ') cmd.title.erase(0, 1);
if (cmd.title.empty()) return std::nullopt;
return cmd;
}
Да, тут есть копия строки (мы превращаем string_view в string). Это не идеально, но нам сейчас важно другое: парсер возвращает либо команду, либо «не получилось».
Printer: отдельный компонент с явной зависимостью от std::ostream
Этот раздел кажется «лишним» новичку: «зачем отдельный Printer, я же могу в CLI печатать напрямую». Можете. Но тогда CLI становится местом, где накапливаются все строки, все форматы, все мелкие детали вывода — и читать это тяжело.
Сделаем маленький Printer, который зависит только от std::ostream&.
#include <ostream>
#include <string>
class Printer {
public:
explicit Printer(std::ostream& out) : out_(out) {}
void ok() { out_ << "OK\n"; } // OK
void invalid() { out_ << "INVALID\n"; } // INVALID
void added(int id) {
out_ << "ADDED " << id << "\n"; // ADDED 42
}
private:
std::ostream& out_;
};
Здесь зависимость явная: чтобы печатать, принтеру нужен поток вывода, и это видно в конструкторе.
CLI: дирижёр, который получает всё снаружи
Вот здесь начинается сама тема лекции. CLI — это компонент, который связывает ввод, парсинг, бизнес-логику и печать. Поэтому зависимостей у него обычно больше. Это нормально. Плохой знак — не «много зависимостей», а «зависимости спрятаны».
Сделаем минимальный TodoCli, который читает одну строку, пробует распарсить add, добавляет задачу и печатает результат.
#include <istream>
#include <string>
class TodoCli {
public:
TodoCli(TaskRepository& repo, std::istream& in, Printer& printer)
: repo_(repo), in_(in), printer_(printer) {}
void step() {
std::string line;
if (!std::getline(in_, line)) return;
if (auto cmd = parse_add(line)) {
repo_.add(Task{cmd->id, cmd->title, false});
printer_.added(cmd->id);
} else {
printer_.invalid();
}
}
private:
TaskRepository& repo_;
std::istream& in_;
Printer& printer_;
};
Заметьте, что TodoCli ничего не создаёт сам. Он не делает TaskRepository repo; внутри себя. Он не делает Printer printer{std::cout}; внутри себя. Он честно говорит: «мне нужен репозиторий, поток ввода и принтер».
И это как раз и есть инверсия в практическом смысле: компонент перестал быть «сам себе режиссёр, актёр и зритель».
Точка сборки: main() соединяет зависимости
Сейчас будет момент, который многие сначала не любят: «а почему main теперь такой… сборочный цех?». Потому что кто-то должен собрать приложение. И лучше, если это будет одно место, чем если «каждый класс собирает себя сам как может».
Пока что (до следующих лекций про структуру файлов) представим, что всё лежит в одном main.cpp:
#include <iostream>
int main() {
TaskRepository repo;
Printer printer(std::cout);
TodoCli app(repo, std::cin, printer);
app.step(); // читаем одну команду и реагируем
}
Если пользователь введёт:
add 1 Buy milk
то вывод будет:
ADDED 1
(У нас это печатает printer_.added(cmd->id);.)
И вот теперь вы можете очень легко изменить поведение программы, не переписывая TodoCli. Например, можно печатать в std::cerr:
Printer printer(std::cerr);
Или читать команды из файла (пока чисто теоретически): вы просто передадите другой std::istream.
Главное — TodoCli не меняется. Потому что он не «женат» на std::cin/std::cout.
Когда лучше параметр функции, а не поле в конструкторе
Иногда студенты, вдохновившись «всё через конструктор!», начинают пихать в конструктор вообще всё, включая временные вещи. Это приводит к другому перекосу: объект хранит то, что ему на самом деле не нужно хранить.
Представим функцию, которая печатает одну задачу. Ей не нужен Printer как поле. Ей нужен std::ostream& прямо сейчас.
#include <ostream>
void print_task(std::ostream& out, const Task& t) {
out << t.id << ": " << t.title;
if (t.done) out << " [done]";
out << "\n";
}
Это хороший стиль: зависимости не «залипают» в полях, если можно обойтись параметром.
6. Зависимость как функция: мягкая инверсия без наследования
В более сложных проектах инверсия часто обсуждается как «зависеть от абстракций». Но полноценные интерфейсы и полиморфизм — это отдельная большая тема, и она у нас будет позже. Сегодня нам достаточно увидеть «облегчённый» вариант: иногда зависимость — это просто действие, которое можно передать как функцию.
Например, «как логировать сообщение»:
#include <functional>
#include <string>
using LogFn = std::function<void(const std::string&)>;
class Service {
public:
explicit Service(LogFn log) : log_(std::move(log)) {}
void do_work() {
log_("Service is working"); // Service is working
}
private:
LogFn log_;
};
И собрать это можно так:
#include <iostream>
int main() {
Service s([](const std::string& msg) {
std::cout << "LOG: " << msg << "\n"; // LOG: Service is working
});
s.do_work();
}
Это тоже инверсия: Service не знает, куда логировать. Ему «дали способ».
Мы не будем превращать наше todo-приложение в «DI-фреймворк на коленке». Но идею полезно запомнить: зависимость — это не обязательно «объект», иногда это «правило поведения».
7. Типичные ошибки
Ошибка №1: компонент создаёт зависимость внутри себя «потому что так проще».
Часто выглядит так: TodoCli внутри конструктора делает repo_ = TaskRepository{}; или создаёт Printer с std::cout. В этот момент компонент перестаёт быть переиспользуемым и превращается в «замкнутую капсулу», которую трудно подключить к другому вводу/выводу и трудно развивать без переписывания половины кода.
Ошибка №2: хранить T& на объект, который живёт меньше, чем владелец ссылки.
Ссылки удобны, но они требуют дисциплины времени жизни. Если вы создали Printer как локальную переменную в функции, а потом вернули TodoCli, который хранит Printer&, то при выходе из функции ссылка станет висячей. Самое неприятное, что компилятор не обязан вас спасать: программа может «иногда работать», а потом падать в самый неподходящий момент.
Ошибка №3: использовать T* как «опциональную зависимость» и забывать nullptr-проверки.
Указатель честно говорит: «меня может не быть». Но если дальше вы делаете logger_->info("...") без проверки, то это билет на segfault. Если зависимость обязательна — делайте T&. Если необязательна — делайте T*, но проверяйте перед использованием, и не стесняйтесь встраивать эту проверку в сам компонент, чтобы вызывающий код не дублировал её.
Ошибка №4: передавать тяжёлые зависимости по значению и случайно копировать их.
Потоки, большие контейнеры, объекты с ресурсами — всё это не надо копировать «просто потому что параметры». Если компонент не владеет объектом, почти всегда лучше T& или const T&. А если владеет — тогда уже осознанно T или std::unique_ptr<T>.
Ошибка №5: делать CLI местом, где живёт вся логика, потому что «там же всё сходится».
CLI действительно связывает компоненты, но он не должен превращаться в свалку: парсинг команд, форматирование вывода, правила корректности данных и хранение — всё это лучше держать в своих компонентах. Тогда CLI станет коротким и читаемым: «прочитал строку → распарсил → выполнил → напечатал».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ