1. CLI — это UX
Когда вы пишете консольную программу, у вас нет красивых кнопок, подсказок и всплывающих окон — но пользователь всё равно «общается» с интерфейсом. И если интерфейс у вас сделан “на авось”, пользователь начинает страдать, а потом страдаете вы (потому что вам прилетает вопрос: «почему не работает?»). Поэтому CLI стоит проектировать как маленький договор: что можно передавать, что программа делает, что она печатает, и как понять, что всё прошло успешно.
Ключевая идея: CLI — это контракт. В этом контракте обычно есть четыре обязательных части: --help, дефолтные значения (что будет, если опцию не передали), понятные ошибки пользователя и понятные коды выхода. Мы сегодня соберём это в единый каркас вокруг нашего учебного приложения.
В качестве “приложения” возьмём простую утилиту FocusTimer: она пока не будет «таймером с ожиданием» (это уже ближе к потокам и ожиданиям, туда мы сегодня не лезем), но будет честно рассчитывать план фокус-сессий: сколько минут работы, сколько минут перерыва, сколько циклов. Суть нам важна не бизнес-логикой, а интерфейсом.
2. --help: как сделать программу самодокументируемой
Флаг --help — это не «бонус», а часть нормального поведения любой CLI-утилиты. Пользователь не обязан читать ваш исходный код телепатией. Хороший --help отвечает на два вопроса: “как запустить” и “какие есть параметры”. А ещё очень важный нюанс: запрос справки — это не ошибка, поэтому программа должна завершаться успешно (кодом 0), а не «обиженно».
Usage-сообщение: что в нём должно быть
Перед тем как писать код, полезно договориться о формате. Мы сделаем простой и устойчивый вариант:
- первая строка — краткая форма запуска,
- дальше — описание опций и дефолтов,
- в конце — один-два примера запуска.
И очень удобно печатать usage не только в std::cout, но и иногда в std::cerr (например, при ошибке аргументов). Поэтому usage лучше печатать через параметр std::ostream&.
#include <iostream>
void print_usage(std::ostream& out) {
out << "FocusTimer - план фокус-сессий\n";
out << "Usage:\n";
out << " focustimer [--work MIN] [--break MIN] [--cycles N] [--help]\n";
}
Обратите внимание: мы не используем никакие «магические» знания о платформах и не усложняем. Это просто текст.
Обработка --help и -h: ранний выход
Хорошая практика — обрабатывать --help как можно раньше и завершать работу сразу. Это называется «ранний выход»: меньше вложенных if, проще читать.
#include <iostream>
#include <string_view>
int main(int argc, char* argv[]) {
for (int i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "--help" || arg == "-h") {
print_usage(std::cout);
return 0;
}
}
// ... основной код
}
Если пользователь явно просит справку, нам не нужно продолжать разбор остальных аргументов. Он не пришёл «выполнять задачу» — он пришёл понять, как ей пользоваться.
3. Дефолты: чтобы программа работала даже без параметров
Дефолтные значения — это то, что делает CLI дружелюбным. Пользователь может запустить программу “как есть” и получить адекватный результат. Это особенно важно в обучающих и утилитарных инструментах: если при запуске без аргументов программа сразу ругается, то интерфейс ощущается как «закрытая дверь без ручки».
Мы в FocusTimer зададим такие дефолты: work = 25 минут, break = 5 минут, cycles = 4. Это «помидорка» (Pomodoro), только без претензий на психологию — просто числа.
Конфиг как структура: один объект вместо россыпи переменных
Когда параметров становится больше двух, очень полезно собрать их в структуру. Так вы не таскаете по коду три отдельные переменные и не забываете, что к чему относится.
struct Config {
int work_minutes = 25;
int break_minutes = 5;
int cycles = 4;
};
Это уже читается как “настройки”, а не как “случайные int’ы”.
Дефолты должны быть видны пользователю
Одна из типичных UX-ошибок — дефолты «есть в коде», но их не видно в --help. Пользователь запускает --help и видит список опций, но не понимает, что будет, если ничего не указать. Поэтому мы добавим дефолты прямо в usage.
#include <iostream>
void print_usage(std::ostream& out) {
out << "FocusTimer - план фокус-сессий\n";
out << "Usage:\n";
out << " focustimer [--work MIN] [--break MIN] [--cycles N] [--help]\n\n";
out << "Options (defaults):\n";
out << " --work MIN minutes of work (default 25)\n";
out << " --break MIN minutes of break (default 5)\n";
out << " --cycles N number of cycles (default 4)\n";
out << " -h, --help show this help\n";
}
Да, это много текста. Зато это текст, который экономит часы объяснений.
4. Ошибки пользователя: коротко, по делу, и с подсказкой
Ошибки бывают разные. Бывает «пользователь ошибся» (ввел неизвестную опцию, забыл значение, указал отрицательное число). А бывает «программа сломалась» (например, вы где-то ошиблись в коде). В рамках UX CLI нас больше интересует первая группа: ошибки пользователя должны быть понятными, без истерики и без “Segmentation fault (core dumped)”.
Главные правила очень простые: печатаем ошибку в std::cerr, формулируем её одной-двумя строками, и добавляем подсказку “Use --help”. Это как вежливый охранник: “Сюда нельзя, но вот дверь, куда можно”.
std::cout vs std::cerr: разделяем результат и проблемы
Есть негласное соглашение: то, что является «нормальным выводом программы», печатаем в std::cout. А всё, что является ошибкой или диагностикой, печатаем в std::cerr. Это важно, потому что stdout часто перенаправляют в файл или в другую программу, и пользователю не хочется, чтобы туда попали ваши ругательства.
Пример “неизвестная опция”:
#include <iostream>
#include <string_view>
void print_unknown_option(std::string_view opt) {
std::cerr << "Error: unknown option: " << opt << '\n';
std::cerr << "Use --help to see available options.\n";
}
Это сообщение короткое, понятное, и указывает, что делать дальше.
Ошибка «не хватило значения» — самая частая в argv
Если у вас есть опция формата --key value, то самая частая ошибка — пользователь написал --key, а значение не дал. И вот тут многие новички делают классическую беду: читают argv[i + 1] без проверки и получают UB. Мы делаем наоборот: проверяем границы и выдаём нормальную ошибку.
#include <iostream>
#include <string_view>
bool require_value(int i, int argc, std::string_view opt) {
if (i + 1 >= argc) {
std::cerr << "Error: " << opt << " requires a value\n";
std::cerr << "Use --help to see usage.\n";
return false;
}
return true;
}
Да, это маленькая функция, но она дисциплинирует код: вы не “надеетесь”, вы проверяете.
5. Коды выхода: чтобы скрипты понимали, что произошло
Код выхода процесса — это то, что возвращает main. Для человека это не всегда заметно, но для автоматизации (скриптов, CI, других программ) это главный сигнал “успех/ошибка”. Соглашение простое: 0 — успех, любое ненулевое — ошибка. А дальше начинается дизайн: какие ошибки различать.
Мы сделаем минимально разумную политику: 0 — успех, 2 — ошибка аргументов. Почему 2? Потому что 1 часто используют для «общей ошибки», а 2 удобно держать именно для bad args. Это не закон физики, а договор, но договор полезный.
Почему лучше не писать return 2; напрямую
Потому что через неделю вы увидите return 2; и будете вспоминать: “2 — это что? два попугая? два цикла? две беды?” Поэтому мы объявим enum class ExitCode.
enum class ExitCode : int {
ok = 0,
bad_args = 2
};
И возвращать будем так:
return static_cast<int>(ExitCode::bad_args);
Да, это чуть длиннее. Зато оно читается как русский язык.
Таблица кодов выхода
Иногда таблица — это самый компактный способ объяснить политику. Мы не будем городить десять кодов, но даже два — уже лучше описать.
| Код | Имя в коде | Смысл |
|---|---|---|
|
|
Всё успешно, результат корректен |
|
|
Пользователь передал неверные аргументы |
Если вы когда-нибудь будете писать утилиту, которую запускают другие программы, эта таблица внезапно станет важнее вашего чувства прекрасного.
6. Собираем каркас FocusTimer: parse_args, дефолты и ошибки
Теперь мы соберём всё в маленькую архитектуру: main будет тонким, print_usage — отдельной функцией, parse_args — отдельной функцией. И да, это всё ещё “один файл”, потому что мы сегодня учимся UX, а не структуре проекта по папкам.
Сначала сделаем аккуратный парсер чисел. Мы хотим парсить int без исключений и без “ой, оно упало”. Для этого удобно использовать std::from_chars. Он возвращает код ошибки, а не кидает исключение.
#include <charconv>
#include <optional>
#include <string_view>
std::optional<int> parse_int(std::string_view s) {
int value = 0;
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
if (ec != std::errc{} || ptr != s.data() + s.size()) return std::nullopt;
return value;
}
Здесь важный UX-момент: мы считаем ошибкой не только “не число”, но и “число с хвостом” ("10min"). Это обычно правильнее для CLI: пользователь должен увидеть, что формат неверный.
Теперь — проверка “положительное число” (минуты и количество циклов должны быть > 0).
#include <optional>
#include <string_view>
std::optional<int> parse_positive_int(std::string_view s) {
auto v = parse_int(s);
if (!v || *v <= 0) return std::nullopt;
return v;
}
Дальше — parse_args. Мы будем возвращать ExitCode, а Config заполнять по ссылке. Если встретили ошибку, печатаем её и возвращаем bad_args.
#include <iostream>
#include <string_view>
ExitCode parse_args(int argc, char* argv[], Config& cfg) {
for (int i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "--work") {
if (!require_value(i, argc, arg)) return ExitCode::bad_args;
auto v = parse_positive_int(argv[i + 1]);
if (!v) { std::cerr << "Error: --work must be a positive integer\n"; return ExitCode::bad_args; }
cfg.work_minutes = *v;
++i;
}
}
return ExitCode::ok;
}
Фрагмент короткий, но уже показывает стиль: проверка границ, проверка значения, понятная ошибка. Обратите внимание: ++i после “съели значение” — это не украшение, а обязательная логика.
Давайте добавим остальные параметры по тому же шаблону (да, это немного однообразно — но CLI-парсинг в таком виде и есть “контролируемая скука”).
ExitCode parse_args(int argc, char* argv[], Config& cfg) {
for (int i = 1; i < argc; ++i) {
std::string_view arg = argv[i];
if (arg == "--work" || arg == "--break" || arg == "--cycles") {
if (!require_value(i, argc, arg)) return ExitCode::bad_args;
auto v = parse_positive_int(argv[i + 1]);
if (!v) { std::cerr << "Error: " << arg << " must be a positive integer\n"; return ExitCode::bad_args; }
if (arg == "--work") cfg.work_minutes = *v;
if (arg == "--break") cfg.break_minutes = *v;
if (arg == "--cycles") cfg.cycles = *v;
++i;
continue;
}
if (arg == "--help" || arg == "-h") {
print_usage(std::cout);
return ExitCode::ok;
}
if (arg.starts_with("-")) {
print_unknown_option(arg);
return ExitCode::bad_args;
}
}
return ExitCode::ok;
}
Здесь мы сделали важную UX-договорённость: все неизвестные аргументы, начинающиеся с -, считаем опциями и ругаемся. А что делать с позиционными аргументами (например, имя файла) — это уже часть следующей архитектуры и следующей лекции дня, где мы будем обсуждать режимы работы и структуру программы. Сегодня мы сознательно держим контракт простым.
Остаётся main: дефолтный конфиг, парсинг, обработка результата, нормальный вывод.
#include <iostream>
int main(int argc, char* argv[]) {
Config cfg{};
ExitCode code = parse_args(argc, argv, cfg);
if (code != ExitCode::ok) return static_cast<int>(code);
std::cout << "Plan: " << cfg.cycles << " cycles\n";
std::cout << "Work " << cfg.work_minutes << " min, break " << cfg.break_minutes << " min\n";
return static_cast<int>(ExitCode::ok);
// Example output:
// Plan: 4 cycles
// Work 25 min, break 5 min
}
С точки зрения UX это уже приятная программа: есть help, есть дефолты, есть понятные ошибки, есть понятный код выхода.
7. Как это выглядит для пользователя: несколько сценариев
Очень полезно мысленно (или реально) прогнать типовые кейсы: “без аргументов”, “help”, “ошибка опции”, “ошибка значения”. Это то место, где вы внезапно начинаете видеть, что интерфейс либо дружелюбный, либо “ну я так написал”.
Сценарий 1: пользователь просто запускает программу без аргументов. Это должно работать.
$ focustimer
Plan: 4 cycles
Work 25 min, break 5 min
Сценарий 2: пользователь просит help. Это не ошибка, значит код выхода 0.
$ focustimer --help
FocusTimer - план фокус-сессий
Usage:
focustimer [--work MIN] [--break MIN] [--cycles N] [--help]
...
Сценарий 3: пользователь ошибся в имени опции. Мы не “падаем”, мы объясняем.
$ focustimer --works 30
Error: unknown option: --works
Use --help to see available options.
Сценарий 4: пользователь забыл значение. Это классическая ошибка, и здесь особенно важно не выйти за границы argv.
$ focustimer --work
Error: --work requires a value
Use --help to see usage.
Сценарий 5: значение есть, но оно неверное.
$ focustimer --cycles 0
Error: --cycles must be a positive integer
Обратите внимание на стиль: коротко и без морали. Программа не должна читать лекции пользователю — для этого есть преподаватель (то есть я), но не утилита.
8. Типичные ошибки в UX CLI
Ошибка №1: --help считается ошибкой и возвращает ненулевой код.
Это выглядит мелочью, но ломает автоматизацию: скрипт, который проверяет “команда доступна и работает”, может вызывать tool --help и ожидать 0. Если вы возвращаете 1 или 2, вы сообщаете системе “что-то пошло не так”, хотя пользователь попросил справку. В нормальном CLI справка — это успешный сценарий.
Ошибка №2: usage печатается только в одном месте, а при ошибках пользователь не получает подсказки.
Когда программа пишет “Error: bad input” и молчит, пользователь начинает гадать, какие аргументы нужны. Правильная практика — хотя бы одна строка “Use --help”. Иногда уместно даже напечатать usage в std::cerr при ошибке, но минимум — подсказка должна быть всегда.
Ошибка №3: ошибки пользователя печатаются в std::cout.
Пока вы тестируете вручную, это кажется «неважным». Но как только пользователь сделает focustimer > plan.txt, он ожидает, что в файл попадёт план, а не “Error: unknown option”. Для этого и существует std::cerr: отделяем результат от проблем.
Ошибка №4: “магические числа” в return.
return 2; в маленькой программе кажется нормальным, но в реальном проекте через месяц вы забудете, почему 2, а не 3. enum class ExitCode делает поведение самодокументируемым и в коде, и в голове. Это такой маленький “пояс безопасности” для будущего вас.
Ошибка №5: не фиксируется контракт по дефолтам.
Когда дефолты есть в коде, но не написаны в --help, пользователи начинают додумывать. Один думает, что --cycles по умолчанию 1, другой — что 10, третий — что программа сама “как-то решит”. В итоге поведение кажется непредсказуемым. Дефолт — это часть интерфейса, значит он должен быть явно описан в usage.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ