JavaRush /Курси /C++ SELF /Фабрика реалізацій за конфігурацією/CLI

Фабрика реалізацій за конфігурацією/CLI

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

1. Проблема вибору реалізації

Коли застосунок стає трохи складнішим, швидко зʼясовується, що «один клас на все» — це міф із підручників. У реальному коді часто є кілька реалізацій однієї ідеї: швидке сховище в памʼяті для тестів, інше — для робочого середовища, третє — для налагодження. І тут постає питання: хто має вибирати, яку реалізацію створювати?

Якщо робити цей вибір напряму, просто в main() через довгий if/else, то певний час це ще терпимо — приблизно перші два дні. Потім ви додасте друге місце, де потрібен такий самий вибір, потім третє — і отримаєте звичайне дублювання коду.

Ідея лекції проста: вибір реалізації має бути зосереджений в одному місці — у фабриці. А «конфігурація/CLI» або будь-яке інше налаштування від користувача має лише повідомити фабриці, що саме вибрати.

TaskTracker і інтерфейс ITaskStorage

Щоб не обговорювати фабрики у вакуумі, візьмемо невеликий навчальний застосунок TaskTracker: додаємо завдання, показуємо список, позначаємо завдання як виконане.

Нам потрібна точка розширення. Припустімо, ми хочемо мати два варіанти зберігання завдань. Для цього опишемо інтерфейс ITaskStorage, який визначає, «що вміє сховище», але не каже, «як воно влаштоване всередині».

#include <iosfwd>
#include <string>
#include <string_view>

struct ITaskStorage {
    virtual ~ITaskStorage() = default;

    // Передумова: title може бути порожнім (дозволимо, але це спірно).
    // Післяумова: повертає id створеного завдання (id > 0).
    virtual int add(std::string_view title) = 0;

    // Передумова: id > 0.
    // Післяумова: повертає true, якщо завдання знайдено й позначено done.
    virtual bool mark_done(int id) = 0;

    // Післяумова: друкує поточний стан сховища.
    virtual void print_all(std::ostream& out) const = 0;
};

Зверніть увагу: ми навмисно не кажемо «це std::vector» або «це std::map». Клієнтський код має залежати від контракту, а не від деталей реалізації.

2. Реалізація № 1: сховище на std::vector

Першу реалізацію зробимо максимально прямолінійною: список завдань зберігатиметься в std::vector. Це хороший варіант для невеликих обсягів даних і для навчання: його легко друкувати, до нього легко додавати елементи, і його легко зрозуміти.

Усередині визначимо модель Task і клас VectorTaskStorage.

#include <iostream>
#include <string>
#include <string_view>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

struct VectorTaskStorage : ITaskStorage {
    int add(std::string_view title) override {
        const int id = next_id_++;
        tasks_.push_back(Task{id, std::string(title), false});
        return id;
    }

    bool mark_done(int id) override {
        for (auto& t : tasks_) {
            if (t.id == id) {
                t.done = true;
                return true;
            }
        }
        return false;
    }

    void print_all(std::ostream& out) const override {
        for (const auto& t : tasks_) {
            out << "[" << (t.done ? 'x' : ' ') << "] "
                << t.id << ": " << t.title << "\n";
        }
    }

private:
    int next_id_ = 1;
    std::vector<Task> tasks_;
};

Так, тут є лінійний пошук у vector. Але сьогодні ми говоримо не про оптимізацію, а про архітектуру вибору реалізації.

3. Реалізація № 2: сховище на std::map

Тепер зробимо альтернативний варіант: зберігатимемо завдання в std::map<int, Task>, де ключем буде id. Тоді позначати done за id простіше й зазвичай швидше.

#include <iostream>
#include <map>
#include <string>
#include <string_view>

struct MapTaskStorage : ITaskStorage {
    int add(std::string_view title) override {
        const int id = next_id_++;
        tasks_.emplace(id, Task{id, std::string(title), false});
        return id;
    }

    bool mark_done(int id) override {
        auto it = tasks_.find(id);
        if (it == tasks_.end()) {
            return false;
        }
        it->second.done = true;
        return true;
    }

    void print_all(std::ostream& out) const override {
        for (const auto& [id, t] : tasks_) {
            out << "[" << (t.done ? 'x' : ' ') << "] "
                << id << ": " << t.title << "\n";
        }
    }

private:
    int next_id_ = 1;
    std::map<int, Task> tasks_;
};

Зауважте приємну річ: ззовні, через ITaskStorage, обидві реалізації виглядають однаково. Клієнтський код може не знати, що всередині — vector, map чи навіть «масив на один елемент».

4. Налаштування: конфігурація/CLI і StorageKind

Тепер переходимо до суті. Припустімо, користувач хоче вказати, який режим використовувати: "vector" або "map". У реальному застосунку це може бути аргумент командного рядка або параметр конфігурації. Поки що зробимо простіше: прочитаємо рядок зі std::cin.

Спочатку введемо enum class, щоб вибір був типобезпечним. Рядки зручні для введення, але погані як «внутрішній протокол» програми: друкарські помилки трапляються.

enum class StorageKind {
    vector,
    map
};

Тепер варто зафіксувати відповідність «введення → enum»:

Введення користувача StorageKind
"vector"
StorageKind::vector
"map"
StorageKind::map

5. Парсинг: рядок → StorageKind

На цьому етапі ми відокремлюємо «брудний» зовнішній світ рядків від «чистого» внутрішнього світу enum. Можна обійтися й без цього, але тоді код буде складніше тестувати й розширювати.

#include <string_view>

StorageKind parse_storage_kind(std::string_view text) {
    if (text == "vector") return StorageKind::vector;
    if (text == "map")    return StorageKind::map;

    // Контракт: усе невідоме вважаємо vector за замовчуванням.
    return StorageKind::vector;
}

Так, ми вибрали варіант за замовчуванням на випадок помилки. Це не єдиний підхід. Можна було б сигналізувати про помилку через std::optional, але сьогодні наша мета — фабрика. Головне: поведінка для невідомого значення має бути визначеною.

6. Фабрика: make_storage як єдина точка вибору

Фабрика — це функція, яка за налаштуванням створює конкретний обʼєкт, а назовні повертає його через інтерфейс: std::unique_ptr<ITaskStorage>.

Ключова думка: клієнтський код не має знати назв конкретних класів реалізацій. Нехай він знає тільки ITaskStorage.

#include <memory>

std::unique_ptr<ITaskStorage> make_storage(StorageKind kind) {
    switch (kind) {
        case StorageKind::vector:
            return std::make_unique<VectorTaskStorage>();
        case StorageKind::map:
            return std::make_unique<MapTaskStorage>();
    }
    return nullptr; // На практиці сюди не повинні потрапити.
}

Чому unique_ptr, а не просто обʼєкт? Тому що інтерфейсний тип (ITaskStorage) не можна створити напряму: він абстрактний. До того ж нам потрібне коректне видалення через базовий тип. unique_ptr розвʼязує питання володіння й часу життя обʼєкта.

Якщо сказати зовсім просто: фабрика створює обʼєкт, а відповідальність за володіння ним далі бере на себе той, хто цей обʼєкт отримав.

7. Використання в main

Тепер зберемо все в маленький «скелет застосунку». Ми навмисно зробимо main() тонким: він лише читає налаштування, просить фабрику створити потрібний обʼєкт і запускає примітивний сценарій.

#include <iostream>
#include <memory>
#include <string>

int main() {
    std::cout << "Виберіть сховище (vector/map): ";
    std::string mode;
    std::cin >> mode;

    const StorageKind kind = parse_storage_kind(mode);
    std::unique_ptr<ITaskStorage> storage = make_storage(kind);

    const int id1 = storage->add("Вивчити C++");
    const int id2 = storage->add("Написати фабрику");

    storage->mark_done(id1);

    std::cout << "Завдання:\n";
    storage->print_all(std::cout);
}

Можливий вивід:

// Виберіть сховище (vector/map): vector
// Завдання:
// [x] 1: Вивчити C++
// [ ] 2: Написати фабрику

Найважливіше тут — не самі завдання, а те, що main() не знає, що саме було створено. Він знає лише, що це ITaskStorage. Саме це й зменшує звʼязаність коду.

8. Схема потоку вибору

Щоб не загубитися, корисно побачити весь процес на одній простій блок-схемі. У реальному житті «конфігурація/CLI» може бути будь-чим: рядком, enum чи структурою налаштувань. Але принцип не змінюється: спочатку інтерпретуємо налаштування, потім створюємо реалізацію в одному місці.

flowchart TD
    A[Налаштування користувача (рядок/конфігурація/CLI)] --> B[parse_* (рядок -> enum)]
    B --> C[make_*: фабрика (enum -> unique_ptr)]
    C --> D[Клієнтський код працює через ITaskStorage]

Якщо вам десь захочеться перескочити через make_* і створити реалізацію вручну, уявіть, що ви виламуєте двері в цій схемі. Так робити можна, але тоді зникає весь сенс архітектури.

9. Корисні нюанси та розширення

Чому фабрика — не «зайва функція»

Типова реакція новачка: «Навіщо мені фабрика? Я ж можу написати if у main()». Можете. Але фабрика потрібна не тому, що if — заборонений оператор, а тому, що точка вибору має бути одна.

Якщо вибір розкиданий по всьому коду, то під час додавання третьої реалізації вам доведеться шукати всі місця, де створюється обʼєкт. А потім ви майже напевно забудете одне з них — за законом Мерфі саме те, яке зламається на демо.

Фабрика робить дві речі: централізує вибір і захищає клієнтський код від знання про конкретні класи. Це особливо корисно, коли застосунок зростає: зʼявляються тести, окремі команди, шари й кілька сценаріїв запуску.

Чому фабрика за enum майже завжди краща, ніж за рядком

Іноді хочеться зробити так:

std::unique_ptr<ITaskStorage> make_storage(std::string_view mode);

Це може працювати, але так ви змішуєте два завдання: «розпізнати введення» і «створити обʼєкт». На маленькому прикладі це здається зручним, але згодом ви захочете приймати "vector", "vec", "v", "VECTOR" — і фабрика почне займатися тим, чим не має займатися.

Краще зберігати це розділення: parse_* займається «розумінням», make_* — «створенням». Це робить код простішим і для тестування, і для читання: кожна функція відповідає за одну ідею.

Як додати нову реалізацію без болю

Уявімо, що ви захотіли додати StorageKind::debug, який друкує все, що відбувається. Із фабрикою список дій зрозумілий: додати новий елемент у enum class, реалізувати новий клас, додати гілку switch у фабрику. І все. Клієнтський код не змінюється.

Без фабрики довелося б шукати «де в нас створюється storage», «де він іще створюється», «чому він створюється ще й у тестах» і «хто взагалі написав цей код, крім мене в минулому».

10. Типові помилки

Помилка № 1: створювати конкретну реалізацію прямо в клієнтському коді («ой, я просто на хвилинку»).
Зазвичай це починається з фрази «та тут усього один make_unique». Потім таких місць стає пʼять, і фабрика перетворюється на декоративний елемент. Якщо ви домовилися, що вибір реалізації централізований, то створення конкретних класів має бути у фабриці.

Помилка № 2: фабрика повертає ITaskStorage*, а не std::unique_ptr<ITaskStorage>.
Сирі вказівники одразу повертають нас до питань «хто видаляє обʼєкт?» і «коли?». Іноді відповідь — «ніхто». А це, сюрприз, витік. unique_ptr робить володіння явним.

Помилка № 3: забули віртуальний деструктор в інтерфейсі.
Якщо базовий тип поліморфний, але деструктор не віртуальний, видалення через unique_ptr<ITaskStorage> може призвести до некоректного руйнування обʼєкта. Правило просте: у базового інтерфейсу майже завжди має бути virtual ~I() = default;

Помилка № 4: додали у фабрику парсинг рядків, введення з cin, друк помилок і ще пів проєкту.
Фабрика має бути нудною: вона не повинна читати з консолі, ставити запитання користувачу чи вирішувати питання взаємодії з користувачем. Її робота — за вже готовим «вибором» створити потрібний обʼєкт. Якщо фабрика починає робити все підряд, вона перетворюється на монстра, якого всі бояться чіпати.

Помилка № 5: не визначили поведінку для невідомого варіанта.
Коли надходить невідоме значення — друкарська помилка або неправильне налаштування, — програма має поводитися передбачувано: вибрати варіант за замовчуванням, повернути nullptr, надрукувати повідомлення — що завгодно, але визначено. Принцип «якось само спрацює» зазвичай закінчується розіменуванням nullptr і сумним студентом.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ