JavaRush /Курсы /C++ SELF /Разделение модулей: model / storage / parser / cli

Разделение модулей: model / storage / parser / cli

C++ SELF
50 уровень , 3 лекция
Открыта

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> куда попало.

Модуль Что хранит/делает Чего принципиально не делает
model
Структуры данных (Task), простые правила корректности Не читает ввод, не печатает, не знает про команды
storage
Хранение и операции над моделями (добавить, найти, изменить) Не парсит строки, не общается с пользователем
parser
Строка → Command (или «не получилось распарсить») Не меняет хранилище, не печатает «ОК/ОШИБКА»
cli
Ввод/вывод, цикл общения, координация вызовов Не дублирует парсинг, не хранит 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, то они должны жить в одном месте, а остальное должно получать их через понятные методы.

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