انتقل إلى المحتوى

Exception Safety Guarantees

Learn the three levels of exception safety — basic, strong, and no-throw — and how RAII and copy-and-swap provide them.

The Three Guarantees

Exception safety is not about preventing exceptions — it is about defining what your code guarantees when an exception does occur. Every function you write implicitly provides one of these levels:

1. No-throw guarantee (strongest): The function never throws. It always succeeds. Examples: destructors,swap, simple getters. Markednoexcept.

2. Strong guarantee (commit-or-rollback): If the function throws, the program state is exactly as it was before the call. It is as if the function was never called. Think of a database transaction that either fully commits or fully rolls back.

3. Basic guarantee (minimum acceptable): If the function throws, the program is in a valid but unspecified state. No resources are leaked, all invariants hold, but the exact state may differ from what it was before.

There is also a fourth, unacceptable level: no guarantee — where an exception can leave the program in a corrupted state with leaked resources. Production code must never provide less than the basic guarantee.

RAII: The Foundation of Exception Safety

RAII (Resource Acquisition Is Initialization) binds a resource's lifetime to an object's lifetime. The constructor acquires the resource; the destructor releases it. Since destructors run during stack unwinding, RAII provides the basic guarantee automatically.

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 for the Strong Guarantee

The copy-and-swap idiom provides the strong exception guarantee for assignment operators. The idea: make a copy first (which may throw), then swap with the copy (which never throws). If the copy throws, the original object is untouched.

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 and Move Operations

The STL relies heavily onnoexceptfor move constructors. Considerstd::vector::push_back: when the vector must reallocate, it needs to transfer elements from the old buffer to the new one.

- If the element's move constructor isnoexcept, the vector moves elements (fast).
- If the move constructor might throw, the vector copies elements instead (slow, but safe — if a copy throws, the original buffer is still intact).

This is why marking move constructorsnoexceptis not just good practice — it can be a 10x or 100x performance difference for types stored in containers.

You can check at compile time withstd::is_nothrow_move_constructible_v. The STL uses this trait internally to decide its strategy.

Identifying the Guarantee Level

عند كتابة دالة، فكر في الضمان الذي توفره. فيما يلي مثال لجميع المستويات الثلاثة في فصل واحد.

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
  • وضع علامة على مُنشئات النقل وتبديلnoexcept: يؤدي ذلك إلى تمكين تحسينات STL ويوفر ضمان عدم الرمي
  • Do all the work that can throw before modifying state: Allocate, compute, and validate first — then commit with no-throw operations likeswap
  • Prefer the copy-and-swap idiom for assignment operators to get the strong guarantee with minimal effort
  • Destructors must never throw: They are implicitlynoexceptin C++11 and later
خطأ شائع

These patterns break exception safety guarantees and lead to subtle bugs.

  • Modifying state before completing all throwing operations: If step 2 of 3 throws, step 1 already changed the object
  • Forgetting thatnewcan throwstd::bad_alloc: Every dynamic allocation is a potential throw point
  • Non-noexceptmove constructors: The STL will silently fall back to copying, killing performance
  • Catching exceptions too broadly:catch(...)without rethrowing hides the real problem
النقاط الرئيسية
  • The three guarantees are: no-throw (always succeeds), strong (commit-or-rollback), and basic (valid but unspecified state)
  • RAII is the foundation — destructors run during unwinding, ensuring resources are always freed
  • Copy-and-swap gives the strong guarantee: do all throwing work first, thenswap(which isnoexcept)
  • noexceptmove constructors are critical — without them,std::vectorfalls back to copying on reallocation
  • Every function should aim for at least the basic guarantee; no-throw where possible

اختبار — اختبر معرفتك

(20 نقطة خبرة)

1. What does the strong exception guarantee promise?

2. لماذا يقوم`std::vector`بنسخ العناصر بدلاً من نقلها عندما لا يكون مُنشئ النقل`noexcept`؟

3. في لغة النسخ والتبديل، لماذا يأخذ عامل التعيين المعلمة الخاصة به حسب القيمة؟