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

Функция чтения конфига может не найти файл, встретить битую строку или упереться в права доступа. Как сообщить об этом вызывающему коду? В C++ у вас минимум четыре способа: бросить исключение, вернуть код ошибки, отдать std::optional или std::expected. Выбор не косметический: он определяет размер бинарника, поведение на горячем пути и то, сможет ли компилятор заставить вас проверить результат.
Очевидное решение — «везде исключения, это же C++» — ломается на двух вещах. Первое: исключения дороги на пути выброса и запрещены или нежелательны в части кодовых баз (embedded, игровые движки, часть библиотек компилируется с -fno-exceptions). Второе: исключение не видно в сигнатуре, и вызывающий код легко забывает про try/catch. Разберём цену каждого подхода.
Четыре подхода и чем каждый платит
Исключения
Классический механизм: throw раскручивает стек до ближайшего подходящего catch, вызывая деструкторы локальных объектов. Главное свойство — так называемый zero-cost1 путь на успешном исполнении: пока исключение не брошено, накладных расходов почти нет. Плата приходит в момент выброса — раскрутка стека через таблицы обработчиков стоит на порядки дороже обычного return.
#include <stdexcept>
#include <string>
// C++17. Бросает при неверном вводе.
int parse_port(const std::string& text) {
size_t consumed = 0;
int value = std::stoi(text, &consumed); // сам может бросить
if (consumed != text.size())
throw std::invalid_argument("лишние символы в номере порта");
if (value < 1 || value > 65535)
throw std::out_of_range("порт вне диапазона 1..65535");
return value;
}Сигнатура ничего не говорит о том, что функция может бросить. Читатель обязан заглянуть в тело или в документацию. Это цена, которую платит вся кодовая база, а не только эта функция.
Коды возврата и out-параметры
Стиль в духе C: функция возвращает статус, а результат кладёт в переданную по ссылке переменную. Ошибку не спрячешь на горячем пути, но и не пропустишь мимо: код статуса приходит в каждом вызове.
// C++17. Возвращает false при ошибке, результат через out-параметр.
bool parse_port(const std::string& text, int& out) {
try {
size_t consumed = 0;
int value = std::stoi(text, &consumed);
if (consumed != text.size() || value < 1 || value > 65535)
return false;
out = value;
return true;
} catch (...) {
return false;
}
}Проблема известная: вызывающий может проигнорировать возвращённое значение, и компилятор по умолчанию промолчит. Спасает атрибут [[nodiscard]] на функции — тогда игнор результата станет предупреждением.
std::optional
Появился в C++17. Подходит, когда причина неудачи вызывающему не важна — важен сам факт «значения нет».
#include <optional>
#include <string>
// C++17. nullopt = не удалось разобрать, деталей не сообщаем.
std::optional<int> parse_port(const std::string& text) {
try {
size_t consumed = 0;
int value = std::stoi(text, &consumed);
if (consumed != text.size() || value < 1 || value > 65535)
return std::nullopt;
return value;
} catch (...) {
return std::nullopt;
}
}Компилятор не даст обратиться к значению без проверки без последствий: разыменование пустого optional через * — UB, а .value() бросит std::bad_optional_access. Но саму проверку забыть всё ещё можно.
std::expected
Добавлен в C++23. Несёт либо значение, либо объект ошибки — компромисс между кодом возврата и исключением: детали неудачи сохраняются, а раскрутки стека нет. Доступен в GCC 14+, Clang 18+ и MSVC 19.3x при флаге стандарта /std:c++23 или -std=c++23.
#include <expected>
#include <string>
enum class PortError { NotANumber, OutOfRange };
// C++23. Возвращает порт или причину отказа.
std::expected<int, PortError> parse_port(const std::string& text) {
size_t consumed = 0;
int value = 0;
try {
value = std::stoi(text, &consumed);
} catch (...) {
return std::unexpected(PortError::NotANumber);
}
if (consumed != text.size())
return std::unexpected(PortError::NotANumber);
if (value < 1 || value > 65535)
return std::unexpected(PortError::OutOfRange);
return value;
}Ошибка теперь часть возвращаемого типа — видна в сигнатуре, и её нельзя молча выбросить. Проверять есть значение или нет придётся явно через has_value() или if (auto r = parse_port(...)).
Сравнение
| Подход | Стоимость на успехе | Стоимость на ошибке | Видно в сигнатуре | Причина ошибки | Версия |
|---|---|---|---|---|---|
| Исключения | около нуля | высокая (раскрутка стека) | Нет | Да, любой тип | любая |
| Код возврата | минимальная | минимальная | Частично | Только код | любая |
| std::optional | минимальная | минимальная | Да | Нет | C++17 |
| std::expected | минимальная | минимальная | Да | Да, свой тип | C++23 |
Исключения бесплатны, пока их не бросают. Если ошибка на вашем пути — обычная ситуация, а не авария, вы платите раскруткой стека за то, что могли бы вернуть значением.
Когда что выбирать
- Ошибка — редкая авария (кончилась память, разорвано соединение, нарушен инвариант): исключения. Успешный путь чист, стоимость выброса не важна на фоне редкости события.
- Ошибка — штатная часть логики (пользователь ввёл кривой порт, ключа нет в кеше):
expectedпри C++23 или код возврата ниже. Возвращать исключением то, что случается в каждом десятом вызове, — бить по горячему пути. - Причина неважна — только «есть значение или нет»:
optional. Не тащитеexpectedс фиктивным типом ошибки. - Кодовая база собирается с
-fno-exceptions(embedded, часть игровых движков): исключения отпадают, остаютсяexpected,optionalи коды. - Публичный API библиотеки:
expectedделает контракт ошибок явным для всех, кто её подключит.
Что ломается
Проглоченное исключение
catch (...) без переброса или логирования прячет и ту ошибку, которую вы ждали, и ту, которую не ждали, — например std::bad_alloc. Ловите конкретные типы, а не всё подряд.
Игнор результата
optional и expected не спасают, если вызывающий забыл проверить и сразу разыменовал. Ставьте [[nodiscard]] на функции, возвращающие статус, — компилятор предупредит о брошенном результате.
Смешение стилей на одном слое
Когда половина функций модуля бросает, а половина возвращает expected, вызывающий код обрастает и try/catch, и проверками has_value(). Выберите один стиль на границу модуля и держитесь его.
Исключения в деструкторах
Деструктор, из которого вылетает исключение во время раскрутки стека другого исключения, ведёт к std::terminate. Деструкторы неявно noexcept начиная с C++11 — не бросайте из них.
Что делать дальше
Определите на уровне модуля, что для вас авария, а что штатная неудача, — от этого зависит выбор. Если проект на C++23, попробуйте expected на новом коде и сравните читаемость. Померьте цену выброса исключения на своём горячем пути микробенчмарком (google/benchmark): цифры зависят от компилятора, глубины стека и числа деструкторов, поэтому меряйте на своём коде, а не верьте чужим. Монадические операции expected (and_then, transform) — отдельная тема, здесь разбирали только выбор между подходами.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: std::expected — cppreference, std::optional — cppreference
Частые вопросы
std::expected нет в моём компиляторе — что делать?
Насколько именно исключения медленнее кодов возврата?
Стоит ли отключать исключения флагом -fno-exceptions?
Почему компилятор не заставляет проверять std::optional?
Можно ли смешивать исключения и expected в одном проекте?
Что вернуть, если причина ошибки вызывающему безразлична?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.