JavaRush /Курсы /C++ SELF /UX CLI: --help, дефолты, коды выхода

UX CLI: --help, дефолты, коды выхода

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

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);

Да, это чуть длиннее. Зато оно читается как русский язык.

Таблица кодов выхода

Иногда таблица — это самый компактный способ объяснить политику. Мы не будем городить десять кодов, но даже два — уже лучше описать.

Код Имя в коде Смысл
0
ExitCode::ok
Всё успешно, результат корректен
2
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.

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