Перейти к содержимому

Стратегии обработки ошибок: выбор правильного подхода

Сравните исключения, коды ошибок и std::expected — узнайте, когда какой подход уместен и как обрабатывать ошибки в конструкторах, деструкторах и многопоточном коде.

Нет универсального решения

C++ предлагает несколько механизмов обработки ошибок, каждый со своими компромиссами. Выбор правильного зависит от вашей области:

Исключения лучше всего подходят для ошибок, которые редки и не могут быть обработаны локально. Они имеют нулевую стоимость на пути успеха, но дороги при выбрасывании (тысячи наносекунд). Они автоматически распространяются через кадры вызова.

Коды ошибок (возвращаемые значения, errno, std::error_code) лучше всего подходят для ошибок, которые ожидаемы и часты. Они имеют небольшую постоянную стоимость при каждом вызове (вызывающий должен проверить возвращаемое значение). Это стандарт для C API и кода уровня ядра.

std::expected (C++23) сочетает лучшее от обоих: заставляет вызывающего подтвердить ошибку (как коды ошибок), но несёт богатый тип ошибки (как исключения) и поддерживает монадическую цепочку. Быстро становится предпочтительным подходом в новом коде C++.

Ключевое понимание: это не конкурирующие философии — это инструменты для различных ситуаций, и промышленный код часто использует все три.

Сравнение: исключения vs коды ошибок vs std::expected

| Критерий | Исключения | Коды ошибок | std::expected |
|---|---|---|---|
| Стоимость при успехе | Нулевая (табличная) | Ветвление + проверка | Минимальная (хранит значение) |
| Стоимость при ошибке | Высокая (раскрутка) | Та же, что при успехе | Та же, что при успехе |
| Можно игнорировать? | Нет (программа завершается) | Да (тихие ошибки) | Сложнее (нужно обращаться к .value()) |
| Конструкторы | Единственный вариант | Не могут вернуть значение | Не могут вернуть значение |
| Деструкторы | Не должны выбрасывать | Допустимо | Допустимо |
| Компонуемость | Ограниченная (try/catch) | Ручные цепочки if | Монадическая (and_then, transform) |
| Размер бинарника | Больше (таблицы раскрутки) | Минимальный | Минимальный |
| Безопасно для реального времени? | Нет (неограниченное время) | Да | Да |

Обработка ошибок в конструкторах

Конструкторы не могут возвращать коды ошибок — у них нет возвращаемого значения. Исключения — единственный чистый механизм сигнализации об ошибке конструирования. Выброшенное исключение означает, что объект никогда не был полностью сконструирован, поэтому его деструктор не вызывается (но деструкторы уже сконструированных подобъектов и баз вызываются).

constructor_error.cpp
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>

class ConfigFile {
public:
    explicit ConfigFile(const std::string& path)
        : path_(path), stream_(path) {
        if (!stream_.is_open()) {
            throw std::runtime_error(
                "Cannot open config file: " + path);
        }
        // Parse the file — if this throws, stream_ is
        // already constructed and its destructor will
        // close the file automatically (RAII)
        parse();
    }

    std::string get(const std::string& key) const {
        auto it = data_.find(key);
        if (it == data_.end()) {
            throw std::out_of_range("Key not found: " + key);
        }
        return it->second;
    }

private:
    void parse() {
        std::string line;
        while (std::getline(stream_, line)) {
            auto pos = line.find('=');
            if (pos != std::string::npos) {
                data_[line.substr(0, pos)] = line.substr(pos + 1);
            }
        }
    }

    std::string path_;
    std::ifstream stream_;  // RAII: closed by destructor
    std::map<std::string, std::string> data_;
};

// Usage: the object is fully valid or does not exist
int main() {
    try {
        ConfigFile cfg("/etc/myapp.conf");
        std::cout << cfg.get("port") << '\n';
    } catch (const std::exception& e) {
        std::cerr << e.what() << '\n';
    }
}

std::error_code для системных ошибок

std::error_code предоставляет легковесный невыбрасывающий механизм для системных ошибок. Библиотека — отличный пример: большинство функций имеют перегрузку, принимающую параметр std::error_code& вместо выбрасывания.

error_code.cpp
#include <filesystem>
#include <iostream>
#include <system_error>

namespace fs = std::filesystem;

// Throwing version — suitable for unexpected errors
void copy_file_throwing(const fs::path& src, const fs::path& dst) {
    fs::copy_file(src, dst);  // throws fs::filesystem_error on failure
}

// Non-throwing version — suitable for expected failures
bool copy_file_safe(const fs::path& src, const fs::path& dst) {
    std::error_code ec;
    fs::copy_file(src, dst, ec);  // sets ec instead of throwing
    if (ec) {
        std::cerr << "Copy failed: " << ec.message()
                  << " (" << ec.category().name()
                  << ':' << ec.value() << ")\n";
        return false;
    }
    return true;
}

int main() {
    // The error_code version is preferable in loops where
    // failure is expected (e.g., batch file processing)
    for (const auto& entry : fs::directory_iterator(".")) {
        std::error_code ec;
        auto sz = fs::file_size(entry.path(), ec);
        if (!ec) {
            std::cout << entry.path().filename()
                      << ": " << sz << " bytes\n";
        }
    }
}
Подводный камень

Деструкторы в C++11 и позднее неявно являются noexcept. Если деструктор выбросит, вызывается std::terminate(). Даже если вы явно пометите деструктор noexcept(false), выбрасывание во время раскрутки стека (когда уже активно другое исключение) вызывает std::terminate().

  • Залогировать и подавить: если операция очистки завершилась ошибкой, запишите ошибку в лог и продолжайте
  • Предоставьте отдельный метод close(): позвольте вызывающему явно обработать ошибки очистки до уничтожения
  • Проектируйте ресурсы так, чтобы очистка не могла завершиться ошибкой: например, закрытие файлового дескриптора всегда успешно на уровне ОС
  • Никогда не вызывайте выбрасывающую функцию из деструктора без обёртки в try/catch

Обработка ошибок в многопоточном коде

Исключения не пересекают границы потоков. Если поток выбросит исключение, и оно не перехвачено внутри этого потока, вызывается std::terminate(). Для распространения исключений между потоками:

1. Используйте std::future и std::async: исключения, выброшенные в асинхронной задаче, перехватываются и повторно выбрасываются при вызове .get() на future.
2. Используйте std::exception_ptr с std::current_exception() и std::rethrow_exception() для ручного перехвата и передачи исключений.
3. Используйте std::promise::set_exception() для отправки исключения из потока-производителя потоку-потребителю.

Для высокопроизводительного параллельного кода часто предпочтительнее коды ошибок или std::expected, так как они позволяют избежать затрат на инфраструктуру исключений в каждом потоке и проще для рассуждения при пересечении границ потоков.

Лучшая практика

Подбирайте механизм обработки ошибок к ситуации.

  • Используйте исключения для ошибок, которые редки, не могут быть обработаны локально и должны распространяться вверх по стеку (например, файл не найден, база данных недоступна)
  • Используйте коды ошибок для высокочастотных операций, где ошибка нормальна и ожидаема (например, промах кэша, неблокирующий ввод-вывод вернул бы блокировку)
  • Используйте std::expected для нового кода, где нужна явная обработка ошибок с богатыми типами ошибок и монадической композицией
  • Используйте исключения в конструкторах — это единственный способ сигнализировать об ошибке до существования объекта
  • Никогда не выбрасывайте в деструкторах — залогируйте ошибку и подавите её
Ключевые выводы
  • Исключения, коды ошибок и std::expected — дополняющие друг друга инструменты, а не конкурирующие философии
  • Конструкторы должны использовать исключения для сигнализации об ошибках; коды ошибок не вариант
  • Деструкторы никогда не должны выбрасывать — они неявно noexcept начиная с C++11
  • Исключения не пересекают границы потоков — используйте std::future или std::exception_ptr для их передачи
  • std::error_code идеален для системных ошибок, где нужен невыбрасывающий API

Тест — проверьте свои знания

(15 XP)

1. Почему исключения являются предпочтительным механизмом обработки ошибок в конструкторах?

2. Что происходит, если исключение выбрасывается в деструкторе во время раскрутки стека от другого исключения?

3. Как можно передать исключения между потоками?