От выброса к обработке
На предыдущей странице вы узнали, как выбросить (throw) исключение, когда что-то идёт не так. Выброс - это только половина истории: исключение, которое так и не перехвачено, вызывает std::terminate и приводит к аварийному завершению программы. Инструкция try/catch - это способ обработать выброшенное и продолжить работу.
Структура проста: поместите рискованный код в блок try, а за ним разместите один или несколько блоков catch, которые реагируют на конкретные типы ошибок. Если блок try отработал без сбоев, каждый catch пропускается. В тот момент, когда что-то выбрасывается, управление сразу переходит к первому подходящему catch.
Обратите внимание, что "after" никогда не печатается. Как только срабатывает throw, оставшаяся часть блока try отбрасывается, и выполнение возобновляется внутри подходящего catch. После завершения catch программа нормально продолжает работу ниже.
Перехват по константной ссылке
Самая важная привычка в обработке ошибок в C++: перехватывайте исключения по const-ссылке, а не по значению.
Перехват по значению копирует исключение и, что хуже, срезает его (slicing). Стандартные исключения образуют иерархию (runtime_error и logic_error оба наследуются от std::exception), поэтому перехват производного исключения как базового значения отсекает производную часть. Перехват по ссылке сохраняет объект целым и полиморфным:
Здесь мы выбрасываем out_of_range, но перехватываем его как const exception&. Поскольку out_of_range наследуется от exception, обработчик базового класса подходит, а ссылка означает, что e.what() по-прежнему возвращает настоящее сообщение. Если бы вы написали catch (exception e) (по значению), объект был бы срезан до простого exception, и вы могли бы потерять конкретное сообщение.
Несколько блоков catch
За одним try может следовать несколько блоков catch, каждый для своего типа исключения. C++ проверяет их сверху вниз и выполняет первый подходящий - поэтому упорядочивайте их от наиболее специфичного к наиболее общему.
Поскольку invalid_argument более специфичен, чем exception, он должен идти первым. Если бы вы перевернули порядок и поставили catch (const exception&) сверху, он бы поглощал все исключения - расположенный ниже обработчик invalid_argument стал бы мёртвым кодом, который никогда не выполнится. Многие компиляторы предупреждают об этом, но язык вас не остановит.
catch (...) и повторный выброс
Иногда вам нужна страховка на всё, чего вы не предусмотрели. Универсальный обработчик catch (...) подходит к любому типу исключения, включая те, что не наследуются от std::exception (кто-то может написать throw 42; или throw "oops";).
Подвох в том, что вы не получаете объекта - нет никакого e для анализа. Поэтому catch (...) лучше всего использовать как последнее средство: залогировать, что что-то пошло не так, или очистить ресурсы и пробросить исключение дальше.
Чтобы пробросить текущее исключение дальше - передать его внешнему обработчику после локальной очистки или логирования - используйте голый throw; без операнда. Это сохраняет исходное исключение (его настоящий тип и сообщение), в отличие от throw e;, который пробросил бы срезанную копию:
Внутренний обработчик логирует и пробрасывает исключение; внешний обработчик в main затем разбирается с ним. Для этого используйте голый throw;, никогда не throw e;.
Раскрутка стека и RAII
Когда исключение распространяется наружу из блока try, C++ выполняет раскрутку стека (stack unwinding): у каждого локального объекта между throw и подходящим catch вызывается деструктор в порядке, обратном порядку конструирования. Именно это делает исключения безопасными - ресурсы, удерживаемые объектами на стеке, освобождаются автоматически.
Именно поэтому ресурсы следует держать в RAII-типах (таких как std::vector, std::string и умные указатели), а не в ручных new/delete. Посмотрите, что происходит, когда исключение пересекает ручное выделение памяти:
void leaky() {
int* buffer = new int[1000];
mightThrow(); // если это выбросит, следующая строка никогда не выполнится...
delete[] buffer; // ...и буфер утечёт
}
Поскольку throw перепрыгивает через delete[], память теряется. Умный указатель решает это бесплатно - его деструктор выполняется во время раскрутки:
void safe() {
auto buffer = std::make_unique<int[]>(1000);
mightThrow(); // если это выбросит, деструктор buffer всё равно освободит память
} // никакого ручного delete, никакой утечки, даже на пути исключения
Вывод: не пытайтесь перехватить исключение лишь для того, чтобы сделать delete. Пусть деструкторы занимаются очисткой, а catch оставьте для решений о том, как восстановиться.
Распространённые ошибки и подводные камни
Несколько ловушек встречаются снова и снова:
Не используйте исключения для обычного потока управления. Выброс и раскрутка намного медленнее простого if. Приберегите исключения для действительно исключительных, ошибочных ситуаций - а не для «пользователь ввёл пустую строку».
Пустой блок catch скрывает баги. Написать catch (...) {}, чтобы заглушить ошибку, означает, что сбои исчезают бесследно. Как минимум залогируйте проблему; обычно следует пробросить её дальше или обработать как положено.
Деструктор, который выбрасывает исключение, опасен. Если деструктор выбрасывает исключение во время раскрутки стека (когда другое исключение уже в полёте), программа вызывает std::terminate. В современном C++ деструкторы неявно noexcept - никогда не позволяйте исключению покинуть деструктор.
catch видит только то, что покрывает try. Исключение, выброшенное до входа в try, или в другой функции, которая не находится на пути вызова внутри него, здесь перехвачено не будет. catch защищает только код, выполняющийся внутри его собственного блока try (напрямую или в вызываемых им функциях).
Далее: неопределённое поведение
Исключения - это определённый способ, которым C++ сообщает вам, что что-то пошло не так: вы выбрасываете, вы перехватываете, поведение предсказуемо. Но у C++ есть и более тёмный угол, где язык не даёт вообще никаких гарантий: разыменование висячего указателя, чтение за пределами массива, переполнение знакового целого. Следующая страница посвящена неопределённому поведению: что его вызывает, почему оно может казаться «работающим» вплоть до момента, когда оно катастрофически отказывает, и как удержать его подальше от вашего кода.
Часто задаваемые вопросы
Как работает try-catch в C++?
Код, который может выбросить исключение, помещается внутрь блока try { }. Если исключение выброшено, программа перестаёт выполнять оставшуюся часть блока try и переходит к первому подходящему блоку catch, где вы обрабатываете ошибку. Если ничего не выброшено, блоки catch полностью пропускаются.
Почему в C++ исключения следует перехватывать по константной ссылке?
Перехват по ссылке (catch (const std::exception& e)) избегает копирования объекта исключения и, что особенно важно, сохраняет полиморфизм - так что производное исключение, перехваченное как его базовый тип, по-прежнему вызывает правильный what(). Перехват по значению (catch (std::exception e)) срезает производную часть (срезка) и может потерять информацию.
Как перехватить любое исключение в C++?
Используйте catch (...) - многоточие перехватывает любое исключение независимо от типа. Это полезный обработчик на крайний случай, но поскольку вы не получаете объекта для анализа, размещайте его после специфичных блоков catch и используйте в основном для логирования или повторного выброса.