1. Навіщо потрібен дизайн помилок
Коли ви лише починаєте писати програми, дуже хочеться жити за принципом: «якщо щось пішло не так — друкуємо "ERROR" і спокійно йдемо собі в захід сонця». На маленьких задачах це працює… приблизно до першої ситуації, коли ви намагаєтеся зрозуміти, що саме пішло не так. Програма починає нагадувати детектив, у якому всі свідки відповідають однією фразою: «Ну… було щось дивне».
Проблема в тому, що «помилка» — не просто текст. Це подія: у неї є причина, категорія, корисні деталі, і часто її треба обробляти по-різному. Наприклад, «не вдалося прочитати рядок із cin» і «користувач увів id задачі, якої не існує» — це обидві «помилки», але ситуації зовсім різні.
Якщо ви зберігаєте помилку лише як рядок, то майже одразу доводиться робити дивні речі: порівнювати рядки, шукати в них підрядки, писати if (msg.find("not found") != npos) і повільно перетворюватися на людину, яка колись хотіла стати програмістом, а стала археологом власного коду.
Нам потрібен дизайн, у якому помилка — це дані.
2. Помилка як дані: код, повідомлення, контекст
Хороша помилка в навчальному й реальному прикладному коді зазвичай складається з трьох рівнів:
- Код помилки — компактна категорія, за якою програма може логічно розгалужуватися. Саме її машина має стабільно розуміти.
- Повідомлення — зрозумілий людині текст. Саме його побачить користувач (або ви в консолі).
- Контекст — додаткові деталі: введений рядок, імʼя поля, позиція символу, значення 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 не зміг прочитати рядок не тому, що рядок порожній, а тому, що потік завершився або зламався.
Щоб це не залишалося абстракцією, зведімо відмінності в таблицю:
| Категорія | Що це означає по-людськи | Типовий приклад |
|---|---|---|
|
«не зміг зрозуміти формат» | "12x" замість числа |
|
«зрозумів, але так не можна за правилами» | -5 як вік |
|
«зрозумів посилання, але об’єкта немає» | id задачі не існує |
|
«технічна проблема потоку/введення» | 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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ