1. Навіщо потрібні власні винятки і коли runtime_error уже замало
Коли ви лише починаєте, дуже хочеться діяти так: «Ой, помилка — кидаю std::runtime_error("щось пішло не так")». І це… не жахливо. Це навіть краще, ніж «повернути -1 і зробити вигляд, що все гаразд». Але щойно ваш застосунок трохи ускладнюється, зʼясовується: одного тексту вже замало. Хочеться відрізняти помилку розбору команди від помилки бізнес-логіки, а не знайдено — від неправильного формату.
Власні винятки потрібні не для краси й не тому, що «ООП — наше все». Вони потрібні, щоб тип помилки став частиною контракту: код на вищому рівні стеку може зрозуміти категорію помилки за типом, а не намагатися розбирати рядок повідомлення (це було б… іронічно: розбирати помилки парсера).
Уявіть, що ви пишете невеликий консольний застосунок «облік витрат» (ми розвиватимемо один спільний приклад). Користувач вводить команду:
add 250 coffee
А потім раптом вводить:
add -999999999999999999999 coffee
Або:
add 250
Або:
remove 10
В одному випадку проблема в числі, в іншому — у нестачі аргументів, у третьому — у неправильному id. Це різні помилки, і часом їх хочеться обробляти по-різному: десь підказати формат, десь сказати «немає такого запису», а десь — «надто велике число».
2. Іменування: як обирати категорії
Коли ви вигадуєте імʼя винятку, у вас дві цілі: зробити його зрозумілим людині й корисним для коду. Назвати виняток MyException — це як назвати змінну x2: формально допустимо, але практична цінність приблизно така сама, як у парасолі з марлі.
Зазвичай імена винятків будують навколо категорії помилки. Категорія — це відповідь на запитання: «Що саме пішло не так за змістом?».
Вдалі, «робочі» категорії для навчального CLI-застосунку зазвичай такі: помилки розбору команди, помилки валідації введення, помилки доменної логіки. Ви можете використовувати суфікс Error або Exception. У прикладному коді на C++ частіше трапляється Error, а в стандартній бібліотеці — ..._error/..._argument. Головне — бути послідовними.
Ось невелика табличка — не догма, а радше «мапа місцевості»:
| Імʼя | Коли доречно | Що читач розуміє без контексту |
|---|---|---|
|
рядок або команду не можна розібрати | «формат некоректний» |
|
команда синтаксично коректна, але логічно неможлива | «команда некоректна в поточному стані» |
|
дані порушують правила (порожньо/надто довго/не те) | «введення некоректне» |
|
елемент не знайдено | «шукали й не знайшли» |
Дуже поширена пастка — назвати виняток за місцем, а не за змістом. Наприклад, LedgerError («помилка бухгалтерської книги»). Це може бути нормально як базовий тип, але для конкретики краще TransactionNotFound, InvalidAmount, ParseError. Місце, тобто модуль, часто змінюється, а зміст помилки зазвичай лишається стабільним.
3. Від кого успадковувати: std::runtime_error vs std::exception
На цьому етапі хочеться дати максимально практичну відповідь, без філософії на три томи. У C++ можна кидати що завгодно, але якщо ви хочете, щоб код добре взаємодіяв із рештою програми та стандартними обробниками, логічно мати сумісність зі std::exception, щоб працював what() і загальний catch (const std::exception&).
Є два поширені шляхи: успадкувати безпосередньо від std::exception і реалізувати what(), або успадкувати від std::runtime_error (чи іншого класу з <stdexcept>) і просто передати повідомлення в базовий клас.
У навчальних проєктах і більшості прикладних випадків зручніше успадковувати від std::runtime_error. Це дає готове зберігання повідомлення і коректний what() «з коробки», а ви додаєте лише те, що справді потрібно: поля контексту, додаткові методи доступу.
Успадкування від std::exception теж є нормальним варіантом, але тоді вам доведеться самим зберігати рядок і гарантувати, що what() повертає вказівник на памʼять, яка існує достатньо довго. Інакше вийде знаменита помилка: «what() повернув вказівник на рядок, якого вже немає». Це, до речі, майже як «повернути посилання на локальну змінну», тільки у світі винятків — тобто біль настане раптово й у найнедоречніший момент.
4. Повідомлення винятку: що писати в what()
Дуже хочеться написати повідомлення на кшталт "Error" або "Bad input". Такі повідомлення виглядають як «я знаю, що погано, але вам не скажу». А потім ви сидите вночі й думаєте: «чому ж воно погано?» — і C++ мовчить, бо ви самі його попросили мовчати.
Повідомлення винятку має відповідати принаймні на три запитання: що робили, чому не вдалося, який ключовий параметр або фрагмент винен. В ідеалі воно коротке, але конкретне. Уявіть, що це повідомлення побачить людина в логах, яка не має доступу до ваших думок (так, навіть ви за два тижні — уже «стороння людина»).
Можна тримати в голові простий шаблон:
<категорія>: <операція> failed: <причина> (контекст)
Наприклад:
ParseError: command failed: missing amountParseError: command failed: amount is not a number: '12x'CommandError: remove failed: transaction id not found: 10
Зверніть увагу: ми не пишемо «користувач дурень». Ми пишемо факти. Компілятор теж не пише «ти дивний», він пише expected ';' — і цим уже достатньо образив.
5. Контекст: коли потрібні поля, а не лише текст
Іноді повідомлення достатньо. Але часом ви хочете не лише надрукувати помилку, а й, наприклад, підсвітити позицію в рядку (якби ми мали UI), або окремо показати імʼя поля, або передати на верхній рівень id запису, який не знайдено. Якщо ви все ховаєте в рядок, то потім не зможете «дістати» це назад без розбору рядка (а ми домовилися так не робити).
І тут зʼявляється проста ідея: виняток — це звичайний обʼєкт. У нього можуть бути поля. Ви можете зберігати там, наприклад, field, token, position, id. Повідомлення лишається для людини, а поля — для коду.
Намалюємо маленьку схему того, як зазвичай розвивається помилка в застосунку:
flowchart TD
A[Користувач увів рядок команди] --> B[Парсер розбирає токени]
B -->|помилка формату| E[throw ParseError]
B --> C[Створили команду або дані]
C --> D[Бізнес-логіка виконує команду]
D -->|помилка змісту| F[throw CommandError]
E --> G[main ловить і друкує]
F --> G[main ловить і друкує]
Головна думка: парсер кидає одне, доменна логіка — інше, а верхній рівень (main) вирішує, що показати користувачеві.
6. Практичний приклад: міні-застосунок «Облік витрат»
Далі розвиватимемо один спільний приклад. Нехай у нас є прості команди:
- add <amount> <title>
- remove <id>
- list
Ми поки не працюємо з файлами, JSON та іншими радощами майбутніх днів. Усе зберігається в памʼяті, щоб не відволікатися від теми винятків.
Модель і сховище
Почнемо з моделі та сховища — мінімально:
#include <string>
struct Expense {
int id{};
int amount{};
std::string title{};
};
Тепер зробимо «книгу витрат»:
#include <vector>
#include <string>
class Ledger {
public:
void add(int amount, std::string title) {
expenses_.push_back(Expense{next_id_++, amount, std::move(title)});
}
const std::vector<Expense>& all() const { return expenses_; }
private:
int next_id_{1};
std::vector<Expense> expenses_{};
};
Поки що тут немає помилок. Але вони зʼявляться, щойно ми додамо remove(id) або почнемо валідувати amount. Ми повернемося до цього трохи пізніше, а поки зосередимося на розборі та помилках формату.
7. ParseError: повідомлення і поле контексту
Зробімо власний тип ParseError. Ми хочемо, щоб його можна було зловити як std::exception, щоб він мав зрозуміле повідомлення і щоб у ньому було поле input_ або field_ — наприклад, із тим, що саме ми намагалися прочитати.
Успадкуємо його від std::runtime_error:
#include <stdexcept>
#include <string>
#include <utility>
class ParseError : public std::runtime_error {
public:
ParseError(std::string input, std::string message)
: std::runtime_error("ParseError: " + message + " | input='" + input + "'")
, input_(std::move(input)) {}
const std::string& input() const { return input_; }
private:
std::string input_;
};
Тут одразу кілька важливих моментів. Ми формуємо зрозуміле людині повідомлення один раз у конструкторі, і воно буде доступне через what(). Водночас початковий рядок input_ зберігається окремо, щоб за потреби його можна було використовувати незалежно від тексту помилки.
Що вважати «контекстом»
Контекст — це не «все, що ви знаєте про світ». Це невеликий набір даних, який допомагає зрозуміти помилку або ухвалити рішення. Наприклад, для розбору команд часто корисно мати command_name, field, token, position. Для доменної логіки кориснішими будуть id, amount, limit, state.
Поганий контекст — це гігантський обʼєкт на 2 мегабайти або посилання на обʼєкт, який ось-ось зникне. Винятки мають бути відносно легкими. Памʼятайте: виняток — не транспортний контейнер для всієї бази даних, а «конверт» із причиною збою.
Якщо ви вагаєтеся, кладіть у виняток лише те, що легко вивести або показати користувачу: короткі рядки, числа, невеликі структури.
8. Парсинг і нормалізація помилок
Розбір першої команди
Зробімо функцію, яка читає першу «команду» з рядка. Ми поки не використовуємо std::stringstream (він буде на іншому занятті курсу), тому зробимо простий розбір через find.
#include <string>
std::string first_word(const std::string& s) {
auto pos = s.find(' ');
if (pos == std::string::npos) return s;
return s.substr(0, pos);
}
parse_amount_or_throw і перетворення винятків на ParseError
Тепер напишемо функцію parse_add_amount, яка очікує команду add <amount> <title>. Ми спеціально зробимо її трохи суворою: якщо формат не той, кидаємо ParseError.
#include <string>
#include <stdexcept>
int parse_amount_or_throw(const std::string& token, const std::string& input) {
if (token.empty()) {
throw ParseError{input, "missing amount"};
}
try {
return std::stoi(token);
} catch (const std::exception&) {
throw ParseError{input, "amount is not a number: '" + token + "'"};
}
}
Тут важливо, що ми ловимо «щось стандартне» і перетворюємо це на нашу помилку, щоб вище в коді бачити саме ParseError. Ми поки не обговорюємо тонкощі винятків, які саме типи кидає stoi — нам важлива сама ідея: «перехопили й нормалізували».
Зауважте, що всередині catch ми кидаємо новий виняток. Це нормальний прийом, коли ви хочете перетворити низькорівневу помилку на свою категорію, зрозумілу вашому застосунку.
Збагачення і повторне викидання
У попередніх лекціях ви вже бачили throw; для повторного викидання. Важливо памʼятати різницю: throw; повторно кидає той самий виняток, зберігаючи його обʼєкт. Така поведінка повʼязана з тим, що обʼєкт винятку доступний через обробник, і стандарт прямо це описує.
Але сьогодні нам важливіший практичний сценарій: ми ловимо «сиру» помилку і кидаємо «нормалізовану» — з нашим типом і повідомленням. Тоді вище за стеком нам простіше будувати UX: у нас є зрозуміла категорія й передбачуваний текст.
Часта стратегія виглядає так: нижній рівень кидає стандартні винятки або свої дрібні, середній — перетворює їх на більш «змістові», а верхній — друкує. Головне — не друкувати на кожному рівні, інакше ви отримаєте три однакові повідомлення й відчуття, що програму «заїло».
9. Доменна помилка та обробка в main
CommandError для логіки, а не для формату
Тепер зробімо другий виняток — уже не про парсинг, а про виконання дії. Наприклад, видалення за id.
#include <stdexcept>
#include <string>
class CommandError : public std::runtime_error {
public:
explicit CommandError(std::string message)
: std::runtime_error("CommandError: " + std::move(message)) {}
};
Ledger::remove кидає CommandError, якщо id не знайдено
І додамо remove у Ledger. Нехай він кидає CommandError, якщо id не знайдено.
#include <algorithm>
#include <vector>
class Ledger {
public:
void add(int amount, std::string title) {
expenses_.push_back(Expense{next_id_++, amount, std::move(title)});
}
void remove(int id) {
auto it = std::find_if(expenses_.begin(), expenses_.end(),
[id](const Expense& e){ return e.id == id; });
if (it == expenses_.end()) {
throw CommandError{"expense id not found: " + std::to_string(id)};
}
expenses_.erase(it);
}
const std::vector<Expense>& all() const { return expenses_; }
private:
int next_id_{1};
std::vector<Expense> expenses_{};
};
Зверніть увагу на важливу річ: тип помилки відображає зміст. ParseError каже «не можу зрозуміти команду», а CommandError каже «команду зрозумів, але виконати не можу».
Точка обробки: ловимо в main і друкуємо what()
Тепер зберімо невеликий цикл читання. Ми будемо читати рядки через std::getline, щоб команди могли містити пробіли в назві.
#include <iostream>
#include <string>
int main() {
Ledger ledger;
std::cout << "Expense CLI. Type 'exit'.\n";
for (std::string line; std::getline(std::cin, line);) {
if (line == "exit") break;
try {
// тут пізніше буде обробка команд
std::cout << "got: " << line << '\n';
} catch (const ParseError& e) {
std::cout << e.what() << '\n';
} catch (const CommandError& e) {
std::cout << e.what() << '\n';
} catch (const std::exception& e) {
std::cout << "Unexpected error: " << e.what() << '\n';
}
}
}
Чому ми ловимо ParseError і CommandError окремо? Тому що завтра, а може, й за годину, ви захочете показувати їх по-різному: для ParseError показувати підказку щодо формату, а для CommandError — просте людське «не знайдено».
І ще одне: ловимо за const&. Це стандартна практика, бо так ви не копіюєте обʼєкт винятку і не втрачаєте поліморфізму.
UX-підказки і what(): не перетворюємо повідомлення на роман
Дуже спокусливо додати в повідомлення все: і повне введення, і підказку, і приклад, і мотиваційну цитату. Але what() — це діагностичне повідомлення, тож воно має бути досить коротким. Якщо ви хочете показати користувачеві, «як правильно», краще робити це в коді обробника, а не «запікати» назавжди всередину what().
Наприклад, для ParseError можна друкувати what(), а потім окремим рядком — підказку:
catch (const ParseError& e) {
std::cout << e.what() << '\n';
std::cout << "Hint: add <amount> <title> | remove <id> | list\n";
}
Так ви розділяєте «діагностику» і «UX». Виняток повідомляє про факт помилки, а UI-шар вирішує, як допомогти користувачеві.
10. Типові помилки
Помилка № 1: кидати std::string або int, а потім ловити «щось».
Технічно C++ дозволяє кидати майже будь-який тип, але тоді ви позбавляєте себе спільної точки сумісності (std::exception) і нормальної діагностики через what(). У підсумку обробники розповзаються по коду, а в main зʼявляється зоопарк catch (std::string), catch (int) і «про всяк випадок catch (...)».
Помилка № 2: робити один тип винятку на все («AppError») без змістових категорій.
Якщо всі помилки мають один тип, то сам тип перестає бути корисним. Ви знову будете відрізняти причини за текстом, а текст — штука для людини, не для логіки. Зазвичай краще мати кілька невеликих категорій: хоча б ParseError і CommandError, і далі розширювати ієрархію лише тоді, коли це справді потрібно.
Помилка № 3: формувати what() із тимчасового рядка і повертати «висячий» вказівник.
Якщо ви успадковуєте від std::exception і пишете what() вручну, не можна повертати c_str() від тимчасового обʼєкта. Повідомлення має зберігатися в полі (наприклад, std::string msg_), щоб памʼять існувала стільки ж, скільки й обʼєкт винятку.
Помилка № 4: перевантажувати виняток зайвим контекстом і перетворювати його на «вантажівку».
Виняток не має тягнути за собою половину стану програми. Кладіть туди невеликі, дешеві й корисні дані: id, імʼя поля, позицію, короткий токен. Усе інше краще логувати або обробляти окремо.
Помилка № 5: друкувати помилку на кожному рівні й отримувати «ехо» з трьох однакових повідомлень.
Низькорівнева функція кидає, середня ловить і друкує, верхня ловить і друкує знову — і користувач бачить ту саму проблему кілька разів. Набагато чистіше: нижні рівні або передають виняток далі, або перетворюють його тип, а друк робиться в одній вибраній точці (зазвичай ближче до main).
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ