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; пробрасывает то же самое исключение, сохраняя его объект. Это поведение связано с тем, что объект исключения доступен через обработчик, и стандарт явно обсуждает наблюдение объекта исключения через объявление 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).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ