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 |
|---|---|
|
|
|
|
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 и грустным студентом.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ