Exception safety is a contract about what your code promises if an exception is thrown. There are four widely used levels, codified by Abrahams in the late 1990s and adopted by the C++ standard library.
- 1. The Four Levels
- 2. Why It Matters Even Without Exceptions
- 3. Achieving the Strong Guarantee — Copy-and-Swap
- 4.
noexceptand the Move Question - 5. Common Failure Modes
- 6. Quick Reference
| Level | Promise on exception |
|---|---|
| No-throw | Operation will not throw, ever. Marked noexcept. |
| Strong | Operation either succeeds, or rolls back to the prior state — no observable change. |
| Basic | No leaks, no broken invariants, but state may have changed. |
| None | All bets off; usually a bug. |
Examples from the standard library:
| Operation | Guarantee |
|---|---|
std::vector<T>::push_back (with throwing copy) |
Strong |
std::vector<T>::push_back (with throwing move) |
Basic |
std::vector<T>::push_back (with noexcept move) |
Strong |
std::swap of trivially-movable types |
No-throw |
std::map::insert |
Strong |
std::sort |
Basic (with throwing comparator) |
| Destructors (everywhere) | No-throw (mandated by convention) |
If you build with -fno-exceptions, you might assume this is moot. It isn't:
- "Strong" generalizes to any failure path. Replace "throws" with "returns error" or "aborts" — the same reasoning applies to writing code that fails cleanly.
- Standard containers may still call
std::terminateon allocation failure, which has the same observable effect on your invariants. - Exception-safety analysis surfaces invariant-management bugs that exist whether or not exceptions are enabled.
The classic recipe: do the work on a copy, then swap. If anything fails, the original is untouched.
#include <algorithm>
#include <cstddef>
class Buffer {
char* data = nullptr;
std::size_t size = 0;
public:
void assign(const char* src, std::size_t n) {
char* tmp = new char[n]; // can throw — original untouched
std::copy_n(src, n, tmp); // can throw — but we'd leak tmp here
delete[] data; // commit point — no-throw from here
data = tmp;
size = n;
}
~Buffer() { delete[] data; }
};
int main() {
Buffer b;
b.assign("hello", 5);
}The pattern: all throwing work first, then a no-throw commit step. Any exception during the work leaves the original state intact. See Copy-and-Swap idiom.
noexcept is part of a function's type, not just a hint. Standard containers test whether your move constructor is noexcept and behave differently:
// Inside std::vector, conceptually:
void vector_push_back_grow() {
if (std::is_nothrow_move_constructible_v<T>) {
// move elements into new buffer — strong guarantee preserved
} else {
// copy elements (slower!) to keep the strong guarantee
}
}A non-noexcept move constructor silently makes your code slower. Mark moves noexcept whenever you can — and you almost always can if your members are.
class Connection {
public:
Connection() = default;
Connection(Connection&&) noexcept = default; // good
Connection& operator=(Connection&&) noexcept = default; // good
~Connection() = default; // already noexcept implicitly
};Pitfall: noexcept lies are checked at runtime. If a noexcept function does throw, the program calls std::terminate. Don't add it to functions you haven't actually verified.
Throw from a destructor. During stack unwinding, a second exception calls std::terminate. Destructors must be no-throw. Period.
Two-step construction without rollback. obj_ = new X; init(obj_); — if init throws, obj_ leaks. Use RAII (std::unique_ptr) or do the work in the constructor.
Self-assignment in copy assignment.
T& operator=(const T& other) {
delete data; // oops — if &other == this, we just freed the source
data = new int(*other.data);
return *this;
}Use copy-and-swap or check &o != this.
Holding a mutex through a throwing operation without RAII. Use std::scoped_lock/std::lock_guard, never mtx.lock()/mtx.unlock() by hand.
Throwing while iterating an STL container. If your comparator or predicate throws, the container is valid but the operation may have done partial work. Document or test which guarantee your callbacks need to provide.
Mixing exceptions with errno / C APIs. A C function that returns -1 + sets errno will not unwind. Wrap it in an RAII translator:
#include <cerrno>
#include <cstdio>
#include <system_error>
struct File {
FILE* fp;
File(const char* path) : fp(std::fopen(path, "r")) {
if (!fp) throw std::system_error(errno, std::generic_category(), path);
}
~File() { if (fp) std::fclose(fp); }
};
int main() {
try {
File f("/etc/hostname"); // throws std::system_error if missing
} catch (const std::system_error& e) {
// handle error
}
}| Want | Do |
|---|---|
| Strong guarantee | Copy-and-swap, or do throwing work on a temporary, then no-throw commit |
| No-throw guarantee | Mark noexcept, prove it, never call throwing code from inside |
| Container speed | Make moves and swap noexcept |
| Survive partial work | RAII for every owning resource |
| Translate C errors | RAII wrapper that throws std::system_error in constructor |
- Exceptional C++, Herb Sutter — the foundational reference.
- Generic: Exception-Safety Analysis, Andrei Alexandrescu.
- Copy-and-Swap idiom
- RAII
- Stack Unwinding
- Error Handling Strategies