1. Общая схема взаимодействия модулей
Если вы когда‑нибудь видели код на 800 строк в одном main.cpp, где вперемешку чтение строк, поиск по std::vector, печать таблиц и разбор команд, то вы уже знаете, как выглядит «архитектура отчаяния». Такой код обычно работает ровно до первой просьбы: «а давай добавим ещё одну команду». После этого начинается весёлый аттракцион: всё ломается, правится в одном месте, ломается в другом — и вы подозреваете заговор.
Идея сегодняшнего шага простая: мы делим программу на модули по ролям, чтобы изменения были локальными. Изменился формат команд? Значит правим парсер. Изменились правила хранения задач? Значит правим хранилище. Изменился формат вывода? Значит правим CLI. А данные (model) вообще стараемся держать максимально «чистыми» и скучными — это комплимент, не ругательство.
Чтобы было наглядно, будем развивать одно и то же учебное приложение: маленький todo‑менеджер в консоли. Не Trello, конечно, но и мы пока не корпорация.
Контекст примера: консольный Todo и маршрут команды
Перед тем как резать код на модули, полезно представить, как вообще течёт управление в программе. Пользователь вводит строку, программа должна понять, что за команда, затем выполнить её над данными, и потом показать результат. Если это не проговорить, легко случайно смешать роли: например, сделать так, чтобы хранилище само читало std::cin. Это звучит так же странно, как «холодильник сам решает, что вы будете есть».
Наша минимальная командная модель будет такой:
- add <id> <title...> — добавить задачу
- done <id> — отметить выполненной
- list — показать список
- help — подсказка
- quit — выход
Заметьте важную мысль: всё начинается со строки. Поэтому нам удобно разложить код так:
flowchart LR
U[Пользователь] -->|ввод строки| CLI[cli]
CLI -->|строка| P[parser]
P -->|Command| CLI
CLI -->|вызов операций| S[storage]
S -->|Task/результат| CLI
CLI -->|печать| U
S <-->|работает с| M[model]
Здесь model — это «какие данные существуют», storage — «как мы их храним и меняем», parser — «как строку превратить в структуру команды», cli — «как общаться с человеком».
3. model: данные и правила корректности
Когда говорят «модель», новичкам иногда мерещится что-то из мира «архитектуры на 200 диаграмм». На практике модель — это просто структуры данных, которые описывают предметную область. В нашем случае предметная область — задачи.
Важно прочувствовать границу: модель не должна уметь «сама себя печатать в консоль» и «сама себя загружать из ввода». Модель — это как анкета: в ней поля, а не человек, который бегает и собирает подписи.
Начнём с Task.
#include <string>
struct Task {
int id{};
std::string title{};
bool done{false};
};
Обратите внимание: здесь нет std::cout, нет std::cin, нет «разобрать строку add 1 ...». Только данные.
Теперь добавим маленькое правило корректности: заголовок задачи не должен быть пустым или состоять только из пробелов. Это не «логика интерфейса», это правило данных: пустая задача — это как файл без имени (технически можно, но потом больно).
#include <cctype>
#include <string>
bool is_blank(const std::string& s) {
for (char ch : s) {
if (!std::isspace(static_cast<unsigned char>(ch))) return false;
}
return true;
}
И снова: никаких чтений/печатей. Просто функция, которую можно использовать где угодно.
4. storage: хранение и операции над задачами
Если модель — это «что мы храним», то storage — это «как мы с этим работаем». Важно, что storage не обязан быть «про файлы» или «про базу данных». Даже если данные лежат в std::vector, это уже хранилище.
Ключевая граница: storage не должен знать про формат команд и не должен печатать пользователю «ОК» или «ОШИБКА». Он должен уметь делать операции и возвращать результат так, чтобы верхний уровень (cli) сам решил, что показать человеку.
Сделаем TaskRepository, который хранит задачи в std::vector<Task>.
#include <vector>
class TaskRepository {
public:
void add(Task t) {
tasks_.push_back(std::move(t));
}
std::size_t size() const {
return tasks_.size();
}
private:
std::vector<Task> tasks_;
};
Это «скелет». Теперь нужны операции. Самые частые — поиск по id и отметка done.
Для поиска удобно вернуть указатель: если нашли — вернули адрес элемента, если нет — вернули nullptr. Это хорошо сочетается с уже знакомой идеей nullable‑контракта.
class TaskRepository {
public:
Task* find_by_id(int id) {
for (auto& t : tasks_) {
if (t.id == id) return &t;
}
return nullptr;
}
const Task* find_by_id(int id) const {
for (const auto& t : tasks_) {
if (t.id == id) return &t;
}
return nullptr;
}
private:
std::vector<Task> tasks_;
};
Теперь отметка выполнения. Заметьте: метод не печатает «готово». Он просто возвращает true/false.
class TaskRepository {
public:
bool mark_done(int id) {
Task* t = find_by_id(id);
if (t == nullptr) return false;
t->done = true;
return true;
}
// find_by_id как выше...
private:
std::vector<Task> tasks_;
};
Если вы чувствуете соблазн сделать внутри mark_done вот так: std::cout << "Task done!\n";, остановитесь и глубоко вдохните. Это смешивание ролей: хранилище вдруг решило стать ведущим на радио.
Осталась ещё одна важная операция: «дать список задач». И вот тут начинается дизайнерский момент: вернуть ссылку на tasks_ — быстро, но мы «светим кишки» наружу. Вернуть копию — безопаснее, но копирование может быть дорогим.
Для учебного примера (и чтобы не уезжать в будущие темы) сделаем простой и понятный вариант: возвращаем const std::vector<Task>&, но будем честно понимать, что это компромисс (а позже научимся делать лучше).
class TaskRepository {
public:
const std::vector<Task>& all() const {
return tasks_;
}
private:
std::vector<Task> tasks_;
};
5. parser: строка → структура команды
Парсер — это переводчик. Он получает строку и превращает её в структурированное «что хотел пользователь». Это важно: парсер не выполняет команду. Он не должен добавлять задачу в репозиторий. Он просто говорит: «кажется, это команда add с такими параметрами».
Для начала создадим тип команды: CommandType и Command. Мы не будем использовать «тяжёлую артиллерию» вроде variant/expected (это отдельные темы), поэтому сделаем простой вариант.
#include <optional>
#include <string>
enum class CommandType {
Add,
Done,
List,
Help,
Quit
};
struct Command {
CommandType type{};
std::optional<int> id{};
std::string title{};
};
Смысл такой: для list/help/quit поля id/title будут пустыми, а для add и done — заполненными по необходимости.
Теперь напишем parse_command. Для простоты используем std::istringstream. Мы его уже проходили (потоковый парсинг), и он отлично подходит для «разобрать слово, число, остаток строки».
#include <optional>
#include <sstream>
#include <string>
std::optional<Command> parse_command(const std::string& line) {
std::istringstream in(line);
std::string word;
if (!(in >> word)) return std::nullopt;
if (word == "list") return Command{CommandType::List};
if (word == "help") return Command{CommandType::Help};
if (word == "quit") return Command{CommandType::Quit};
return std::nullopt;
}
Это «первая версия»: понимает только три команды без параметров.
Добавим done <id>. Здесь важно: если id не число — команда не распарсилась.
std::optional<Command> parse_command(const std::string& line) {
std::istringstream in(line);
std::string word;
if (!(in >> word)) return std::nullopt;
if (word == "done") {
int id{};
if (!(in >> id)) return std::nullopt;
Command cmd{CommandType::Done};
cmd.id = id;
return cmd;
}
if (word == "list") return Command{CommandType::List};
if (word == "help") return Command{CommandType::Help};
if (word == "quit") return Command{CommandType::Quit};
return std::nullopt;
}
И добавим add <id> <title...>. Главная тонкость: заголовок — это остаток строки, и он может содержать пробелы. Значит, после id нам нужно прочитать остаток через std::getline.
std::optional<Command> parse_command(const std::string& line) {
std::istringstream in(line);
std::string word;
if (!(in >> word)) return std::nullopt;
if (word == "add") {
int id{};
if (!(in >> id)) return std::nullopt;
std::string title;
std::getline(in, title); // остаток строки
if (!title.empty() && title[0] == ' ') title.erase(0, 1);
if (title.empty()) return std::nullopt;
Command cmd{CommandType::Add};
cmd.id = id;
cmd.title = std::move(title);
return cmd;
}
// done/list/help/quit как раньше...
return std::nullopt;
}
Обратите внимание, как аккуратно выглядит контракт: либо std::optional<Command> с заполненными полями, либо std::nullopt. Парсер ничего не печатает. Он даже не говорит «неизвестная команда» — потому что парсер не обязан решать, как общаться с человеком. Это задача CLI.
6. cli: ввод/вывод и координация модулей
CLI — это «дирижёр». Он читает ввод, вызывает парсер, вызывает операции хранилища, печатает результат. При этом CLI старается быть тонким: он не должен хранить внутри себя сложные алгоритмы поиска и не должен сам вручную разбирать строку «по пробелам» (для этого есть парсер).
Сделаем функцию print_help.
#include <iostream>
void print_help() {
std::cout << "Commands:\n";
std::cout << " add <id> <title>\n";
std::cout << " done <id>\n";
std::cout << " list\n";
std::cout << " help\n";
std::cout << " quit\n";
}
Теперь print_task — формат вывода одной задачи. Это опять же CLI‑ответственность: формат отображения — часть интерфейса.
#include <iostream>
void print_task(const Task& t) {
std::cout << (t.done ? "[x] " : "[ ] ");
std::cout << t.id << ": " << t.title << "\n";
// пример: [ ] 1: Buy milk
}
Наконец, основной цикл. Старайтесь читать его как историю: «прочитал строку → распарсил → выполнил → напечатал».
#include <iostream>
#include <string>
void run_cli(TaskRepository& repo) {
std::string line;
while (true) {
std::cout << "> " << std::flush;
if (!std::getline(std::cin, line)) break;
auto cmdOpt = parse_command(line);
if (!cmdOpt) {
std::cout << "INVALID. Type help.\n";
continue;
}
const Command& cmd = *cmdOpt;
if (cmd.type == CommandType::Quit) break;
if (cmd.type == CommandType::Help) { print_help(); continue; }
if (cmd.type == CommandType::List) {
for (const auto& t : repo.all()) print_task(t);
continue;
}
}
}
Пока мы не обработали add и done. Добавим их, но сохраним стиль «CLI координирует, storage делает работу».
void run_cli(TaskRepository& repo) {
std::string line;
while (true) {
std::cout << "> " << std::flush;
if (!std::getline(std::cin, line)) break;
auto cmdOpt = parse_command(line);
if (!cmdOpt) {
std::cout << "INVALID. Type help.\n";
continue;
}
const Command& cmd = *cmdOpt;
if (cmd.type == CommandType::Quit) break;
if (cmd.type == CommandType::Add) {
int id = *cmd.id;
if (is_blank(cmd.title)) {
std::cout << "Title is blank.\n";
continue;
}
repo.add(Task{id, cmd.title, false});
std::cout << "OK\n";
continue;
}
if (cmd.type == CommandType::Done) {
int id = *cmd.id;
bool ok = repo.mark_done(id);
std::cout << (ok ? "OK\n" : "NOT FOUND\n");
continue;
}
if (cmd.type == CommandType::Help) { print_help(); continue; }
if (cmd.type == CommandType::List) {
for (const auto& t : repo.all()) print_task(t);
continue;
}
}
}
Смотрите, как роли удерживаются:
- parser решает «что написали»;
- storage решает «как поменять данные»;
- cli решает «что показать пользователю».
main() теперь становится очень спокойным. И это редкий случай, когда «спокойный main» — хорошая новость.
int main() {
TaskRepository repo;
print_help();
run_cli(repo);
}
Таблица ролей модулей
Когда код маленький, кажется, что модульность — это «для больших дядь». Но на практике даже в учебном проекте границы полезны: вы меньше путаетесь и быстрее находите нужное место для правки.
Ниже — «карта ролей» для нашего разбиения. Её удобно держать в голове, когда рука тянется включить <iostream> куда попало.
| Модуль | Что хранит/делает | Чего принципиально не делает |
|---|---|---|
|
Структуры данных (Task), простые правила корректности | Не читает ввод, не печатает, не знает про команды |
|
Хранение и операции над моделями (добавить, найти, изменить) | Не парсит строки, не общается с пользователем |
|
Строка → Command (или «не получилось распарсить») | Не меняет хранилище, не печатает «ОК/ОШИБКА» |
|
Ввод/вывод, цикл общения, координация вызовов | Не дублирует парсинг, не хранит CRUD-логику внутри |
Эта таблица кажется очевидной… пока вы не добавите две новые команды и не поймаете себя на «да ладно, я тут быстренько прямо в CLI вектор руками пройдусь». А потом у вас «временно» появится 6 мест, где задачи перебираются разным способом.
7. Типичные ошибки
Ошибка №1: печать из storage.
Самая частая проблема выглядит невинно: внутри mark_done() хочется написать std::cout << "OK\n";. Сначала это удобно, потом внезапно вам надо будет использовать storage не только в консоли, но, например, в тестах или в другом интерфейсе. И выяснится, что хранилище «болтает» в неожиданный момент. Правильнее возвращать результат (bool, optional, указатель), а решение «что печатать» оставить CLI.
Ошибка №2: парсер, который выполняет действие.
Новички часто делают функцию parse_and_execute(line, repo). Кажется, что так меньше кода. Но цена — рост связности: парсер теперь обязан знать про хранилище, а любая правка формата команд начинает затрагивать бизнес‑логику. Гораздо устойчивее модель «parse → Command → execute», где execute живёт уровнем выше (в CLI).
Ошибка №3: модель начинает «знать про мир» (iostream, команды, форматирование).
Если в Task появляется метод print() или, ещё веселее, static Task read_from_console(), то модель начинает тянуть зависимости и превращается в комбайн. Это ломает читаемость и повторное использование. Модель должна быть максимально «тупой», потому что именно на «тупых и предсказуемых» типах хорошо держится остальной код.
Ошибка №4: CLI превращается в «место всей логики».
Когда CLI быстро растёт, появляется желание всё делать прямо там: и искать, и сортировать, и валидировать, и хранить формат команд. Итог — огромная функция run_cli() на 300 строк, которую страшно трогать. Удерживайте дисциплину: CLI координирует, но не «выполняет всю работу руками». Если внутри CLI вы увидели длинный цикл по задачам с условиями — это хороший повод подумать, не должна ли часть этого жить в storage.
Ошибка №5: размытые границы приводят к «двойным источникам истины».
Например, CLI хранит отдельный vector<Task> «для отображения», а storage хранит свой. Потом один обновили, другой забыли. Результат: интерфейс показывает одно, а данные внутри другие. Если данные — это ответственность storage, то они должны жить в одном месте, а остальное должно получать их через понятные методы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ