Лекция 5

05.03.2025 Обновлено: 05.03.2025

Лекция 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 (по шагам)

  1. Сконструированный в throw объект-исключение пробрасывается обратно по стеку.
  2. Стек раскручивается до первого подходящего catch (если такого нет — аварийное завершение через std::terminate).
  3. По пути уничтожаются все объекты с automatic storage duration (вызываются их деструкторы) — это то, ради чего нужен RAII.
  4. Если в процессе раскрутки возникает ещё одно исключение — вызывается std::terminate (принудительное завершение программы).
  5. Поэтому деструкторы по умолчанию noexcept — нельзя кидать из деструктора при раскрутке.
  6. Сам объект-исключение хранится в специальной (неопределённой реализацией) области памяти.
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

Объяснение:

  • mainexternalFuncinternalFunc → бросили 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:

  1. Делаем копию принимаемого объекта (если конструктор копирования бросит — старый объект цел).
  2. Свапаем поля копии и текущего объекта (своп — noexcept).
  3. Копия с «нашими старыми данными» уничтожается на выходе из функции.

noexcept

Ключевое слово — гарантия, что функция не бросает исключений.

  • Если она всё-таки бросит — std::terminate.
  • Не нужна разворотная инфраструктура — компилятор лучше оптимизирует код.
  • Деструкторы — noexcept по умолчанию.
  • В обмен на это компилятор не генерирует машинный код для раскрутки стека из этой функции.
  • Все STL-контейнеры дают гарантии на свои методы (часто — strong).

P.S. Когда объявление сопровождается инициализацией — вызывается конструктор копирования (Boo b1 = b;), а когда переменная уже существовала — оператор присваивания (b1 = b;).