Исключения: throw, try, catch
Разберитесь в механизме исключений C++ — раскрутка стека, стандартная иерархия исключений, пользовательские исключения и когда предпочтительнее исключения по сравнению с кодами ошибок.
Зачем существуют исключения
До исключений код на C сигнализировал об ошибках возвратом специальных значений (таких как -1 или NULL). У этого был критический недостаток: вызывающий код мог молча проигнорировать ошибку. Исключения решают эту проблему, заставляя программу либо обработать ошибку, либо завершиться. Они также разделяют место обнаружения ошибки (глубоко в библиотечном коде) и место обработки ошибки (в прикладном коде), которые могут находиться на расстоянии многих кадров стека.
Исключения C++ следуют модели «нулевой стоимости на пути успеха» на большинстве современных компиляторов (с использованием табличной раскрутки). Когда исключение не выбрасывается, нет накладных расходов во время выполнения — нет скрытых проверок if, нет промахов в предсказании переходов. Стоимость оплачивается только при фактическом выбрасывании исключения, что делает исключения идеальными для исключительных (редких) условий ошибки.
throw, try и catch
Основной механизм прост: throw выбрасывает объект для сигнализации об ошибке, рискованный код оборачивается в блок try, а соответствующие типы исключений перехватываются catch.
#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. Её понимание помогает выбрасывать правильный тип и перехватывать на нужном уровне.
// 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(), чтобы предоставить осмысленное сообщение.
#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. Что происходит с локальными переменными в каждом кадре стека во время раскрутки стека?