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. Marked noexcept.
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.
#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.
#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 on noexcept for move constructors. Consider std::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 is noexcept, 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 constructors noexcept is not just good practice — it can be a 10x or 100x performance difference for types stored in containers.
You can check at compile time with std::is_nothrow_move_constructible_v. The STL uses this trait internally to decide its strategy.
Identifying the Guarantee Level
When writing a function, think about which guarantee it provides. Here is an example of all three levels in a single class.
#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_;
};Follow these guidelines to write exception-safe code consistently.
- Use RAII everywhere: Never hold raw resources — wrap them in smart pointers or RAII wrappers
- Mark move constructors and swap
noexcept: This enables STL optimizations and provides the no-throw guarantee - Do all the work that can throw before modifying state: Allocate, compute, and validate first — then commit with no-throw operations like
swap - Prefer the copy-and-swap idiom for assignment operators to get the strong guarantee with minimal effort
- Destructors must never throw: They are implicitly
noexceptin 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 that
newcan 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, then
swap(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
Quiz — Test Your Knowledge
(20 XP)1. What does the strong exception guarantee promise?
2. Why does `std::vector` copy elements instead of moving them when the move constructor is not `noexcept`?
3. In the copy-and-swap idiom, why does the assignment operator take its parameter by value?