Представьте, что деструктор RAII-класса выбрасывает исключение во время раскрутки стека из-за другого исключения. Как завершится программа и почему?
Программа завершится через std::terminate, потому что во время раскрутки стека уже активно одно исключение, а выброс второго приводит к двойному исключению. Деструкторы обычно не должны позволять исключениям покидать их тело: неустранимые ошибки освобождения ресурса следует обрабатывать внутри деструктора или отражать другим способом.
RAII появился как способ связать управление ресурсом с временем жизни объекта. Конструктор захватывает ресурс, а деструктор гарантированно освобождает его при выходе из области видимости, включая выход из-за исключения.
Эта модель устраняет необходимость вручную вызывать освобождение ресурса на каждом пути выхода. Однако она требует, чтобы уничтожение объектов было безопасным при раскрутке стека; иначе механизм обработки исключений сам окажется прерван вторым исключением.
Пусть функция захватила несколько ресурсов, а затем выбросила исключение. C++ начинает уничтожать локальные объекты в обратном порядке. Если один из их деструкторов также выбрасывает исключение, одновременно возникают два активных исключения.
Язык не пытается выбрать, какое исключение сохранить, и не продолжает раскрутку стека. Вызывается std::terminate, поэтому исходная ошибка и часть гарантированной очистки могут быть потеряны.
Деструктор без явно указанного спецификатора обычно имеет неявное noexcept(true), если его свойства не делают его потенциально выбрасывающим. Если исключение покидает такой деструктор, вызывается std::terminate даже без другого активного исключения.
Если деструктор объявлен как noexcept(false), исключение может покинуть его при обычном уничтожении объекта. Но во время раскрутки стека это всё равно приведёт к std::terminate, поскольку второе исключение нельзя безопасно добавить к уже обрабатываемому.
Минимальная иллюстрация:
Здесь деструктор Guard вызывается во время обработки исключения 2. Попытка выбросить исключение 1 приводит к std::terminate; стандартный обработчик обычно завершает процесс аварийно.
Практическое правило — деструктор должен быть небросающим. Ошибки освобождения нужно перехватывать внутри него, записывать в журнал, сохранять во внутреннем состоянии объекта или обрабатывать через отдельный явный метод, вызванный до уничтожения объекта.
Есть компромисс: подавление ошибки в деструкторе может скрыть проблему. Поэтому для операций, где освобождение действительно может завершиться значимой ошибкой, часто используют явный метод close или commit, а деструктор оставляют аварийным резервным механизмом очистки без выброса исключений.
В сервере RAII-объект должен отправить незаписанные данные при уничтожении сетевого соединения. Отправка может завершиться ошибкой одновременно с исключением из бизнес-логики.
Первый вариант — выбрасывать исключение из деструктора. Он позволяет сообщить об ошибке обычным способом, но при раскрутке стека вызывает std::terminate, поэтому сервер может аварийно завершиться вместо обработки исходной ошибки.
Второй вариант — объявить деструктор noexcept(false). Это сохраняет возможность распространить ошибку при обычном выходе, но не решает проблему второго исключения во время раскрутки и делает поведение объекта зависимым от контекста уничтожения.
Выбранный вариант — явный метод flush или close, который вызывается в основном сценарии и возвращает либо сообщает об ошибке. Деструктор пытается освободить соединение без исключения и записывает неустранённую ошибку в журнал. Так основной код контролирует критическую операцию, а аварийная очистка не разрушает механизм обработки исключений.
std::terminate?Нет. Если деструктор действительно допускает исключения через noexcept(false) и в данный момент нет другого активного исключения, оно может покинуть деструктор. Но это опасный дизайн: при уничтожении нескольких объектов или во время раскрутки стека второе исключение приведёт к std::terminate.
noexcept(false) только ради сообщения об ошибке?Такой спецификатор переносит проблему на вызывающий код, который не всегда контролирует момент уничтожения объекта. Объект может уничтожаться при выходе из функции, при разрушении другого объекта или внутри обработки исключения. Надёжнее разделить успешную операцию и освобождение ресурса: важное действие выполнить явно, а деструктор сделать небросающим.
Если исключение полностью обработано внутри деструктора и не покидает его, двойного исключения не возникает. Деструктор должен сохранить инварианты объекта и не допустить выхода исключения наружу; при необходимости он может записать диагностическую информацию или установить состояние ошибки. При этом перехват не делает операцию успешной — вызывающий код уже не сможет обработать её как обычное исключение, поэтому для критичных ошибок нужен отдельный явный метод.