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».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ