Добавить в избранное

Исключения в C++: try/catch, иерархия std::exception и безопасность

Чтение ≈ 5 мин

Исключения C++ от базового try/catch до своих классов ошибок: стандартная иерархия std::exception, гарантии исключений (basic/strong/nothrow), noexcept и альтернативы — std::expected и коды возврата.

Исключения как механизм ошибок

Исключение — это механизм «бросить объект и раскрутить стек до того, кто поймает». Позволяет отделить обнаружение ошибки от её обработки: библиотека бросает, а прикладной код решает, что делать — на другом уровне вызовов.

#include <stdexcept>
#include <iostream>

int divide(int a, int b) {
    if (b == 0)
        throw std::invalid_argument("деление на ноль");
    return a / b;
}

int main() {
    try {
        std::cout << divide(10, 0);
    } catch (const std::invalid_argument& e) {
        std::cerr << "ошибка аргумента: " << e.what() << "\n";
    } catch (const std::exception& e) {
        std::cerr << "другая ошибка: " << e.what() << "\n";
    }
}

Ключевые правила: бросать по значению (throw std::runtime_error(...)), ловить по константной ссылке (catch (const std::exception&)). Ловля по значению скопирует исключение и срежет наследников.

Стандартная иерархия

Все стандартные исключения наследуют std::exception и предоставляют what() — строку с описанием:

  • std::logic_error — программная ошибка: invalid_argument, domain_error, out_of_range, length_error. По-хорошему, это ошибки в коде, а не в данных.
  • std::runtime_error — ошибки выполнения: overflow_error, underflow_error, system_error (с errno-кодом).
  • std::bad_alloc — нехватка памяти, std::bad_cast — неудачный dynamic_cast на ссылке, std::bad_variant_access — неверный тип variant.
try {
    v.at(1000000);             // std::out_of_range
} catch (const std::out_of_range& e) {
    // обрабатываем именно выход за границу
} catch (const std::exception& e) {
    // любая другая стандартная ошибка
} catch (...) {
    // вообще всё, включая throw 42 — лучше не делать
}

Порядок catch-блоков важен: от более специфических к более общим. catch (const std::exception&) в начале «съест» всё.

Свои исключения

Для доменных ошибок наследуются от подходящего стандартного:

class ConfigError : public std::runtime_error {
public:
    explicit ConfigError(const std::string& what) : std::runtime_error(what) {}
};

class ConfigFileMissing : public ConfigError {
public:
    explicit ConfigFileMissing(const std::string& path)
        : ConfigError("не найден файл конфигурации: " + path) {}
};

Иерархия своих исключений даёт выбор: поймать широко (catch ConfigError) или узко (catch ConfigFileMissing). Конструктор должен собирать сообщение сразу — what() просто отдаёт строку.

noexcept: контракт об исключениях

Спецификатор noexcept говорит «функция не бросает»:

void swapData(Buffer& a, Buffer& b) noexcept;
int size() const noexcept;

Зачем это нужно:

  • Производительность: std::vector при реаллокации выбирает move только если он noexcept.
  • Семантика: деструкторы, swap, move-операции не должны бросать — это контракт для компилятора и читателя.
  • Бросок из noexcept — вызов std::terminate немедленно, без раскрутки.

По умолчанию деструкторы noexcept. Помечайте noexcept деструкторы, move-конструкторы, операторы присваивания-перемещения, swap и всё, что физически не может бросить.

Гарантии исключений: что обещает код

Три уровня безопасности при исключениях:

  • Базовая (basic): после исключения объекты в валидном состоянии, нет утечек, но состояние могло частично измениться. Минимум, который должен давать весь код.
  • Строгая (strong): либо операция успела целиком, либо состояние как до вызова — транзакционность. Достигается идиомой copy-and-swap или «сначала всё может сломаться, потом коммит».
  • Nothrow: функция не бросает вообще.
// strong guarantee: изменения только через временную копию
void setData(std::vector<int> newData) {
    validate(newData);              // может бросить — this ещё не тронут
    data_.swap(newData);            // noexcept — коммит
}

Исключения против кодов возврата и std::expected

Исключения хороши для редких ошибок и разделения логики от обработки. Но на критичном пути или где ошибки — нормальная часть бизнес-логики (невалидный ввод пользователя, отсутствие файла) исключения тяжеловесны: раскрутка стека недёшева.

Альтернативы:

// std::expected (C++23) — либо значение, либо ошибка
std::expected<int, std::string> parsePort(std::string_view s);

auto r = parsePort("8080");
if (r) usePort(*r); else logError(r.error());

// C++17: optional для «может отсутствовать»
std::optional<int> findIndex(const std::string& key);

// status-коды (для embedded / hot path)
enum class Status { Ok, NotFound, IoError };
Status save(const Data& d);

На микроконтроллерах и в системах реального времени исключения часто вообще выключают (-fno-exceptions) — там работают коды возврата и expected.

Типичные ошибки

  • Ловить по значению — срезка наследников.
  • Пустой catch — проглатывание ошибок делает отладку адом.
  • Бросать примитивы throw -1 — ловится только catch(...), нет информации.
  • Исключение из деструктора — std::terminate при раскрутке.
  • throw; вне catch — terminate. Для переброса — только throw; внутри catch.
  • Слишком мелкие catch вокруг каждой строки — теряется смысл исключений.

Итог

Модель: программные ошибки — logic_error, ошибки окружения — runtime_error и наследники, специфика — свои классы от std-иерархии. Бросайте по значению, ловите по const&, помечайте noexcept то, что не бросает, держите строгую гарантию там, где частичное состояние опасно. Для «ошибка — обычный исход» используйте std::expected или optional.

try/catch как стратегия архитектуры

Исключение должно пересекать границы ответственности: библиотека бросает — приложение решает. Внутри одной функции исключение излишне — там if достаточно. Глубокий стек с исключением внизу — нормально, он для того и раскручивается.

// уровень библиотеки: бросаем
Image loadImage(const fs::path& p) {
    if (!fs::exists(p)) throw std::runtime_error("нет файла " + p.string());
    // ...
}

// уровень приложения: решаем
try {
    auto img = loadImage(path);
    show(img);
} catch (const std::exception& e) {
    showError(e.what());          // пользователю — понятный текст
}

noexcept move: почему vector любит его

Когда vector перевыделяет память, он должен перенести элементы. Если move-конструктор элемента может кинуть исключение — перенос наполовину завершится, и старый буфер уже удалён: нарушена строгая гарантия. Поэтому при не-noexcept move вектор предпочитает копию. Пометьте move noexcept — и вектор будет перемещать быстро.

Исключения в конструкторах

Конструктор — единственный способ сказать «объект не создался»: нет кода возврата. Если ресурс недоступен — кидайте исключение из конструктора. Уже созданные члены уничтожатся при раскрутке благодаря RAII — утечек не будет, если все ресурсы в обёртках.

Что читать для углубления

  • std::exception_ptr и std::rethrow_exception — перенос исключения между потоками.
  • std::nested_exception — исключение с причиной (exception chaining).
  • std::expected (C++23) — когда ошибка — нормальный путь, а не «исключительная ситуация».

Проверка: хорошая функция с исключениями

Функция хорошо спроектирована, если: бросает стандартные или свои исключения от std::exception, не бросает из деструктора/move/swap (они noexcept), не ловит без обработки, и либо даёт строгую гарантию, либо документирует базовую. Это всё, что нужно от корректного кода с исключениями.

Поделиться
Понравился материал?

Комментарии

Добавить комментарий