Исключения в C++: когда бросать, а когда возвращать код
Исключения в C++ бесплатны на счастливом пути и дороги на пути ошибки. Разбираем, где они уместны, где вредят, и как не сломать инварианты при их распространении.

Функция парсинга конфига на горячем пути начала съедать заметную долю времени под нагрузкой, хотя логика в ней тривиальная. Профилировщик показал, что дело не в парсинге, а в том, что на каждую отсутствующую строку конфига бросалось и ловилось исключение — сотни тысяч раз в секунду. После замены на код возврата время обработки упало в разы. Это классическая ошибка: исключения в C++ спроектированы для редких ситуаций, и когда ими заменяют штатный поток управления, они мстят производительностью.
Разберём, где исключения — правильный инструмент, где они превращаются в утечку и порчу данных, и по какому критерию решать между throw и кодом возврата. Речь про C++17 и новее; std::optional, std::expected (C++23) и гарантированный copy elision меняют картину по сравнению со старым C++98.
Что вообще происходит при throw
Когда вы бросаете исключение, среда выполнения раскручивает стек: для каждого локального объекта между точкой throw и подходящим catch вызывается деструктор. Это и называется stack unwinding. Механизм построен на таблицах, которые компилятор кладёт рядом с кодом. На современных ABI (Itanium ABI, который использует GCC и Clang) действует модель zero-cost exceptions: пока исключение не брошено, накладных расходов нет вообще — счастливый путь не платит ни за что. Но само бросание дорогое: поиск обработчика по таблицам, вызов деструкторов, работа с RTTI типа исключения.
Исключения бесплатны, пока не случаются. Ошибка в том, чтобы сделать их частыми — тогда вы платите полную цену раскрутки стека на каждом шаге штатной логики.
Отсюда первое правило: исключение — для ситуации, которая по смыслу является ошибкой и происходит редко. «Файл не открылся», «сеть отвалилась», «не хватило памяти» — да. «Ключ не найден в словаре, и это нормально» — нет, это find(), возвращающий итератор end().
Гарантии безопасности исключений
Главная опасность исключений не в скорости, а в том, что раскрутка стека может оставить объект в противоречивом состоянии. Классификация Абрахамса делит функции по тому, что они гарантируют, если внутри вылетело исключение:
- Базовая гарантия — инварианты объекта не нарушены, ресурсы не утекли, но состояние может измениться непредсказуемо (например, вектор частично заполнен).
- Строгая гарантия — либо операция выполнилась целиком, либо состояние осталось таким, каким было до вызова. Это транзакционность.
- Гарантия no-throw — функция не бросает вообще. Обязательна для деструкторов и операций перемещения, если вы хотите, чтобы контейнеры их использовали.
Стандартная библиотека даёт эти гарантии осознанно. Например, std::vector::push_back даёт строгую гарантию: если при реаллокации бросится копирующий конструктор элемента, вектор останется прежним. Но это работает, только если ваш move-конструктор помечен noexcept — иначе вектор вынужден копировать вместо перемещения, чтобы сохранить строгую гарантию.
// C++17. Без noexcept vector при росте будет КОПИРОВАТЬ,
// а не перемещать, чтобы сохранить строгую гарантию.
class Buffer {
public:
Buffer(Buffer&& other) noexcept // критично: noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
Buffer& operator=(Buffer&&) noexcept = default;
private:
char* data_ = nullptr;
std::size_t size_ = 0;
};RAII — единственный способ не течь
При раскрутке стека вызовутся только деструкторы. Значит, любой ресурс, который надо освободить, должен принадлежать объекту с деструктором. Ручной new без владельца — гарантированная утечка при исключении между new и delete.
// Плохо: если process() бросит, память утечёт
void handle_bad() {
Widget* w = new Widget();
process(w); // если бросит — delete не вызовется
delete w;
}
// Хорошо: unique_ptr освободит объект при раскрутке стека
#include
void handle_good() {
auto w = std::make_unique();
process(w.get()); // бросит — деструктор unique_ptr всё уберёт
}То же касается блокировок (std::lock_guard), файловых дескрипторов, транзакций БД. Если у вас есть пара «захватил — освободил» без RAII-обёртки, исключение эту пару разорвёт.
Деструктор, который бросает — это crash
С C++11 деструкторы по умолчанию noexcept. Если из деструктора вылетит исключение, вызовется std::terminate, и программа умрёт. Причина в том, что деструктор может вызываться во время уже идущей раскрутки стека — а два исключения одновременно среда не умеет обрабатывать. Правило: из деструктора ничего не должно вылетать. Если внутри есть операция, способная бросить (закрытие сокета, flush буфера), ловите исключение прямо в деструкторе и логируйте.
Throw или код возврата: критерий выбора
Не бывает «исключения всегда лучше» или «коды возврата всегда лучше». Есть характер ситуации.
| Ситуация | Инструмент | Почему |
|---|---|---|
| Редкая ошибка, восстановление на верхнем уровне | Исключение | Не засоряет промежуточный код проверками, тащит контекст наверх |
| Ожидаемый исход, вызывающий обязан обработать сразу | std::optional / std::expected | Это часть контракта, а не ошибка; вызывающий не забудет проверить |
| Горячий цикл, ошибка на каждой итерации возможна | Код возврата | Раскрутка стека убьёт производительность |
| Конструктор не смог создать валидный объект | Исключение | У конструктора нет другого способа сообщить о провале |
| Граница с C-кодом или ABI | Код возврата | Исключения не проходят через extern "C" безопасно |
С C++23 появился std::expected<T, E> — типизированная альтернатива, когда ошибка ожидаема и её тип известен. Он делает «неуспех» частью сигнатуры функции, и компилятор не даст молча проигнорировать результат, если вы обвяжете его [[nodiscard]]. Если целитесь в C++23 и цепочка вызовов часто заканчивается ошибкой — это лучше исключений.
Пример границы: конструктор
Конструктор не возвращает значение. Если объект не может быть построен в валидном состоянии, единственный корректный выход — бросить исключение. Альтернатива — двухфазная инициализация (конструктор плюс метод init()), но она даёт окно, где объект существует, но не готов, и это источник багов. Исключение из конструктора здесь чище.
Чего делать нельзя
- Ловить и глотать.
catch (...) {}без обработки прячет ошибку и превращает баг в невоспроизводимое поведение. Ловите то, что реально умеете обработать. - Бросать по значению, ловить по значению. Бросайте по значению, ловите по
const&— иначе получите срезку (slicing) типа исключения. - Использовать исключения для нормального потока. «Дошли до конца файла» — не исключение, это ожидаемое событие.
- Бросать не-исключения. Бросать можно что угодно, но наследуйтесь от
std::exception, иначе обобщённые обработчики вас не поймают.
Что делать дальше
Пройдитесь по классам с ручным управлением ресурсами и переведите их на RAII-обёртки — это закрывает большинство утечек при исключениях. Пометьте move-операции noexcept там, где они действительно не бросают: без этого контейнеры теряют в скорости на реаллокации. Если проект на C++23 — рассмотрите std::expected для функций с ожидаемым неуспехом. И померьте: соберите с -fno-exceptions экспериментально, чтобы понять, где в коде исключения вообще используются, и не идут ли они по горячему пути. Гарантии безопасности исключений для собственных классов — отдельная большая тема, здесь мы только обозначили классификацию.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: cppreference — Exceptions, C++ Core Guidelines — Error handling
Частые вопросы
Стоит ли собирать проект с -fno-exceptions ради скорости?
А если у меня старый C++98 без std::optional и std::expected?
Почему move-конструктор обязательно помечать noexcept?
Можно ли бросать исключение из деструктора?
Что выбрать между исключением и std::expected для ошибок сети?
Исключение из конструктора — что произойдёт с уже созданными членами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.