JavaRush /Курси /C++ SELF /Залежності та інверсія

Залежності та інверсія

C++ SELF
Рівень 50 , Лекція 4
Відкрита

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.

Корисно тримати в голові просту таблицю «контрактів»:

Як зберігаємо/передаємо Це володіння? Може бути відсутнім? Що це говорить читачеві
T (за значенням)
так ні «я володію цим і відповідаю за час життя»
const T&
ні ні «мені потрібно це читати, не змінюючи»
T&
ні ні «мені потрібно це змінювати, і обʼєкт точно має існувати»
T*
ні так (nullptr) «залежність необовʼязкова, тож перевіряй»
std::unique_ptr<T>
так так (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 буде коротким і читабельним: «прочитав рядок → розібрав → виконав → надрукував».

1
Опитування
Порівняння, композиція, рівень 50, лекція 4
Недоступний
Порівняння, композиція
Порівняння, композиція
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ