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; пробрасывает то же самое исключение, сохраняя его объект. Это поведение связано с тем, что объект исключения доступен через обработчик, и стандарт явно обсуждает наблюдение объекта исключения через объявление handler-а.

Но сегодня нам важнее практический сценарий: мы ловим “сырую” ошибку и кидаем “нормализованную” — с нашим типом и сообщением. Тогда выше по стеку нам проще строить 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).

1
Задача
C++ SELF, 53 уровень, 3 лекция
Недоступна
Возрастной контроль
Возрастной контроль
1
Задача
C++ SELF, 53 уровень, 3 лекция
Недоступна
Токен парсинга
Токен парсинга
1
Задача
C++ SELF, 53 уровень, 3 лекция
Недоступна
Две ошибки
Две ошибки
1
Задача
C++ SELF, 53 уровень, 3 лекция
Недоступна
Конфиг сервиса
Конфиг сервиса
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ