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. Но сегодня мы не про оптимизацию, а про архитектуру выбора реализации.

4. Реализация №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 или «массив на 1 элемент».

5. Настройка: конфиг/CLI и StorageKind

Теперь переходим к сути. Допустим, пользователь хочет указать, какой режим использовать: "vector" или "map". В реальном приложении это мог бы быть аргумент командной строки или параметр конфигурации. Мы пока сделаем проще: читаем строку из std::cin.

Сначала заведём enum class, чтобы выбор был типобезопасным. Строки хороши для ввода, но плохи как «внутренний протокол» программы: опечатки никто не отменял.

enum class StorageKind {
    vector,
    map
};

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

Ввод пользователя StorageKind
"vector"
StorageKind::vector
"map"
StorageKind::map

6. Парсинг: строка → 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, но сегодня наша цель — фабрика. Главное: поведение при неизвестном значении должно быть определено.

7. Фабрика: 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 решает вопрос владения и времени жизни.

Если говорить совсем по-человечески: фабрика говорит «я создам, ты владей».

8. Использование в main

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

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

int main() {
    std::cout << "Choose storage (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("Learn C++");
    const int id2 = storage->add("Write factory");

    storage->mark_done(id1);

    std::cout << "Tasks:\n";
    storage->print_all(std::cout);
}

Возможный вывод:

// Choose storage (vector/map): vector
// Tasks:
// [x] 1: Learn C++
// [ ] 2: Write factory

Самое важное здесь не «задачи», а то, что main() не знает, что именно создалось. Он знает только, что это ITaskStorage. И это именно то, что уменьшает связанность кода.

9. Схема потока выбора

Чтобы не потеряться, полезно увидеть весь поток как одну простую блок-схему. В реальной жизни «конфиг/CLI» может быть чем угодно (строка, enum, структура настроек), но принцип не меняется: сначала интерпретируем настройки, потом создаём реализацию в одном месте.

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

Если у вас где-то возникает желание перепрыгнуть через make_* и создать реализацию вручную, представьте, что вы выламываете дверь в этой схеме. Можно, но потом весь смысл архитектуры исчезает.

10. Полезные нюансы и расширение

Почему фабрика — это не «лишняя функция»

Очень частая реакция новичка: «Зачем мне фабрика? Я же могу написать 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», «где у нас ещё создаётся storage», «почему оно создаётся ещё и в тестах» и «кто вообще написал этот код, кроме меня из прошлого».

11. Типичные ошибки

Ошибка №1: создавать конкретную реализацию прямо в клиентском коде («ой, я просто на минутку»).
Обычно это начинается с фразы «да тут всего один make_unique». Потом таких мест становится пять, и фабрика превращается в декоративный элемент. Если вы договорились, что выбор реализации централизован, то создание конкретных классов должно жить в фабрике.

Ошибка №2: фабрика возвращает ITaskStorage*, а не std::unique_ptr<ITaskStorage>.
Сырые указатели резко возвращают вопрос «кто удаляет объект?» и «когда?». Иногда ответ «никто» — и это, сюрприз, утечка. unique_ptr делает владение явным.

Ошибка №3: забыли виртуальный деструктор в интерфейсе.
Если базовый тип полиморфный, но деструктор не виртуальный, удаление через unique_ptr<ITaskStorage> может привести к некорректному разрушению объекта. Правило простое: у интерфейса почти всегда должен быть virtual ~I() = default;

Ошибка №4: «запихнули» в фабрику парсинг строк, ввод из cin, печать ошибок и ещё полпроекта.
Фабрика должна быть скучной: она не должна читать из консоли, спрашивать пользователя и решать UX. Её работа — по уже готовому «выбору» создать нужный объект. Если фабрика начинает делать всё подряд, она превращается в монстра, которого боятся трогать.

Ошибка №5: не определили поведение на неизвестный вариант.
Когда приходит неизвестное значение (опечатка, неправильная настройка), программа должна вести себя предсказуемо: выбрать дефолт, вернуть nullptr, напечатать сообщение — что угодно, но определённо. «Ну оно как-нибудь само» обычно заканчивается разыменованием nullptr и грустным студентом.

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