Синтаксис си/с++

Исключения: try/catch/throw, иерархия std::exception

Введение

Статья 524 кратко упомянула std::bad_alloc, выбрасываемое new при неудачном выделении памяти — в этой статье разберём механизм исключений C++ систематически: синтаксис try/catch/throw, стандартную иерархию std::exception, и как исключения взаимодействуют с RAII (статья 532/552) через раскрутку стека.

Концепция

throw инициирует процесс обработки исключения, передавая объект (обычно производный от std::exception по сложившемуся соглашению, хотя технически можно выбросить объект произвольного типа) — выполнение немедленно прерывается в текущей точке и начинается поиск подходящего catch-блока, способного обработать исключение этого типа, «разматывая» стек вызовов функций (вызывая деструкторы всех локальных объектов на этом пути, статья 525/532 — именно это и обеспечивает корректность RAII даже при исключениях) до тех пор, пока подходящий catch не будет найден (или, если такой блок не найден ни на одном уровне стека вызовов до самого main(), программа аварийно завершается через std::terminate()). std::exception — базовый класс стандартной иерархии исключений, с производными классами (std::runtime_error, std::logic_error, std::out_of_range, статья 535 уже использовала последний) для разных категорий типичных ошибочных ситуаций, и собственные, пользовательские исключения обычно наследуются от подходящего класса этой иерархии для совместимости с кодом, перехватывающим исключения через базовый тип std::exception&.

Пример кода: Исключения

#include <iostream>
#include <stdexcept>

double safeDivide(double a, double b)
{
    if (b == 0.0) {
        throw std::invalid_argument("Деление на ноль недопустимо"); // выбрасывание исключения с сообщением
    }
    return a / b;
}

int main()
{
    try {
        std::cout << safeDivide(10.0, 2.0) << std::endl; // 5 — нормальное выполнение, исключение не возникает
        std::cout << safeDivide(10.0, 0.0) << std::endl; // выбрасывает исключение — следующая строка НЕ выполнится
        std::cout << "Эта строка никогда не выполнится" << std::endl;
    } catch (const std::invalid_argument &e) { // перехват ИМЕННО этого конкретного типа исключения
        std::cout << "Ошибка: " << e.what() << std::endl; // what() — стандартный метод получения сообщения
    }

    return 0;
}
// Иерархия исключений и порядок catch-блоков — от более конкретного к более общему
#include <iostream>
#include <stdexcept>

void demonstrateExceptionHierarchy()
{
    try {
        throw std::out_of_range("Индекс вне диапазона"); // std::out_of_range наследует от std::logic_error
    } catch (const std::out_of_range &e) { // НАИБОЛЕЕ конкретный тип — проверяется ПЕРВЫМ
        std::cout << "Конкретная обработка out_of_range: " << e.what() << std::endl;
    } catch (const std::logic_error &e) { // более общий — сработал бы, если бы первый catch НЕ совпал
        std::cout << "Общая обработка logic_error: " << e.what() << std::endl;
    } catch (const std::exception &e) { // самый общий — "запасной вариант" для ЛЮБОГО std::exception
        std::cout << "Самая общая обработка: " << e.what() << std::endl;
    }
}
// Пользовательское исключение, наследующее от стандартной иерархии
#include <stdexcept>
#include <string>

class InsufficientFundsException : public std::runtime_error {
public:
    InsufficientFundsException(double requested, double available)
        : std::runtime_error("Недостаточно средств: запрошено " + std::to_string(requested)
                              + ", доступно " + std::to_string(available)) {}
};

void withdraw(double amount, double balance)
{
    if (amount > balance) {
        throw InsufficientFundsException(amount, balance);
    }
}

Пояснения к коду

safeDivide показывает базовый паттерн — throw std::invalid_argument(...) немедленно прерывает обычное выполнение функции, и единственный способ «продолжить» нормальную работу программы — перехватить это исключение в соответствующем catch-блоке где-то выше по стеку вызовов, что демонстрируется тем, что строка после вызова с делением на ноль никогда не выполняется. Демонстрация иерархии исключений показывает важный, обязательный порядок catch-блоков — компилятор проверяет их последовательно сверху вниз, и поскольку std::out_of_range является производным классом std::logic_error, который в свою очередь производный от std::exception, для корректной, предполагаемой работы более специфичные catch-блоки должны располагаться ВЫШЕ (проверяться раньше) более общих, иначе более общий блок «перехватит» исключение первым, и более специфичный блок ниже никогда не сработает вовсе (что для некоторых компиляторов даже может быть явной ошибкой компиляции о недостижимом коде). InsufficientFundsException, наследующий от std::runtime_error, показывает идиоматичный способ создания пользовательских исключений — наследование от подходящего стандартного класса даёт совместимость с кодом, перехватывающим исключения через std::exception& (или std::runtime_error&), и автоматически предоставляет работающий метод what() через переданное в конструктор базового класса сообщение.

Подводные камни

  • Неправильный порядок catch-блоков (более общий тип исключения перед более специфичным производным типом) — как явно показано в пояснении, это приводит к тому, что специфичный catch-блок ниже по списку никогда не будет достигнут, поскольку более общий блок выше «перехватывает» все подходящие исключения первым, и порядок блоков от наиболее специфичного к наиболее общему — обязательное, не опциональное правило для корректной работы множественных catch-блоков для одного try.
  • Перехват исключения по значению вместо ссылки (catch (std::exception e) вместо catch (const std::exception &e)) — перехват по значению создаёт КОПИЮ объекта исключения, что не только менее эффективно, но и, что критичнее, для полиморфных исключений (производный класс, пойманный через базовый тип) приводит к «срезанию» (slicing) — копия будет иметь статический тип базового класса, теряя любую дополнительную информацию производного класса, тогда как перехват по константной ссылке сохраняет полный, реальный полиморфный тип объекта исключения.
  • Выбрасывание исключений не для действительно исключительных, ошибочных ситуаций, а для обычного, ожидаемого потока управления — исключения предназначены именно для представления реально нештатных, ошибочных ситуаций (статья основного цикла про обработку ошибок), и использование их для обычной, ожидаемой логики ветвления программы (как альтернатива обычным if/return) не только концептуально неуместно, но и менее эффективно, поскольку механизм исключений в большинстве реализаций C++ имеет заметные накладные расходы именно в момент выбрасывания исключения (хотя обычно не в «нормальном» пути выполнения без исключений).
  • Игнорирование того, что некоторые операции (особенно деструкторы, статья 532) не должны выбрасывать исключения вовсе, и попытка выбросить исключение из такого контекста (особенно во время уже происходящей раскрутки стека из-за другого исключения) приводит к немедленному, безусловному завершению программы через std::terminate() без какой-либо возможности дальнейшей обработки этой ситуации — код должен быть спроектирован с явным пониманием, какие операции (деструкторы, обработчики сигналов, некоторые специфичные callback-функции) накладывают это ограничение на отсутствие исключений, и соответствующим образом гарантировать, что такие операции действительно никогда не выбрасывают исключения, даже в редких, граничных случаях внутренней ошибки.

Третий блок цикла «Углублённый цикл синтаксиса C/C++» (статьи 531-540, C++ и ООП) завершён. Далее следует блок 4 — шаблоны и современный C++ (541-550).