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["Репозиторій задач"]
M --> P["Парсер команд"]
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 нічого не знає ні про консоль, ні про парсер, ні про репозиторій
У цьому розділі ми робимо паузу і нагадуємо собі: модель — це дані. Дуже хочеться «для зручності» додати туди друк, введення, парсинг… але тоді це вже буде не модель, а «комбайн».
#include <string>
struct Task {
int id{};
std::string title{};
bool done{false};
};
І це все. Жодного std::cout, жодних команд. Лише потрібні поля даних.
Сховище: репозиторій володіє задачами, але не володіє консоллю
Коли ви робите репозиторій, особливо на початку, дуже хочеться просто друкувати з методів «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("...") без перевірки, це прямий шлях до аварійного завершення програми. Якщо залежність обовʼязкова — використовуйте T&. Якщо необовʼязкова — використовуйте T*, але перевіряйте перед використанням. І не соромтеся вбудовувати таку перевірку в сам компонент, щоб код, який його викликає, не дублював її.
Помилка № 4: передавати важкі залежності за значенням і випадково копіювати їх.
Потоки, великі контейнери, обʼєкти з ресурсами — усе це не варто копіювати лише тому, що «це ж параметри». Якщо компонент не володіє обʼєктом, майже завжди краще T& або const T&. А якщо володіє — тоді вже свідомо обирайте T або std::unique_ptr<T>.
Помилка № 5: робити CLI місцем, де живе вся логіка, бо «там же все сходиться».
CLI справді поєднує компоненти, але він не має перетворюватися на звалище: парсинг команд, форматування виводу, правила коректності даних і зберігання — усе це краще тримати у своїх компонентах. Тоді CLI буде коротким і читабельним: «прочитав рядок → розібрав → виконав → надрукував».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ