Exceptions separate reporting a failed operation from propagating it to a boundary that can respond. An exception transfers control to a matching handler; while control travels outward, C++ destroys every fully constructed automatic object whose scope is exited, in reverse construction order. Resource acquisition is initialization (RAII) therefore makes the normal and exceptional cleanup paths the same.
void load_configuration(std::filesystem::path path) {
std::ifstream input{path}; // Closed during unwinding.
if (!input) {
throw std::runtime_error{"cannot open configuration"};
}
// Parsing may throw; input still closes.
}
try {
load_configuration("app.conf");
} catch (const std::exception& error) {
report(error.what());
}Throw exception objects by value and catch polymorphic exception types by const reference. Catching a base exception by value slices a derived object. A catch (...) belongs only at a boundary that can restore an invariant, translate an error, or deliberately terminate; it must not silently continue with damaged state.
Unwinding and RAII #
A constructor that throws does not produce a completed object, but destructors run for its fully constructed base and member subobjects. A destructor must not allow an exception to escape while another exception is active, because that calls std::terminate. Destructors, smart-pointer deleters, and rollback guards should therefore be non-throwing. When cleanup can fail, expose an explicit operation that reports failure before destruction.
class Transaction {
public:
explicit Transaction(Database& db) : db_{db} { db_.begin(); }
void commit() {
db_.commit();
committed_ = true;
}
~Transaction() noexcept {
if (!committed_) {
db_.rollback_noexcept();
}
}
private:
Database& db_;
bool committed_ = false;
};The destructor only attempts a rollback; commit() remains the operation that may report an error. Each public operation must define what state survives its failures.
Exception-Safety Guarantees #
An operation can state one of these guarantees:
- No-throw guarantee: it does not propagate an exception.
- Strong guarantee: if it fails, observable state is unchanged.
- Basic guarantee: invariants hold and resources are not leaked, but state may change.
No guarantee beyond the basic guarantee should be inferred merely because a type uses RAII. RAII prevents leaks; it does not restore overwritten values or external effects. Document the guarantee for mutating operations whose work can throw.
Commit, Rollback, and Invariants #
The strong guarantee follows a prepare-and-commit structure: perform potentially throwing work in replacement state, then apply a non-throwing commit. The commit is often a swap, pointer replacement, or database transaction commit.
void Account::replace_limits(Limits requested) {
requested.validate(); // May throw; `limits_` is unchanged.
limits_.swap(requested); // Commit must not throw.
}When replacement state is too expensive or an external action cannot be undone, maintain the basic guarantee instead. Define an invariant that callers can rely on after every failure, such as “the object remains usable and its balance is nonnegative.” Do not claim rollback after an irreversible side effect unless the external system provides a transaction or compensating action.
noexcept Is a Termination Contract
#
noexcept changes the function type for some purposes and promises that an exception escaping the function causes std::terminate. It does not suppress exceptions or make the body faster by definition. Mark a function noexcept when termination is the intended response and every invoked operation fits that contract.
Destructors are implicitly non-throwing unless declared otherwise. Explicitly making a destructor noexcept(false) is appropriate only when callers can catch its exception on a non-unwinding path; it remains unsafe if another exception is already propagating.
noexcept also affects standard-library strategy. For example, when std::vector reallocates, it can move elements while preserving its strong guarantee if their move constructor is non-throwing. If moving may throw and copying is available, an implementation can copy instead. If moving may throw and copying is unavailable, the vector may be unable to provide the strong guarantee for that reallocation. This is a container-specific consequence, not a reason to mark an operation noexcept dishonestly.
Designing Exception Boundaries #
Keep ordinary code exception-neutral: acquire resources with owning objects, preserve invariants locally, and allow failures to reach a layer that knows the recovery policy. Translate exceptions at process, request, thread, or foreign-function boundaries where the target error model requires it. Never allow a C++ exception to cross an interface that does not permit it.
An exception is not a substitute for validation. Validate preconditions before mutation, use return values such as C++23 std::expected when failure is part of normal local control flow, and use exceptions when a caller needs non-local propagation with reliable cleanup.
Comments
Read or comment on GitHub (sign-in required to comment)