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 Що означає Код виходу
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, але якщо це зроблено в одному місці, проєкт залишається керованим.

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]] — ваш маленький суворий друг, який не дає вам удавати, ніби помилки не існує.

1
Опитування
variant/expected + visit, рівень 23, лекція 4
Недоступний
variant/expected + visit
variant/expected + visit
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ