Menu

try-catch в C++: правильная обработка исключений

Оберните рискованный код в try, реагируйте в catch. Научитесь перехватывать исключения по константной ссылке, упорядочивать несколько обработчиков, использовать catch (...) и пробрасывать исключения дальше - без утечки ресурсов.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

От выброса к обработке

На предыдущей странице вы узнали, как выбросить (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 и используйте в основном для логирования или повторного выброса.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ