JavaRush /Курсы /C++ SELF /Политика ошибок

Политика ошибок

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

1. Зачем нужна политика ошибок, если уже есть Error?

Когда программа маленькая, очень легко жить по принципу «ну тут std::cout, там return -1, а вот тут я просто напишу “что-то пошло не так”». Это работает ровно до момента, когда вы добавляете третью команду, четвёртую функцию и пятого человека, который читает ваш код. И вот тогда выясняется, что программа может печатать ошибки в разных форматах, иногда в cout, иногда в cerr, иногда вообще молча. Пользователь злится, вы злитесь, компьютер… ну, компьютер не злится, он просто молча делает вид, что всё нормально.

Политика ошибок — это договор внутри проекта: кто создаёт ошибку, кто её «упаковывает» в результат, кто её печатает, кто выбирает код выхода. Идея простая: ошибка должна пройти путь от места возникновения до границы программы так, чтобы по дороге она не превратилась в кашу из строк и «магических» чисел.

Микро-аналогия

Представьте, что вы передаёте посылку. Error — это сама посылка (внутри содержимое: код, сообщение, контекст). Политика ошибок — это правила доставки: кто принимает посылку на складе, кто везёт, и кто в конце вручает человеку и говорит: «вот что случилось».

Правило: рабочие функции не печатают ошибки

Сейчас важный момент, который многим новичкам кажется странным: «Если я поймал ошибку, почему бы мне её сразу не вывести?» Потому что «сразу» — это почти всегда не там. Внутри функций у вас нет полного контекста: что делает программа, в каком режиме работает, нужно ли продолжать, и кто вообще потребитель этой ошибки (пользователь, лог‑файл, тесты, другая программа).

Поэтому мы вводим правило, которое делает код предсказуемым: функции бизнес‑логики не печатают ошибки, они возвращают их наверх в виде std::expected (или std::variant, если expected недоступен). А вот граница программы (обычно main) — это место, где ошибка превращается в текст и уходит либо пользователю, либо в лог.

Если хочется запомнить это одной фразой: «Ошибки создаются в глубине, печатаются на границе».

2. Инструменты политики ошибок

std::cout vs std::cerr: почему важно разделять потоки

Разделение вывода на «обычный» и «ошибочный» кажется мелочью, пока вы не запускаете программу в консоли и не перенаправляете вывод в файл. В Unix‑мире (и не только) есть сильная традиция: stdout — для нормального результата, stderr — для ошибок и диагностики. Тогда можно сделать так: ./app > result.txt и получить «чистый» результат без мусора от ошибок, а ошибки останутся на экране.

В C++ это выражается просто: обычные сообщения и результаты — в std::cout, ошибки — в std::cerr. Это не «стилистика ради стиля», это реально влияет на удобство жизни.

Мини‑пример (очень учебный, но показывает идею):

#include <iostream>

int main() {
    std::cout << "tasks: 3\n";          // tasks: 3
    std::cerr << "parse_error: ...\n";  // parse_error: ...
}

Да, пользователю в консоли может казаться, что «всё в одном месте». Но для автоматизации и адекватной диагностики — разделение спасает.

Единый формат печати ошибки

Если вы уже сделали ErrorCode, Error и format_error, то дальше всё довольно механично. Важно договориться, что все ошибки печатаются через одну функцию, а не «кто во что горазд». Иначе один модуль пишет Error: ..., второй [ERR] ..., третий ошибка!!!, а четвёртый молча возвращает -1 и загадочно уходит в закат.

Ниже — компактный шаблон того, к чему мы стремимся (сильно похоже на то, что вы делали в прошлой лекции, просто теперь мы подчёркиваем: это обязательная точка печати).

#include <optional>
#include <string>

enum class ErrorCode { InvalidInput, ParseError, NotFound };

struct Error {
    ErrorCode code{};
    std::string message;
    std::optional<std::string> context;
};

std::string to_string(ErrorCode code) {
    switch (code) {
        case ErrorCode::InvalidInput: return "invalid_input";
        case ErrorCode::ParseError:   return "parse_error";
        case ErrorCode::NotFound:     return "not_found";
    }
    return "unknown";
}

std::string format_error(const Error& e) {
    std::string out = to_string(e.code) + ": " + e.message;
    if (e.context) out += " (" + *e.context + ")";
    return out;
}

Обратите внимание на «психологию» формата: сначала стабильный код (по нему легко искать в логах), затем человеческое сообщение, затем контекст (если есть). Такую строку приятно читать и человеку, и скрипту.

Мини‑логирование: уровни и единая функция log(...)

Настоящее логирование с файлами, ротацией, временем, JSON‑форматом и прочими радостями взрослой жизни — это отдельная тема. Сегодня нам нужен только концепт: сообщения должны иметь уровень важности, и печататься они должны единообразно. Мы не строим фреймворк, мы строим дисциплину.

Введём простейшие уровни: Info, Warn, Error. И сделаем одну функцию, которая пишет в std::cerr (да, даже Info — в учебном проекте это нормально, потому что это диагностика; если хотите, позже можно разделить).

#include <iostream>
#include <string_view>

enum class LogLevel { Info, Warn, Error };

std::string_view to_string(LogLevel lvl) {
    switch (lvl) {
        case LogLevel::Info:  return "INFO";
        case LogLevel::Warn:  return "WARN";
        case LogLevel::Error: return "ERROR";
    }
    return "UNKNOWN";
}

void log(LogLevel lvl, std::string_view msg) {
    std::cerr << to_string(lvl) << ": " << msg << '\n';
}

Почему это полезно даже без «настоящего логгера»? Потому что когда вы пишете log(LogLevel::Warn, "..."), вы сами себе объясняете: «это предупреждение, программа может продолжать». А когда пишете log(LogLevel::Error, "..."), вы сигнализируете: «это ошибка, дальше нельзя/не стоит».

И да, это ещё и уменьшает количество кода: вы не размазываете по проекту std::cerr << ....

Коды выхода процесса: как сделать поведение предсказуемым

Код выхода — это то, что получает «внешний мир», когда программа заканчивается. Даже если вы не пишете скрипты, ваш преподаватель/проверяющая система/IDE фактически играют роль «внешнего мира». Базовое правило: 0 означает успех, любое ненулевое значение — ошибка. Это почти универсальная традиция.

Дальше возникает практичный вопрос: «А какое ненулевое?» Если возвращать всегда 1, то «ошибка есть», но какая — непонятно. Поэтому мы делаем отображение ErrorCode -> int в одном месте. Пример таблицы для нашего учебного CLI:

ErrorCode Смысл exit code
InvalidInput
Пользователь ввёл ерунду
2
ParseError
Не смогли распарсить команду
3
NotFound
Не нашли сущность по id
4
unknown
Всё остальное
1

И реализация:

int exit_code(ErrorCode c) {
    switch (c) {
        case ErrorCode::InvalidInput: return 2;
        case ErrorCode::ParseError:   return 3;
        case ErrorCode::NotFound:     return 4;
    }
    return 1;
}

Тут важна не «красота чисел», а факт централизованности. Вы всегда можете поменять политику (например, сделать 10/11/12), но если это сделано в одном месте — проект остаётся управляемым.

4. Практический пример: CLI Tasky и тонкий main

Соберём всё в цельную картину. Пусть у нас есть учебное приложение Tasky (условный трекер задач), где команды вводятся строкой. Мы не будем полностью реализовывать все команды (это была бы уже другая лекция), нам важна архитектурная линия: функции возвращают expected, а main решает, что печатать и каким кодом завершиться.

Сигнатура «шага» обработки команды может выглядеть так:

#include <expected>
#include <string_view>

[[nodiscard]] std::expected<void, Error> process_command(std::string_view line) {
    if (line.empty()) {
        return std::unexpected<Error>(
            Error{ErrorCode::InvalidInput, "empty command", std::nullopt}
        );
    }
    return {}; // успех: void
}

Обратите внимание на две вещи. Во‑первых, [[nodiscard]] дисциплинирует: нельзя «случайно забыть» проверить результат. Во‑вторых, на успехе мы возвращаем «пустое значение» (для expected<void, E> это нормально): успех есть, данных нет.

Теперь «тонкий main», который делает политику:

#include <iostream>
#include <string>

std::expected<void, Error> process_command(std::string_view line);
std::string format_error(const Error& e);
int exit_code(ErrorCode c);

int main() {
    std::string line;
    std::getline(std::cin, line);

    auto r = process_command(line);
    if (!r) {
        std::cerr << format_error(r.error()) << '\n';
        return exit_code(r.error().code);
    }

    std::cout << "ok\n"; // ok
    return 0;
}

Выглядит почти скучно — и это комплимент. «Скучный main» означает, что у вас предсказуемая политика. Ошибка всегда печатается единообразно, всегда в cerr, и процесс всегда возвращает код, который можно интерпретировать.

Небольшая блок‑схема

flowchart TD
    A["process_command(...)"] -->|expected: success| B[main: печатаем результат в cout]
    A -->|expected: error| C[main: format_error + cerr]
    C --> D["main: return exit_code(ErrorCode)"]
    B --> E[return 0]

Да, блок‑схемы редко делают код быстрее. Зато они делают мозг спокойнее.

Где заканчивается политика ошибок и начинается хаос

Есть тонкий момент: иногда вы хотите не только печатать ошибки при завершении, но и оставлять следы о ходе работы программы (например: «прочитали команду», «удалили задачу», «не нашли id»). Вот здесь новичок часто либо вообще не пишет никаких сообщений, либо превращает программу в новогоднюю гирлянду из cout.

В учебном проекте мы договоримся так: любая диагностика (и ошибки, и предупреждения, и «инфо») идёт через log(...) в cerr, а пользовательский «нормальный» результат идёт через cout. Если позже вы захотите «настоящие логи», вы сможете заменить тело log(...) на запись в файл, и остальной код не придётся переписывать.

Ещё один важный предел: не пытайтесь лечить всё печатью. Если ошибка влияет на корректность работы — её нужно возвращать как Error и останавливать сценарий, а не писать «ой» и идти дальше с поломанным состоянием.

5. Типичные ошибки при построении политики ошибок

Ошибка №1: печатать ошибку и возвращать ошибку одновременно.
Очень частая ситуация: функция пишет std::cerr << "bad id\n" и при этом возвращает std::unexpected<Error>{...}. В итоге на границе программы ошибка печатается ещё раз, и пользователь видит два сообщения. Чуть позже вы добавите ещё один слой — и сообщений станет три. Лекарство простое: рабочие функции не печатают, они возвращают; печать — на границе.

Ошибка №2: писать ошибки в std::cout.
Пока вы запускаете программу вручную, разницы почти не видно. Но как только вывод перенаправляют в файл, ваш «результат» смешивается с ошибками и становится непригодным. Это особенно болезненно в автоматической проверке и в связке программ. Договоритесь: результат — cout, диагностика — cerr.

Ошибка №3: «магические числа» кодов выхода по всему проекту.
Если в одном месте return 2, в другом return 5, в третьем return 17, то очень скоро никто не помнит, что они означают. Хуже того, вы сами начнёте путаться и «чинить» не то. Нормальная практика — одна функция exit_code(ErrorCode), и все коды выходов живут только там.

Ошибка №4: разный формат сообщений об ошибках в разных местах.
Сегодня вы вывели parse_error: ..., завтра вы решили добавить префикс [ERROR], послезавтра — позицию в строке, а потом выяснилось, что половина проекта печатает по‑старому. Централизованный format_error решает проблему: поменяли формат в одном месте — и весь проект «переоделся».

Ошибка №5: игнорировать результат expected, потому что «ну я уверен, что там успех».
Это ловушка уверенности. Вчера вы были уверены, сегодня добавили новую ветку, завтра пользователь ввёл пустую строку, и программа упала или продолжила с мусорными данными. Если функция возвращает expected, её результат нужно обработать всегда. В этом смысле атрибут [[nodiscard]] — ваш маленький строгий друг, который не даёт вам сделать вид, что ошибки не существует.

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