JavaRush /Курси /C++ SELF /Дизайн помилок — код, повідомлення, контекст

Дизайн помилок — код, повідомлення, контекст

C++ SELF
Рівень 23 , Лекція 3
Відкрита

1. Навіщо потрібен дизайн помилок

Коли ви лише починаєте писати програми, дуже хочеться жити за принципом: «якщо щось пішло не так — друкуємо "ERROR" і спокійно йдемо собі в захід сонця». На маленьких задачах це працює… приблизно до першої ситуації, коли ви намагаєтеся зрозуміти, що саме пішло не так. Програма починає нагадувати детектив, у якому всі свідки відповідають однією фразою: «Ну… було щось дивне».

Проблема в тому, що «помилка» — не просто текст. Це подія: у неї є причина, категорія, корисні деталі, і часто її треба обробляти по-різному. Наприклад, «не вдалося прочитати рядок із cin» і «користувач увів id задачі, якої не існує» — це обидві «помилки», але ситуації зовсім різні.

Якщо ви зберігаєте помилку лише як рядок, то майже одразу доводиться робити дивні речі: порівнювати рядки, шукати в них підрядки, писати if (msg.find("not found") != npos) і повільно перетворюватися на людину, яка колись хотіла стати програмістом, а стала археологом власного коду.

Нам потрібен дизайн, у якому помилка — це дані.

2. Помилка як дані: код, повідомлення, контекст

Хороша помилка в навчальному й реальному прикладному коді зазвичай складається з трьох рівнів:

  1. Код помилки — компактна категорія, за якою програма може логічно розгалужуватися. Саме її машина має стабільно розуміти.
  2. Повідомлення — зрозумілий людині текст. Саме його побачить користувач (або ви в консолі).
  3. Контекст — додаткові деталі: введений рядок, імʼя поля, позиція символу, значення id, шлях до файла тощо. Контекст допомагає не вгадувати, а відразу розуміти, де саме все зламалося.

Цю ідею зручно уявити як листковий пиріг:

flowchart TB
  A[ErrorCode: машинна категорія] --> B[message: текст для людини]
  B --> C[context: деталі, що допомагають зрозуміти місце й причину]

А тепер спроєктуймо це в C++.

3. Категорії помилок: parse, invalid_input, not_found, io

Перед тим як писати код, важливо домовитися про значення, адже половина «помилок дизайну помилок» виникає тоді, коли категорії переплутані. Нижче — чотири дуже практичні категорії, які майже завжди трапляються в консольних програмах і CLI-утилітах.

parse

Програма часто читає рядки й намагається перетворити їх на щось осмислене: число, команду, дату, структуру. І саме тут може настати момент: «я взагалі не розумію, що ви написали». Це і є parse.

Приклад: ви очікуєте число, а натомість отримуєте "12x" або порожній рядок там, де має бути токен.

invalid_input

Часто введення формально вдалося розібрати, але за змістом це нісенітниця. Це важлива відмінність: синтаксис нормальний, а зміст — ні.

Приклад: число -5 успішно розібралося, але вік не може бути відʼємним; або команда "add" прийшла без тексту задачі.

not_found

Це ситуація: «ви посилаєтеся на сутність, якої немає».

Приклад: користувач вводить "done 10", а задачі з id 10 у нас немає.

io

Це проблеми з введенням/виведенням як процесом: потік зламаний, cin у fail(), файл закінчився (у майбутньому), неочікуваний eof тощо.

Приклад: std::getline не зміг прочитати рядок не тому, що рядок порожній, а тому, що потік завершився або зламався.

Щоб це не залишалося абстракцією, зведімо відмінності в таблицю:

Категорія Що це означає по-людськи Типовий приклад
ParseError
«не зміг зрозуміти формат» "12x" замість числа
InvalidInput
«зрозумів, але так не можна за правилами» -5 як вік
NotFound
«зрозумів посилання, але об’єкта немає» id задачі не існує
IoError
«технічна проблема потоку/введення» getline не прочитав

4. Проєктуємо ErrorCode та Error

Зараз ми зробимо мінімальний, але зручний тип помилки: код, повідомлення й опціональні поля контексту. Так, контекст можна зробити одним полем std::string, але практика показує: позицію помилки в рядку й «фрагмент введення» настільки часто доводиться використовувати, що їх зручніше тримати окремими полями.

#include <optional>
#include <string>

enum class ErrorCode {
    ParseError,
    InvalidInput,
    NotFound,
    IoError
};

struct Error {
    ErrorCode code{};
    std::string message;

    std::optional<std::string> context;     // наприклад, "done xyz"
    std::optional<std::size_t> position;    // наприклад, індекс проблемного символу
};

Зверніть увагу на «психологічний ефект» структури. Коли помилка — це struct Error, у вас з’являється дисципліна: ви не можете випадково повернути просто рядок. Ви або повертаєте успіх, або свідомо створюєте об’єкт помилки. Код стає нуднішим — і це комплімент.

5. Форматування та виведення помилок

Стабільні ярлики: to_string(ErrorCode)

Повідомлення про помилку може бути будь-яким і з часом змінюватися. Це нормально: ви захочете покращити формулювання. А от код помилки має залишатися стабільним, щоб не ламалася обробка.

Тому корисно мати функцію to_string(ErrorCode).

#include <string>

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

Чому return "unknown" наприкінці взагалі потрібен? Тому що компілятор не зобов’язаний вважати switch вичерпним. До того ж ви можете додати новий код і забути оновити switch. Це маленький страховий парашут.

Єдиний формат: format_error(const Error&)

У цій лекції ми не обговорюємо «політику помилок» — де друкувати й які коди завершення повертати. Це окрема тема. Але вже зараз корисно мати єдиний формат.

Головна ідея проста: форматування помилки має жити в одному місці, інакше ви почнете друкувати її десятьма різними способами, а в логах з’явиться «зоопарк стилів».

#include <string>
#include <utility>  // std::move (якщо захочете)

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

    if (e.context) {
        out += " | ctx=\"" + *e.context + "\"";
    }
    if (e.position) {
        out += " | pos=" + std::to_string(*e.position);
    }

    return out;
}

Формат на кшталт code: message | ctx="..." | pos=... хороший тим, що він одночасно читабельний і доволі стабільний. Згодом ви зможете розбирати його очима або навіть скриптом — і не страждати.

Контекст: що туди класти, а чого — не варто

Дуже легко впасти в крайність і почати запихати в помилку весь світ: «а давайте додамо назву функції, стек, фазу місяця й температуру кулера». На цьому етапі курсу ми тримаємо дизайн простим і практичним.

Контекст потрібен, щоб відповісти на два запитання: «з чим працювали?» і «де саме зламалося?».

context зручно використовувати як «сире» введення або його частину. Наприклад, повну команду: "done xyz". Тоді, побачивши помилку, ви відразу зрозумієте, що саме ввів користувач.

Поле position зручне, коли ви парсите рядок і хочете показати точку, у якій виявили проблему. Навіть якщо ви не малюєте стрілочку під рядком, номер позиції вже багато що пояснює.

Приклад повідомлення в консолі може виглядати так:

parse_error: очікувалося ціле число | ctx="done xyz" | pos=5

6. Міні-застосунок: CLI-планувальник задач

Щоб не залишатися на рівні абстракцій, продовжимо в нашому стилі: уявімо, що ми пишемо маленький CLI-планувальник задач. Він зберігає задачі в std::vector, а користувач уводить команди:

  • add <text> — додати задачу
  • done <id> — позначити виконаною
  • list — показати список

Сьогодні ми не робимо «ідеальний парсер». Достатньо, щоб з’явилися реальні місця, де виникають помилки, і ми могли коректно їх описувати.

Модель задачі

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{false};
};

7. Приклади помилок і обробка через std::expected

parse: парсимо ціле число через from_chars

Зараз буде типовий сценарій: потрібно розібрати id із рядка. Раніше багато хто використовував stoi, але він може кидати винятки, а ми поки не живемо у світі, де винятки є основним механізмом. Тому беремо std::from_chars.

std::expected у C++23 якраз і створений для таких контрактів: «значення або помилка».

#include <charconv>
#include <expected>
#include <string>
#include <string_view>
#include <system_error>

[[nodiscard]] std::expected<int, Error> parse_int(std::string_view s) {
    int value{};
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);

    if (ec != std::errc{}) {
        return std::unexpected(Error{ErrorCode::ParseError, "очікувалося ціле число",
                                     std::string{s}, std::nullopt});
    }
    if (ptr != s.data() + s.size()) {
        std::size_t pos = static_cast<std::size_t>(ptr - s.data());
        return std::unexpected(Error{ErrorCode::ParseError, "зайві символи після числа",
                                     std::string{s}, pos});
    }
    return value;
}

Зверніть увагу на важливий момент: ми розрізняємо дві ситуації. В одній число взагалі не вдалося розпарсити. В іншій — число розпарсилося, але після нього залишилося сміття. Це і є хороший контекстний дизайн: користувач і ви отримуєте точнішу діагностику.

invalid_input: число коректне, але заборонене за правилами

Тепер додамо змістову перевірку: наприклад, id задачі має бути додатним. Це вже не parse, бо число успішно розпізнано. Це invalid_input.

#include <expected>
#include <string_view>

[[nodiscard]] std::expected<int, Error> parse_positive_id(std::string_view s) {
    auto id = parse_int(s);
    if (!id) {
        return std::unexpected(id.error());
    }
    if (*id <= 0) {
        return std::unexpected(Error{ErrorCode::InvalidInput, "id має бути > 0",
                                     std::string{s}, std::nullopt});
    }
    return *id;
}

Сенс цього розділення дуже практичний. Якщо згодом у вас з’явиться логіка: на InvalidInput показати користувачеві підказку, а на ParseError — приклад формату, ви зможете робити це за ErrorCode, не аналізуючи рядки повідомлень.

not_found: шукаємо задачу за id

Далі — життєвий біль усіх новачків: «я знайшов/не знайшов, але як повідомити про це красиво?». Багато хто повертає -1, але це одразу створює проблему: -1 може бути валідним значенням в іншій задачі, а ще ви втрачаєте причину.

Зробімо функцію, яка повертає індекс задачі у векторі або помилку NotFound.

#include <expected>
#include <string>
#include <vector>

[[nodiscard]] std::expected<std::size_t, Error>
find_task_index_by_id(const std::vector<Task>& tasks, int id) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        if (tasks[i].id == id) {
            return i;
        }
    }
    return std::unexpected(Error{ErrorCode::NotFound, "задачу з таким id не знайдено",
                                 "id=" + std::to_string(id), std::nullopt});
}

Чому ми повертаємо індекс, а не Task&? Тому що expected<Task&,...> — це окрема тема з нюансами тривалості життя та посилальних типів, а нам зараз важливіші базова механіка й зрозумілий контракт.

io: читаємо рядок і відрізняємо «порожньо» від «потік зламався»

В інтерактивних програмах часто пишуть:

std::getline(std::cin, line);

і не перевіряють, що сталося. А потім, коли Ctrl+D (EOF) або потік переходить у стан помилки, програма поводиться дивно.

Зробімо функцію: «прочитати рядок або повернути помилку».

#include <expected>
#include <iostream>
#include <string>

[[nodiscard]] std::expected<std::string, Error> read_line() {
    std::string line;
    if (!std::getline(std::cin, line)) {
        return std::unexpected(Error{ErrorCode::IoError, "не вдалося прочитати рядок",
                                     std::nullopt, std::nullopt});
    }
    return line;
}

Тут IoError — це не «користувач щось не так увів», а «ми фізично не отримали дані». Це інша за змістом ситуація, і її справді варто відрізняти.

Фабрики помилок: менше шуму під час створення Error

На цьому етапі вам може здатися, що Error{...} занадто багатослівний. Це нормальна реакція: мозок програміста любить короткі імена й не любить друкувати зайве.

Замість того щоб усюди повторювати одне й те саме, часто роблять маленькі функції-конструктори (їх ще називають factory). Ми обійдемося без архітектурних ускладнень: просто пара функцій, щоб код читався легше.

#include <optional>
#include <string>

Error make_not_found(std::string what, std::optional<std::string> ctx = std::nullopt) {
    return Error{ErrorCode::NotFound, std::move(what), std::move(ctx), std::nullopt};
}

Error make_parse_error(std::string what, std::string ctx, std::size_t pos) {
    return Error{ErrorCode::ParseError, std::move(what), std::move(ctx), pos};
}

Тепер в основному коді ви зможете писати по суті, а не відтворювати щоразу один і той самий синтаксис.

Як виглядає обробка в потоці програми

Давайте зберемо невеликий фрагмент, у якому видно: функція повертає expected, а в разі помилки ми друкуємо форматоване повідомлення й продовжуємо цикл. Ми не обговорюємо тут, як завершувати програму, — це окрема лекція про політику помилок. Тут мета — побачити, що дизайн помилки дозволяє не вгадувати, а обробляти все передбачувано.

#include <iostream>
#include <vector>

void demo_handle_done(std::vector<Task>& tasks, std::string_view idText) {
    auto id = parse_positive_id(idText);
    if (!id) {
        std::cerr << format_error(id.error()) << '\n';
        return;
    }

    auto idx = find_task_index_by_id(tasks, *id);
    if (!idx) {
        std::cerr << format_error(idx.error()) << '\n';
        return;
    }

    tasks[*idx].done = true;
    std::cout << "ok\n"; // ok
}

З погляду читача коду все максимально лінійно: «перевірив → застосував». І найголовніше: у разі помилки ми не втрачаємо інформації — вона вже упакована.

8. Типові помилки під час проєктування помилок

Помилка № 1: помилка — це лише рядок, а розгалуження роблять через find("...").
Спочатку це здається зручним, але дуже швидко перетворюється на крихкий код. Повідомлення — для людини, воно має право змінюватися. Для логіки потрібна стабільна ознака: ErrorCode. Якщо завтра ви зміните текст із «не знайдено» на «не існує», ваш find() раптом зламає обробку помилок — і компілятор нічого не скаже.

Помилка № 2: плутанина між ParseError і InvalidInput.
Якщо число не розпарсилося — це ParseError. Якщо розпарсилося, але порушує правила, — це InvalidInput. Коли ці значення змішуються, користувачу складно зрозуміти, що саме виправляти: формат чи значення. А вам — підтримувати єдиний стиль повідомлень і підказок.

Помилка № 3: контекст не зберігається, хоча він є.
Найприкріша ситуація — коли код точно знає, що користувач увів (наприклад, "done xyz"), але помилка повертається без цього фрагмента. У підсумку ви бачите «очікувалося число», але не бачите, що саме було введено. Контекст, навіть у вигляді рядка введення, часто економить хвилини, а інколи й години налагодження.

Помилка № 4: контекст перетворюють на сміттєвий контейнер «усе підряд».
Зворотна крайність — запхати в context величезні полотна тексту, дублювати повідомлення, додавати випадкові поля «про всяк випадок». Контекст має допомагати зрозуміти проблему, а не створювати нову. Зазвичай достатньо вхідного рядка й позиції або одного-двох невеликих значень на кшталт "id=10".

Помилка № 5: різні частини програми форматують помилки по-різному.
Якщо в одному місці ви друкуєте code: message, в іншому — [ERROR] message, а в третьому — message (code=...), логи стають нечитабельними. Навіть у навчальних проєктах корисно мати один format_error, інакше консоль перетворюється на виставку самовираження через stderr.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ