Обработка исключений в 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
Рекомендуемый стиль в конструкторе:
std::unique_ptr/std::make_uniqueвместо гологоnew— ООП в C++, раздел про память- контейнеры STL вместо ручных массивов — STL в ООП C++
- guard-объекты (
std::lock_guard, обёртки над файлом) с деструктором, который освобождает ресурс — многопоточность
См. также жизненный цикл объекта, Управление памятью, свойства конструкторов.
Повторный 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 |
дороже на пути throw | std::expected<T,E> без раскрутки стека |
В embedded часто компилируют с -fno-exceptions — Системное программирование на C++.
Типичные ошибки
| Ошибка | Последствие |
|---|---|
catch в неверном порядке | узкий тип никогда не ловится |
catch (...) не первым, но единственный "широкий" | ок; но внутри catch (...) нельзя узнать тип без повторного throw |
Деструктор с throw | риск terminate при двойном исключении |
| Бросать сырой указатель | кто владеет памятью — неясно |
new и исключение в конструкторе | объект может не создаться; сырой new в теле конструктора даст утечку — исключения в конструкторах, make_unique, RAII-поля |
Связанные материалы
- Иерархия std::exception
- RTTI — bad_cast
- ООП в C++ — конструкторы, RAII
- Идиомы — exception safety