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 | Що означає | Код виходу |
|---|---|---|
|
Користувач увів нісенітницю | |
|
Не змогли розібрати команду | |
|
Не знайшли сутність за 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, але якщо це зроблено в одному місці, проєкт залишається керованим.
3. Практичний приклад: 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: успіх| B[main: виводимо результат у cout]
A -->|expected: помилка| C[main: format_error + cerr]
C --> D["main: повертаємо exit_code(ErrorCode)"]
B --> E[повертаємо 0]
Так, блок-схеми рідко роблять код швидшим. Зате з ними легше впорядкувати думки.
Де закінчується політика помилок і починається хаос
Є тонкий момент: іноді ви хочете не лише друкувати помилки під час завершення, а й залишати сліди про перебіг роботи програми, наприклад: «прочитали команду», «видалили завдання», «не знайшли ID». Ось тут новачок часто або взагалі не пише жодних повідомлень, або перетворює програму на новорічну гірлянду з cout.
У навчальному проєкті ми домовимося так: будь-яка діагностика — і помилки, і попередження, і «інфо» — іде через log(...) у cerr, а користувацький «нормальний» результат іде через cout. Якщо пізніше ви захочете мати «справжні логи», то зможете замінити тіло log(...) на запис у файл, і решту коду не доведеться переписувати.
Ще одна важлива межа: не намагайтеся лікувати все друком. Якщо помилка впливає на коректність роботи, її потрібно повертати як Error і зупиняти сценарій, а не писати «ой» і рухатися далі зі зламаним станом.
4. Типові помилки під час побудови політики помилок
Помилка № 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]] — ваш маленький суворий друг, який не дає вам удавати, ніби помилки не існує.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ