Исключения как механизм ошибок
Исключение — это механизм «бросить объект и раскрутить стек до того, кто поймает». Позволяет отделить обнаружение ошибки от её обработки: библиотека бросает, а прикладной код решает, что делать — на другом уровне вызовов.
#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), не ловит без обработки, и либо даёт строгую гарантию, либо документирует базовую. Это всё, что нужно от корректного кода с исключениями.