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

Обработка исключений в C++

Разработчику Архитектору
О чём эта статья

Исключение — способ сообщить "здесь операция не удалась" и передать обработку наверх по стеку вызовов, минуя промежуточные return. В C++ это связано с RAII: при раскрутке стека вызываются деструкторы локальных объектов — это практическая форма блока с гарантированным завершением без отдельного finally в стандарте. Необработанное исключение обычно вызывает std::terminate (аварийный отказ процесса).

Иерархия стандартных типов — Иерархия исключений в стандартной библиотеке C++. Сначала — ошибка vs исключение и ошибки и исключения. Zero-cost EH: C++ — углублённые темы. Память: Управление памятью в C++.

Play ITЗагрузка интерактивного демо…


Исключение простыми словами

Функция divide(a, b) не может вернуть число, если b == 0. Варианты:

ПодходКак выглядитПлюсМинус
Код ошибкиif (!ok) return -1;предсказуемо в embeddedлегко забыть проверить
Исключениеthrow ...;ошибка "всплывает" к catchстоимость на пути throw

throw создаёт объект-исключение и ищет ближайший catch, подходящий по типу. Пока ищет — выполнение покидает текущую функцию, вызывая деструкторы локальных переменных.


Базовый синтаксис

Код ITЗагрузка примера кода…

Разбор:

  • В divide проверка if (b == 0.0) реализует fail-fast: некорректный вход сразу превращается в исключение.
  • throw std::invalid_argument(...) создаёт объект исключения стандартного типа, подходящего для ошибок аргументов.
  • Блок try изолирует потенциально аварийный участок, а цепочка catch идёт от более конкретного типа к более общему.
  • catch (...) выступает последней страховкой для нестандартных типов исключений, но не даёт типизированного доступа к ошибке.

Разбор конструкций

КонструкцияРоль
throw std::invalid_argument("...");создать объект исключения и начать поиск обработчика
try { ... }охраняемый блок — отсюда исключение может "вылететь"
catch (const std::invalid_argument& ex)перехватить этот тип и производные
catch (const std::exception& ex)перехватить любой стандартный exception
catch (...)перехватить любой тип (последний обработчик)
ex.what()человекочитаемое сообщение (const char*)

Порядок catch важен

Обработчики смотрят сверху вниз. Сначала — узкий тип, потом — широкий:

catch (const std::invalid_argument& ex) { } // сначала
catch (const std::exception& ex) { } // потом

Разбор:

  • Порядок важен, потому что поиск catch выполняется сверху вниз.
  • std::invalid_argument наследуется от std::exception, поэтому более общий обработчик должен идти после частного.
  • Такой порядок сохраняет точность обработки: можно отдельно реагировать на ошибки валидации и на остальные исключения.

Если поменять местами, ветка invalid_argument никогда не выполнится: invalid_argument является std::exception, и сработает первый подходящий catch.


Объект исключения

Исключение — значение (часто объект класса). При throw e; для lvalue может происходить копирование; для временных — перемещение (C++11).

Рекомендация — бросать типы, наследующие std::exception, с понятным what():

#include <exception>
#include <string>

class ConfigError : public std::exception {
std::string message_;
public:
explicit ConfigError(std::string msg) : message_(std::move(msg)) {}
const char* what() const noexcept override {
return message_.c_str();
}
};

Разбор:

  • Класс наследуется от std::exception, чтобы его можно было корректно перехватывать в стандартной иерархии catch.
  • Сообщение хранится в message_, а what() возвращает стабильный const char* через c_str().
  • explicit у конструктора предотвращает случайные неявные преобразования строк в исключение.
  • noexcept у what() обязателен по контракту базового класса и критичен для безопасной обработки ошибок.

Разбор ConfigError:

  • std::string message_ — текст хранится в объекте (строка переживёт выход из конструктора).
  • explicit ConfigError(...) — запрет неявного преобразования из string в ConfigError.
  • noexcept override на what() — стандартное требование: what() не бросает.

Избегайте throw "error"; и throw 42; — ловить по типу const char* или int неудобно, легко ошибиться в catch.


Раскрутка стека (stack unwinding)

Когда исключение не перехвачено в текущей функции, runtime покидает её и поднимается вверх по стеку вызовов. На каждом уровне вызываются деструкторы локальных объектов, пока не найдётся catch.

Код ITЗагрузка примера кода…

Разбор:

  • inner() открывает файл и затем бросает std::runtime_error, имитируя ошибку в глубине стека вызовов.
  • При выходе из inner() по исключению деструктор std::ifstream автоматически освобождает ресурс (RAII).
  • outer() перехватывает std::exception, поэтому получает как runtime_error, так и другие стандартные производные.
  • Пример показывает, что unwinding управляет очисткой локальных объектов без ручного finally.

Цепочка:

main → outer() → inner() → throw
↑ unwinding: ~file, ~локальные в inner
catch в outer()

std::ifstream file(...)RAII: при unwinding файл закроется в деструкторе, даже если throw случился сразу после открытия. Поэтому в C++ предпочитают RAII вместо try/finally как в Java: Идиомы современного C++, Объектно-ориентированное программирование в C++ — жизненный цикл.

Если исключение нигде не поймано — вызывается std::terminate (аварийное завершение).


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

Обычная функция может вернуть код ошибки (return -1) или std::optional. Конструктор так не умеет — у него нет возвращаемого значения. Если инициализация невозможна, принято throw — передать обработку наверх по стеку. Подробнее про механизм — ошибка и исключение.

Главный вопрос для новичка: считается ли объект уже созданным, если конструктор бросил исключение на полпути?

Нет. Пока конструктор не завершился успешно, объект не существует в смысле языка. Его деструктор не вызовется. При throw в конструкторе уже готовые поля-классы и базовые подклассы разрушаются автоматически — это часть раскрутки стека.

Объект на стеке

void foo() {
Test t; // конструктор Test бросил std::runtime_error
// сюда выполнение не дойдёт
}
  • объект t не создан
  • ~Test() не вызывается
  • поля Test, которые успели сконструироваться до throw, уничтожаются при выходе из конструктора

Объект в куче (new)

Test* p = nullptr;
try {
p = new Test();
} catch (...) {
// если конструктор бросил — p остаётся nullptr
}
delete p; // безопасно, но бессмысленно при nullptr
  • оператор new сначала выделяет память, затем вызывает конструктор
  • если конструктор бросил, new не возвращает указатель наружу (исключение уходит в catch)
  • переменная p остаётся в том состоянии, в котором была до new (здесь nullptr)

Опасная иллюзия — думать, что после catch можно вызвать delete и "дочистить" объект. Если конструктор не дошёл до конца, деструктора не будет — в том числе для ресурсов, захваченных сырым new внутри тела конструктора.

Ошибка с сырым new в конструкторе

class Test {
public:
Test() {
open_resource(); // файл, сокет, сырой new…
throw std::runtime_error("init failed");
}
~Test() { close_resource(); } // не вызовется, если throw в конструкторе
};
  • ~Test() сработает только у полностью созданного объекта
  • сырой new или fopen в теле конструктора до throw нужно закрывать в самом конструкторе (локальный guard, вложенный try/catch) или не использовать сырой API вовсе
  • поля типа std::string, std::vector, std::unique_ptr при throw корректно разрушатся сами — это и есть смысл RAII

Рекомендуемый стиль в конструкторе:

См. также жизненный цикл объекта, Управление памятью, свойства конструкторов.


Повторный throw

try {
risky();
} catch (const std::exception& ex) {
log(ex.what());
throw; // проброс того же объекта
}

Разбор:

  • Локальный catch используется для side-effect логирования, не "поглощая" исходную ошибку.
  • throw; без аргумента пробрасывает именно текущий объект исключения, сохраняя его динамический тип.
  • Такой шаблон полезен, когда нужно добавить контекст в логи и передать обработку на уровень выше.

throw; без операнда — только внутри catch. Пробрасывается тот же объект, что поймали.


noexcept и деструкторы

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

Практическое правило: деструкторы только освобождают ресурсы; ошибки внутри — логировать или глотать, но не throw. Подробнее: Иерархия исключений в стандартной библиотеке C++.


Гарантии безопасности

ГарантияСмысл для новичка
Базоваяпри исключении ресурсы освобождаются (деструкторы)
Сильнаяпосле ошибки объект как до операции
Нет выбросаоперация гарантированно не бросает

Контейнеры STL документируют гарантии для push_back, resize и т.д. — см. Объектно-ориентированное программирование в C++ — STL, Управление памятью в C++.


Исключения и коды ошибок

ИсключенияКоды возврата / std::expected (C++23)
ошибку сложнее "проглотить" молчапроверка на каждом шаге явная
удобны при глубокой вложенности вызововпредсказуемы в real-time / embedded
дороже на пути throwstd::expected<T,E> без раскрутки стека

В embedded часто компилируют с -fno-exceptionsСистемное программирование на C++.


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

ОшибкаПоследствие
catch в неверном порядкеузкий тип никогда не ловится
catch (...) не первым, но единственный "широкий"ок; но внутри catch (...) нельзя узнать тип без повторного throw
Деструктор с throwриск terminate при двойном исключении
Бросать сырой указателькто владеет памятью — неясно
new и исключение в конструктореобъект может не создаться; сырой new в теле конструктора даст утечку — исключения в конструкторах, make_unique, RAII-поля

Связанные материалы