Лекция 5. Обработка ошибок
Какие ошибки могут возникать (делаем что-то некорректное — не потому что мы криворукие, а потому что внешний мир мог не дать):
- выход за границу массива;
- деление на ноль;
- невозможность выделить память;
- отсутствие прав на открытие файла;
- недоступность внешнего сервера;
- …
Варианты обработки
Обычный assert (runtime)
Если проверка ложна — программа просто падает.
#include <cassert>
int main() {
assert(2 + 2 == 4);
assert(2 + 2 == 5);
return 0;
}
// Assertion '2+2==5' failed.
static_assert (compile-time)
Помогает выяснить разные факты про типы прямо при компиляции.
// Пример: есть ли у произвольного типа T дефолтный конструктор
static_assert(sizeof(int) == 4, "int must be 4 bytes");
template <typename T>
struct data_structure {
static_assert(
std::is_default_constructible<T>::value,
"Data Structure requires default-constructible elements"
);
};
struct no_default {
no_default() = delete;
};
int main() {
data_structure<no_default> ds_error; // ошибка компиляции
return 0;
}
Код возврата
Получаем число/значение ошибки, потом перебираем варианты:
Просто возвращаемое значение. Например, количество успешно записанных байт:
size_t fwrite(const void* buffer, size_t size, size_t count, FILE* stream);errno— глобальный макрос, в который записывается код ошибки, который потом можно перевести в текст.- Ошибок очень много, тяжело учесть все.
- Это глобальная переменная — следующая ошибка затрёт предыдущую.
FILE* fopen(const char* filename, const char* mode);Возвращает указатель на
FILEилиnullptr(нет прав / файл не существует / занят другой программой); подробности черезerrno.Ошибка как код возврата (
errno_t). Само значение возвращается через out-параметр:errno_t fopen_s( FILE* restrict* restrict streamptr, const char* restrict filename, const char* restrict mode );
Обработка в месте возврата
Куча if-ов на возврат значений — неудобно. На просмотр всех этих if-ов уходит больше сил, чем на сам код. Бизнес-логика тонет в обработке ошибок.
Exceptions: throw / try / catch
int foo() {
throw std::runtime_error("error");
}
void boo() {
throw 2;
}
void coo() {
throw std::string("Hello world");
}
int main(int, char**) {
try {
foo();
}
catch(...) {
// do something...
}
}
throw— сообщает об исключительной ситуации.try— в коде внутри блока пытаемся найти исключения.catch— обрабатываем поднятые вtryисключения.- После
throwзапускается механизм stack unwinding («раскрутка стека»).
Stack unwinding (по шагам)
- Сконструированный в
throwобъект-исключение пробрасывается обратно по стеку. - Стек раскручивается до первого подходящего
catch(если такого нет — аварийное завершение черезstd::terminate). - По пути уничтожаются все объекты с automatic storage duration (вызываются их деструкторы) — это то, ради чего нужен RAII.
- Если в процессе раскрутки возникает ещё одно исключение — вызывается
std::terminate(принудительное завершение программы). - Поэтому деструкторы по умолчанию
noexcept— нельзя кидать из деструктора при раскрутке. - Сам объект-исключение хранится в специальной (неопределённой реализацией) области памяти.
struct Foo {
Foo() { std::cout << "Foo()\n"; }
~Foo() { std::cout << "~Foo()\n"; }
};
void internalFunc() {
Foo f;
throw std::runtime_error("Some error");
}
void externalFunc() {
try { internalFunc(); }
catch (const std::exception& e) { std::cout << e.what() << std::endl; }
}
Вывод:
Foo()
~Foo()
Some error
Объяснение:
main→externalFunc→internalFunc→ бросилиthrow.- Раскручиваем стек, по пути вызывается деструктор
f. - Находим блок
try/catchвexternalFuncи обрабатываем. - Можно обрабатывать не в месте возникновения, а где удобно — главное где-то выше по стеку. Это и есть главное преимущество исключений: бизнес-логика не загромождается проверками.
Очень важно: именно поэтому нужен RAII. Если мы вручную делали
new, то послеthrowмы пролетим мимоdelete— получим утечку.void internalFunc() { Foo* f = new Foo; throw std::runtime_error("Some error"); // утечка! delete f; // никогда не выполнится }
Несколько catch-блоков
int main() {
try {
foo();
} catch (const std::overflow_error& e) {
// do something
} catch (const std::runtime_error& e) {
// do something
} catch (const std::exception& e) {
// do something
} catch (...) {
// do something
}
}
- Ищем первый подходящий
catch-блок (тип брошенного исключения должен приводиться к типу пойманного — в т.ч. через наследование). - Гарантированно попадаем только в один
catch-блок. - Если ни один не подошёл —
terminating due to uncaught exception. - Если подходят несколько — берётся первый в порядке записи.
- Порядок важен: от самого конкретного (производного класса) к самому общему (базового). Иначе более общий перехватит всё первым.
catch(...)ловит что угодно, в т.ч. не-исключения (например,throw 2). Полезен как «последний шанс», но плох тем, что не знаем, что именно случилось.- Передача в
catch— как в функцию. Стандарт: поconst&— чтобы не было лишних копий.
Гарантии безопасности исключений
- No guarantee — никаких гарантий, объект может остаться в произвольном состоянии.
- Basic guarantee — инвариант сохраняется, утечек нет, но точное состояние может быть произвольным.
- Strong guarantee — либо операция полностью успешна, либо состояние возвращается к тому, что было до вызова (как будто операции не было). Никаких утечек.
- Nothrow guarantee — функция не бросает исключений вообще.
Пример: в наивном operator= после исключения в конструкторе копирования объект остаётся в полусобранном состоянии — это basic-гарантия (nullptr — нормальный инвариант), но не strong. Для strong-гарантии используется Copy-and-swap idiom:
- Делаем копию принимаемого объекта (если конструктор копирования бросит — старый объект цел).
- Свапаем поля копии и текущего объекта (своп —
noexcept). - Копия с «нашими старыми данными» уничтожается на выходе из функции.
noexcept
Ключевое слово — гарантия, что функция не бросает исключений.
- Если она всё-таки бросит —
std::terminate. - Не нужна разворотная инфраструктура — компилятор лучше оптимизирует код.
- Деструкторы —
noexceptпо умолчанию. - В обмен на это компилятор не генерирует машинный код для раскрутки стека из этой функции.
- Все STL-контейнеры дают гарантии на свои методы (часто — strong).
P.S. Когда объявление сопровождается инициализацией — вызывается конструктор копирования (
Boo b1 = b;), а когда переменная уже существовала — оператор присваивания (b1 = b;).