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

Власні винятки — іменування, повідомлення та контекст

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

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. Головне — бути послідовними.

Ось невелика табличка — не догма, а радше «мапа місцевості»:

Імʼя Коли доречно Що читач розуміє без контексту
ParseError
рядок або команду не можна розібрати «формат некоректний»
CommandError
команда синтаксично коректна, але логічно неможлива «команда некоректна в поточному стані»
ValidationError
дані порушують правила (порожньо/надто довго/не те) «введення некоректне»
NotFoundError
елемент не знайдено «шукали й не знайшли»

Дуже поширена пастка — назвати виняток за місцем, а не за змістом. Наприклад, 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 amount
  • ParseError: 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).

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