Exception safety is about what guarantees your code provides when an exception is thrown. It’s not about preventing exceptions — it’s about maintaining program correctness despite exceptions. This is one of the most important and most misunderstood topics in C++.
The concept was formalized by Dave Abrahams in the late 1990s during the standardization of the STL. His work defined the vocabulary we still use today.
The function may leak resources, violate invariants, or corrupt data if an exception is thrown. This is a bug, not a design choice.
void bad_transfer(Account& from, Account& to, int amount) {
from.balance -= amount; // Step 1: debit
// If this throws, 'from' is debited but 'to' never credited!
to.balance += amount; // Step 2: credit — may throw
}
If an exception is thrown: - All invariants are preserved - No resources are leaked - Objects are in a valid but unspecified state
This is the minimum acceptable level for production code.
void basic_transfer(Account& from, Account& to, int amount) {
std::lock_guard lock(mutex_);
from.withdraw(amount); // may throw, but Account stays valid
try {
to.deposit(amount); // may throw
} catch (...) {
from.deposit(amount); // rollback — basic guarantee
throw;
}
}
If an exception is thrown, the program state is exactly as it was before the function was called. Either the operation fully succeeds, or it’s as if it never happened.
This is the copy-and-swap level.
void strong_transfer(Account& from, Account& to, int amount) {
Account from_copy = from; // work on copies
Account to_copy = to;
from_copy.withdraw(amount);
to_copy.deposit(amount);
// Only now commit — swap is noexcept
swap(from, from_copy); // noexcept
swap(to, to_copy); // noexcept
}
noexcept)The operation never throws an exception. Period. If something goes wrong internally, it handles it without propagating an exception.
void nothrow_swap(Account& a, Account& b) noexcept {
std::swap(a.balance_, b.balance_); // primitive swap, can't throw
}
Functions that must be noexcept:
- Destructors (implicitly noexcept since C++11)
- Move constructors and move assignment (for container efficiency)
- swap() functions
- Deallocation functions (operator delete)
noexcept SpecifierMark a function noexcept when:
1. It genuinely cannot throw (e.g., swapping integers)
2. You want the compiler to assume it won’t throw (move operations!)
3. Failure should terminate rather than propagate
// Unconditional noexcept
void swap(MyClass& other) noexcept;
// Conditional noexcept — noexcept if T's move ctor is noexcept
template<typename T>
void Container<T>::swap(Container& other) noexcept(
std::is_nothrow_move_constructible_v<T>
);
The compiler can optimize noexcept functions more aggressively:
- No need to maintain stack unwinding information
- Move operations used instead of copies in containers
- std::vector::push_back will COPY instead of MOVE if the move ctor isn’t noexcept
noexcept OperatorThe noexcept() operator is a compile-time check:
static_assert(noexcept(std::declval<int&>() = 5)); // int assignment is noexcept
// Conditional noexcept using the operator
template<typename T>
void wrapper(T& obj) noexcept(noexcept(obj.do_thing())) {
obj.do_thing();
}
The classic technique for providing the strong guarantee on assignment:
class Widget {
int* data_;
size_t size_;
public:
// Copy constructor — may throw (allocates)
Widget(const Widget& other)
: data_(new int[other.size_])
, size_(other.size_)
{
std::copy(other.data_, other.data_ + size_, data_);
}
// Swap — must be noexcept
friend void swap(Widget& a, Widget& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
swap(a.size_, b.size_);
}
// Assignment via copy-and-swap — strong guarantee
Widget& operator=(Widget other) { // note: pass by VALUE (copy happens here)
swap(*this, other); // noexcept swap
return *this; // old data destroyed when 'other' dies
}
};
Why the naive approach fails:
// WRONG — not exception safe!
Widget& operator=(const Widget& other) {
delete[] data_; // point of no return!
data_ = new int[other.size_]; // THROWS? data_ is dangling!
std::copy(...);
size_ = other.size_;
return *this;
}
Resource Acquisition Is Initialization — every resource is owned by an object whose destructor releases it. This is THE mechanism that makes C++ exception safety work.
// BAD: manual resource management
void unsafe() {
FILE* f = fopen("data.txt", "r");
int* buf = new int[1024];
process(f, buf); // if this throws, f and buf leak!
delete[] buf;
fclose(f);
}
// GOOD: RAII wrappers
void safe() {
auto f = std::unique_ptr<FILE, decltype(&fclose)>(
fopen("data.txt", "r"), &fclose);
auto buf = std::make_unique<int[]>(1024);
process(f.get(), buf.get()); // if this throws, destructors clean up
}
std::uncaught_exceptions() and Scope GuardsC++17 added std::uncaught_exceptions() (note: plural!) which returns the
number of uncaught exceptions currently in flight. This enables scope guards
that behave differently on success vs failure.
class ScopeFailure {
int exceptions_on_entry_;
std::function<void()> rollback_;
public:
ScopeFailure(std::function<void()> fn)
: exceptions_on_entry_(std::uncaught_exceptions())
, rollback_(std::move(fn)) {}
~ScopeFailure() {
// Only call rollback if we're unwinding due to a NEW exception
if (std::uncaught_exceptions() > exceptions_on_entry_) {
rollback_();
}
}
};
The fully-constructed-subobject rule: If a constructor throws, destructors are called for all fully constructed base classes and members (in reverse order of construction), but NOT for the object being constructed (its destructor won’t run).
class Composite {
ResourceA a_; // constructed first
ResourceB b_; // constructed second
ResourceC c_; // constructed third — THROWS!
public:
Composite()
: a_() // constructed, will be destroyed
, b_() // constructed, will be destroyed
, c_() // THROWS — b_ then a_ destructors called
{} // ~Composite() is NOT called
};
This is why RAII members are essential — raw pointers in members won’t be cleaned up if a later member’s construction throws.
push_back Provides the Strong Guaranteestd::vector::push_back either succeeds or leaves the vector unchanged:
Critical: This only works efficiently if the move constructor is noexcept.
Otherwise, vector must COPY elements during reallocation (because if a move
throws halfway through, the old buffer is already partially moved-from).
std::move_if_noexcepttemplate<typename T>
auto move_if_noexcept(T& x) noexcept {
// Returns T&& if T's move ctor is noexcept, otherwise const T&
if constexpr (std::is_nothrow_move_constructible_v<T>) {
return std::move(x);
} else {
return x; // returns lvalue — will copy
}
}
This is what std::vector uses internally during reallocation. If your move
constructor isn’t noexcept, your “fast moves” silently become “slow copies”.
1. Do all the work that might throw (on temporaries/copies)
2. Commit the results using only noexcept operations (swap, pointer assignment)
This is the generalized form of copy-and-swap.
When multiple objects must be updated atomically:
void atomic_update(A& a, B& b, C& c) {
A a_new = compute_new_a(a); // may throw — a unchanged
B b_new = compute_new_b(b); // may throw — a,b unchanged
C c_new = compute_new_c(c); // may throw — a,b,c unchanged
// COMMIT PHASE — all noexcept
swap(a, a_new);
swap(b, b_new);
swap(c, c_new);
}
Exceptions and threads interact dangerously:
std::thread that isn’t caught calls std::terminatestd::promise/std::future to transport exceptions across threadsstd::shared_mutex with RAII locks provides exception-safe concurrent accessvoid thread_work(std::promise<int>& p) {
try {
p.set_value(compute()); // success
} catch (...) {
p.set_exception(std::current_exception()); // propagate to caller
}
}
-fno-exceptionsThe warehouse robot firmware (running on Zephyr RTOS) compiles with -fno-exceptions
because:
Deterministic timing: Exception handling adds unpredictable latency. A motor controller ISR must complete in microseconds — stack unwinding is not acceptable.
Code size: Exception tables and unwind info add significant binary size on embedded ARM Cortex-M targets.
No heap: Many firmware modules run without dynamic allocation.
std::bad_alloc doesn’t make sense.
What replaces exceptions in firmware?
- Return codes (often wrapped in Result<T, Error> types)
- Error callback / handler registration
- assert() / __builtin_trap() for unrecoverable errors
- std::expected<T, E> (C++23, or backported)
- Safety-Monitor watchdog: if a subsystem fails, the watchdog reboots it
This is a valid engineering tradeoff — not a rejection of exception safety principles. The same guarantee levels apply, just enforced through different mechanisms.
try { throw DerivedError("oops"); }
catch (BaseError e) { // SLICED! DerivedError info lost
// e is a BaseError, not DerivedError
}
// CORRECT: catch by const reference
catch (const BaseError& e) { ... }
If a destructor throws during stack unwinding (another exception is already
in flight), std::terminate() is called. Since C++11, destructors are
implicitly noexcept.
~Resource() {
try { cleanup(); } // cleanup might fail
catch (...) {
log_error(); // swallow — NEVER let destructors throw
}
}
If swap() can throw, copy-and-swap gives NO guarantee at all:
// WRONG: swap that allocates (may throw)
void swap(BigObject& a, BigObject& b) {
BigObject temp = a; // COPIES! May throw!
a = b;
b = temp;
}
// CORRECT: swap by exchanging internals
void swap(BigObject& a, BigObject& b) noexcept {
using std::swap;
swap(a.ptr_, b.ptr_); // pointer swap — noexcept
swap(a.size_, b.size_); // int swap — noexcept
}
noexcept on Move OperationsThis silently degrades std::vector performance from O(1) amortized moves
to O(N) copies during reallocation. See exercise 04.
void leak() {
Resource* r = acquire(); // raw pointer!
do_work(); // THROWS — r leaks!
std::unique_ptr<Resource> p(r); // too late
}
void safe() {
auto p = std::unique_ptr<Resource>(acquire()); // RAII immediately
do_work(); // if throws, p's destructor cleans up
}
| Guarantee | State After Exception | Resource Leaks? | Example |
|---|---|---|---|
| No-throw | N/A (never throws) | No | swap(), destructors |
| Strong | Rolled back | No | vector::push_back |
| Basic | Valid but changed | No | vector::insert (sometimes) |
| None | Anything | Maybe | BUG |
noexcept — containers depend on itconst&, never by valuenoexcept is not just documentation — it changes runtime behavior