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

Гарантии безопасности исключений

Изучите три уровня безопасности исключений — базовый, строгий и без выбрасывания — и как RAII и copy-and-swap их обеспечивают.

Три гарантии

Безопасность исключений — это не предотвращение исключений, а определение того, что ваш код гарантирует при возникновении исключения. Каждая написанная вами функция неявно предоставляет один из этих уровней:

1. Гарантия без выбрасывания (самая сильная): Функция никогда не выбрасывает. Она всегда завершается успешно. Примеры: деструкторы, swap, простые геттеры. Помечается noexcept.

2. Строгая гарантия (фиксация или откат): Если функция выбрасывает, состояние программы точно такое же, как до вызова. Как если бы функция никогда не вызывалась. Представьте транзакцию базы данных, которая либо полностью фиксируется, либо полностью откатывается.

3. Базовая гарантия (минимально приемлемая): Если функция выбрасывает, программа находится в валидном, но неопределённом состоянии. Ресурсы не утекают, все инварианты выполняются, но точное состояние может отличаться от того, что было до вызова.

Существует также четвёртый, неприемлемый уровень: без гарантии — когда исключение может оставить программу в повреждённом состоянии с утечками ресурсов. Промышленный код никогда не должен предоставлять меньше базовой гарантии.

RAII: основа безопасности исключений

RAII (Resource Acquisition Is Initialization — получение ресурса есть инициализация) привязывает время жизни ресурса к времени жизни объекта. Конструктор получает ресурс; деструктор освобождает его. Поскольку деструкторы выполняются при раскрутке стека, RAII автоматически обеспечивает базовую гарантию.

raii_db.cpp
#include <fstream>
#include <string>
#include <stdexcept>
#include <iostream>

class DatabaseConnection {
public:
    explicit DatabaseConnection(const std::string& conn_str)
        : conn_str_(conn_str), connected_(false) {
        // Simulate connection
        if (conn_str.empty()) {
            throw std::invalid_argument("empty connection string");
        }
        connected_ = true;
        std::cout << "Connected to: " << conn_str_ << '\n';
    }

    ~DatabaseConnection() {
        if (connected_) {
            // Cleanup never throws
            std::cout << "Disconnected from: " << conn_str_ << '\n';
            connected_ = false;
        }
    }

    void execute(const std::string& query) {
        if (!connected_) throw std::runtime_error("not connected");
        // Even if this function throws, the destructor
        // will still close the connection during unwinding
        std::cout << "Executing: " << query << '\n';
    }

    // Non-copyable, movable
    DatabaseConnection(const DatabaseConnection&) = delete;
    DatabaseConnection& operator=(const DatabaseConnection&) = delete;
    DatabaseConnection(DatabaseConnection&& other) noexcept
        : conn_str_(std::move(other.conn_str_)),
          connected_(other.connected_) {
        other.connected_ = false;
    }

private:
    std::string conn_str_;
    bool connected_;
};

void process_data() {
    DatabaseConnection db("postgres://localhost/mydb");
    db.execute("SELECT * FROM users");
    // If execute() throws, ~DatabaseConnection() still runs
    // — the connection is always properly closed
}

Copy-and-swap для строгой гарантии

Идиома copy-and-swap обеспечивает строгую гарантию исключений для операторов присваивания. Идея: сначала создать копию (которая может выбросить), затем обменять с копией (что никогда не выбрасывает). Если копирование выбросит, исходный объект останется нетронутым.

copy_and_swap.cpp
#include <algorithm>
#include <utility>
#include <cstddef>

class Matrix {
public:
    Matrix(std::size_t rows, std::size_t cols)
        : rows_(rows), cols_(cols),
          data_(new double[rows * cols]{}) {}

    // Copy constructor — may throw (allocates memory)
    Matrix(const Matrix& other)
        : rows_(other.rows_), cols_(other.cols_),
          data_(new double[other.rows_ * other.cols_]) {
        std::copy(other.data_,
                  other.data_ + rows_ * cols_,
                  data_);
    }

    // Move constructor — never throws
    Matrix(Matrix&& other) noexcept
        : rows_(other.rows_), cols_(other.cols_),
          data_(other.data_) {
        other.data_ = nullptr;
        other.rows_ = other.cols_ = 0;
    }

    ~Matrix() { delete[] data_; }

    // No-throw swap — the key ingredient
    friend void swap(Matrix& a, Matrix& b) noexcept {
        using std::swap;
        swap(a.rows_, b.rows_);
        swap(a.cols_, b.cols_);
        swap(a.data_, b.data_);
    }

    // Copy-and-swap assignment: STRONG guarantee
    // Takes parameter by value (invokes copy/move ctor)
    Matrix& operator=(Matrix other) noexcept {
        swap(*this, other);
        return *this;
        // 'other' is destroyed here, freeing old data
    }

private:
    std::size_t rows_, cols_;
    double* data_;
};

noexcept и операции перемещения

STL в значительной степени полагается на noexcept для конструкторов перемещения. Рассмотрим std::vector::push_back: когда вектору нужна реаллокация, он должен перенести элементы из старого буфера в новый.

- Если конструктор перемещения элемента является noexcept, вектор перемещает элементы (быстро).
- Если конструктор перемещения может выбросить, вектор копирует элементы (медленно, но безопасно — если копирование выбросит, исходный буфер по-прежнему цел).

Вот почему пометка конструкторов перемещения как noexcept — это не просто хорошая практика, а потенциально 10-кратная или 100-кратная разница в производительности для типов, хранимых в контейнерах.

Вы можете проверить во время компиляции с помощью std::is_nothrow_move_constructible_v. STL использует этот трейт внутренне для выбора стратегии.

Определение уровня гарантии

При написании функции подумайте, какую гарантию она обеспечивает. Вот пример всех трёх уровней в одном классе.

guarantee_levels.cpp
#include <vector>
#include <string>
#include <algorithm>
#include <stdexcept>

class UserRegistry {
public:
    // NO-THROW: simple getter, can never fail
    std::size_t size() const noexcept {
        return users_.size();
    }

    // STRONG GUARANTEE: either the user is added or nothing changes
    void add_user(const std::string& name) {
        if (name.empty()) {
            throw std::invalid_argument("name cannot be empty");
        }
        // If push_back throws (e.g., bad_alloc during reallocation),
        // vector guarantees the original data is intact.
        users_.push_back(name);
    }

    // BASIC GUARANTEE: if an exception occurs partway through,
    // some users may have been processed and some may not.
    // But the object is still in a valid state (no leaks, no corruption).
    void normalize_all() {
        for (auto& user : users_) {
            // std::transform could theoretically throw
            // (e.g., if user string allocation fails during tolower)
            std::transform(user.begin(), user.end(), user.begin(),
                          [](unsigned char c) { return std::tolower(c); });
        }
    }

private:
    std::vector<std::string> users_;
};
Лучшая практика

Следуйте этим рекомендациям для написания последовательно безопасного кода с точки зрения исключений.

  • Используйте RAII повсюду: никогда не держите «сырые» ресурсы — оборачивайте их в умные указатели или RAII-обёртки
  • Помечайте конструкторы перемещения и swap как noexcept: это включает оптимизации STL и обеспечивает гарантию без выбрасывания
  • Выполняйте всю работу, которая может выбросить, до модификации состояния: сначала выделите память, вычислите и проверьте — затем зафиксируйте с помощью невыбрасывающих операций вроде swap
  • Предпочитайте идиому copy-and-swap для операторов присваивания, чтобы получить строгую гарантию с минимальными усилиями
  • Деструкторы никогда не должны выбрасывать: они неявно noexcept начиная с C++11
Подводный камень

Эти паттерны нарушают гарантии безопасности исключений и приводят к неочевидным ошибкам.

  • Модификация состояния до завершения всех выбрасывающих операций: если шаг 2 из 3 выбросит, шаг 1 уже изменил объект
  • Забвение того, что new может выбросить std::bad_alloc: каждое динамическое выделение — потенциальная точка выбрасывания
  • Не-noexcept конструкторы перемещения: STL молча откатится к копированию, убивая производительность
  • Слишком широкий перехват исключений: catch(...) без повторного выбрасывания скрывает реальную проблему
Ключевые выводы
  • Три гарантии: без выбрасывания (всегда успешно), строгая (фиксация или откат) и базовая (валидное, но неопределённое состояние)
  • RAII — основа: деструкторы выполняются при раскрутке, обеспечивая освобождение ресурсов
  • Copy-and-swap обеспечивает строгую гарантию: выполните всю выбрасывающую работу сначала, затем swap (который noexcept)
  • noexcept конструкторы перемещения критичны — без них std::vector откатывается к копированию при реаллокации
  • Каждая функция должна стремиться хотя бы к базовой гарантии; без выбрасывания — где возможно

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

(20 XP)

1. Что обещает строгая гарантия исключений?

2. Почему `std::vector` копирует элементы вместо перемещения, когда конструктор перемещения не является `noexcept`?

3. Почему в идиоме copy-and-swap оператор присваивания принимает параметр по значению?