JavaRush /Курсы /C++ SELF /Дизайн ошибок — код, сообщение, контекст

Дизайн ошибок — код, сообщение, контекст

C++ SELF
23 уровень , 3 лекция
Открыта

1. Зачем нужен дизайн ошибок

Когда начинаешь писать первые программы, очень хочется жить по принципу «если что-то пошло не так — печатаем "ERROR" и уходим в закат». На маленьких задачах это работает… примерно до первой ситуации, когда вы пытаетесь понять, что именно пошло не так. Программа начинает напоминать детектив, где все свидетели отвечают одной фразой: «Ну… было что-то странное».

Проблема в том, что «ошибка» — это не просто текст. Ошибка — это событие, у которого есть причина, категория, полезные детали, и часто она должна быть обработана по-разному. Например, «не удалось прочитать строку из cin» и «пользователь ввёл id задачи, которой не существует» — обе «ошибки», но это совсем разные ситуации.

Если вы храните ошибку только строкой, вы почти сразу начинаете делать плохие вещи: сравнивать строки, искать подстроки, писать if (msg.find("not found") != npos) и медленно превращаться в человека, который когда-то хотел стать программистом, а стал археологом по собственному коду.

Нам нужен дизайн, где ошибка — это данные.

2. Ошибка как данные: код, сообщение, контекст

Хорошая ошибка в учебном (и реальном) прикладном коде обычно состоит из трёх уровней:

  1. Код ошибки — компактная «категория», по которой программа может ветвиться логически. Это то, что машина должна понимать стабильно.
  2. Сообщение — человекочитаемый текст. Это то, что увидит пользователь (или вы в консоли).
  3. Контекст — дополнительные детали: исходная строка ввода, имя поля, позиция символа, значение 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 не смог прочитать строку (не потому что строка пустая, а потому что поток завершился/сломался).

Чтобы это не было абстракцией, сведём различия в таблицу:

Категория Что означает по-человечески Типичный пример
ParseError
«не смог понять формат» "12x" вместо числа
InvalidInput
«понял, но так нельзя по правилам» -5 как возраст
NotFound
«понял ссылку, но объекта нет» id задачи не существует
IoError
«техническая проблема потока/ввода» 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».

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