Перейти к содержимому

Исключения: throw, try, catch

Разберитесь в механизме исключений C++ — раскрутка стека, стандартная иерархия исключений, пользовательские исключения и когда предпочтительнее исключения по сравнению с кодами ошибок.

Зачем существуют исключения

До исключений код на C сигнализировал об ошибках возвратом специальных значений (таких как -1 или NULL). У этого был критический недостаток: вызывающий код мог молча проигнорировать ошибку. Исключения решают эту проблему, заставляя программу либо обработать ошибку, либо завершиться. Они также разделяют место обнаружения ошибки (глубоко в библиотечном коде) и место обработки ошибки (в прикладном коде), которые могут находиться на расстоянии многих кадров стека.

Исключения C++ следуют модели «нулевой стоимости на пути успеха» на большинстве современных компиляторов (с использованием табличной раскрутки). Когда исключение не выбрасывается, нет накладных расходов во время выполнения — нет скрытых проверок if, нет промахов в предсказании переходов. Стоимость оплачивается только при фактическом выбрасывании исключения, что делает исключения идеальными для исключительных (редких) условий ошибки.

throw, try и catch

Основной механизм прост: throw выбрасывает объект для сигнализации об ошибке, рискованный код оборачивается в блок try, а соответствующие типы исключений перехватываются catch.

divide.cpp
#include <iostream>
#include <stdexcept>
#include <string>

double divide(double numerator, double denominator) {
    if (denominator == 0.0) {
        throw std::invalid_argument("denominator cannot be zero");
    }
    return numerator / denominator;
}

int main() {
    try {
        double result = divide(10.0, 0.0);
        std::cout << "Result: " << result << '\n';
    } catch (const std::invalid_argument& e) {
        std::cerr << "Invalid argument: " << e.what() << '\n';
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << '\n';
    } catch (...) {
        std::cerr << "Unknown error occurred\n";
    }
}

Раскрутка стека в деталях

При выбрасывании исключения среда выполнения осуществляет раскрутку стека: она проходит вверх по стеку вызовов, уничтожая все локальные переменные в каждом кадре путём вызова их деструкторов. Вот почему RAII (Получение ресурса есть инициализация) так важно — если вы храните ресурсы в RAII-обёртках, они автоматически освобождаются при раскрутке.

Процесс раскрутки работает следующим образом:

1. Выражение throw создаёт объект исключения (обычно в специальной области памяти, не на стеке).
2. Среда выполнения ищет соответствующий обработчик catch в текущей функции.
3. Если таковой не найден, она выходит из текущей функции, уничтожая все локальные объекты в обратном порядке конструирования.
4. Шаги 2-3 повторяются вверх по стеку вызовов.
5. Если ни в одном кадре обработчик не найден, вызывается std::terminate().

Ключевое правило: выбрасывайте по значению, ловите по const-ссылке. Выбрасывание по значению предотвращает висячие указатели. Перехват по const-ссылке предотвращает срезку (когда производное исключение копируется в объект базового класса, теряя данные производного) и избегает ненужных копий.

Стандартная иерархия исключений

Стандартная библиотека предоставляет иерархию с корнем std::exception. Её понимание помогает выбрасывать правильный тип и перехватывать на нужном уровне.

hierarchy.cpp
// Standard exception hierarchy (simplified)
//
// std::exception
// ├── std::logic_error          (programmer mistakes - precondition violations)
// │   ├── std::invalid_argument
// │   ├── std::domain_error
// │   ├── std::length_error
// │   └── std::out_of_range
// ├── std::runtime_error        (errors detectable only at runtime)
// │   ├── std::range_error
// │   ├── std::overflow_error
// │   ├── std::underflow_error
// │   └── std::system_error     (OS-level errors with error_code)
// └── std::bad_alloc            (memory allocation failure)
//     std::bad_cast              (failed dynamic_cast)
//     std::bad_typeid            (typeid on null pointer)

#include <stdexcept>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3};
    try {
        // .at() throws std::out_of_range, unlike operator[] which is UB
        int val = v.at(10);
    } catch (const std::out_of_range& e) {
        std::cerr << "Out of range: " << e.what() << '\n';
    }
}

Пользовательские классы исключений

Наследуйте от std::exception или одного из его подклассов для создания доменных исключений. Всегда переопределяйте what(), чтобы предоставить осмысленное сообщение.

custom_exception.cpp
#include <stdexcept>
#include <string>
#include <iostream>

class FileParseError : public std::runtime_error {
public:
    FileParseError(const std::string& filename, int line, const std::string& msg)
        : std::runtime_error(
              "Parse error in '" + filename + "' at line " +
              std::to_string(line) + ": " + msg),
          filename_(filename),
          line_(line) {}

    const std::string& filename() const noexcept { return filename_; }
    int line() const noexcept { return line_; }

private:
    std::string filename_;
    int line_;
};

void parse_config(const std::string& filename) {
    // Simulate a parse failure at line 42
    throw FileParseError(filename, 42, "unexpected token '@@'");
}

int main() {
    try {
        parse_config("app.conf");
    } catch (const FileParseError& e) {
        std::cerr << e.what() << '\n';
        std::cerr << "  File: " << e.filename()
                  << ", Line: " << e.line() << '\n';
    }
}

noexcept и спецификации исключений

Спецификатор noexcept сообщает компилятору (и человеку), что функция никогда не выбросит исключение. Если noexcept-функция всё же выбросит, немедленно вызывается std::terminate() — раскрутки стека не происходит.

Почему noexcept важен?

- Производительность: компилятор может опустить таблицы раскрутки для noexcept-функций, генерируя более компактный код.
- Оптимизация STL: контейнеры вроде std::vector будут перемещать элементы при реаллокации только если конструктор перемещения является noexcept. В противном случае они откатываются к копированию для поддержания строгой гарантии исключений. Это может быть драматическое различие в производительности.
- Документация: это контракт с вызывающим кодом о том, что функция не может завершиться ошибкой.

Используйте noexcept для: конструкторов перемещения, операторов перемещающего присваивания, swap-функций, деструкторов (неявно noexcept), простых геттеров и операторов сравнения.

Старая спецификация throw() (и throw(type-list)) была объявлена устаревшей в C++11 и удалена в C++20. Всегда используйте noexcept.

Подводный камень

Никогда не выбрасывайте исключения в деструкторах. Во время раскрутки стека от другого исключения выброс второго исключения вызывает std::terminate(). Если деструктор должен выполнить операцию, которая может завершиться ошибкой, перехватите исключение внутри деструктора и обработайте его на месте (например, запишите в лог и подавите).

  • Перехват по значению срезает производные исключения — всегда перехватывайте по const&
  • Выбрасывание указателей (например, throw new std::runtime_error(...)) приводит к утечкам памяти — выбрасывайте по значению
  • Перехват ... первым скрывает все специфические обработчики ниже — ставьте его последним
  • Исключения в деструкторах во время раскрутки стека вызывают std::terminate()
  • Использование исключений для управления потоком (в неаварийных путях) нарушает модель нулевой стоимости и ухудшает производительность
Ключевые выводы
  • Выбрасывайте по значению, перехватывайте по const-ссылке, чтобы избежать срезки и утечек
  • Раскрутка стека уничтожает локальные объекты в обратном порядке конструирования — RAII обеспечивает очистку
  • Исключения C++ имеют нулевую стоимость на пути успеха (табличная раскрутка), затраты возникают только при выбрасывании
  • noexcept позволяет оптимизации компилятора и критичен для операций перемещения, используемых STL
  • Наследуйте пользовательские исключения от std::runtime_error или std::logic_error и переопределяйте what()

Тест — проверьте свои знания

(15 XP)

1. Почему следует перехватывать исключения по `const&`, а не по значению?

2. Что происходит, если функция, помеченная `noexcept`, выбрасывает исключение?

3. Что происходит с локальными переменными в каждом кадре стека во время раскрутки стека?