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 |
|---|---|---|
|
Пользователь ввёл ерунду | |
|
Не смогли распарсить команду | |
|
Не нашли сущность по id | |
|
Всё остальное | |
И реализация:
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]] — ваш маленький строгий друг, который не даёт вам сделать вид, что ошибки не существует.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ