C++ / ERRORS, EXCEPTIONS, AND UNDEFINED BEHAVIOUR
Stack unwinding and RAII during exceptions
Trace exactly which destructors run when an exception propagates, and own every resource with an automatic object so nothing leaks on the throwing path.
What you will learn
- Predict unwinding order: reverse construction order, frame by frame up to the handler
- Spot what unwinding cannot free: raw new, fopen, hand-locked mutexes
- A throwing constructor destroys built members but never runs the object's destructor
- Use std::uncaught_exceptions() to tell unwinding apart from a normal scope exit
Understanding Stack unwinding and RAII during exceptions
A `throw` first initialises the exception object in storage owned by the runtime, not in the frame that is about to disappear. The runtime then walks back through the active call frames looking for a handler, and for each frame it leaves it calls the destructor of every automatic object whose constructor had completed, in exact reverse order of construction. That is all stack unwinding is: the same end-of-scope destructor calls the compiler would emit for a `return`, triggered from an arbitrary point in the middle of a statement. Objects declared after the throw point were never constructed, so they are not in the list and nothing is called for them.
The consequence that trips people up is that unwinding destroys objects; it does not release resources. A `Widget*` holding a `new`ed object is itself an automatic object and is duly destroyed, which means its eight bytes go away and the heap block stays allocated, now unreachable. The same holds for a `FILE*` from `fopen`, a mutex you locked by hand, or a socket in an `int`: these types have trivial destructors, so unwinding walks straight past them. RAII closes the gap by binding each resource to a class whose destructor releases it and giving that object automatic storage duration, so unwinding becomes complete cleanup on paths you never wrote.
Two boundary rules complete the model. If a constructor throws, the object never came into existence, so its own destructor is never called, while every base and member already fully constructed is destroyed in reverse order, which is why a resource acquired in a constructor must be held by a member guard rather than freed in `~T()`. And a destructor running during unwinding must not let an exception escape: one exception is already in flight, and rather than choose between them the runtime calls `std::terminate`. That is why anything that can genuinely fail during cleanup, such as a flush or a commit, belongs in an ordinary method the caller can call and check.
<iostream>
<stdexcept>
<string>
<utility>
struct Trace {
std::string name;
explicit Trace(std::string n) : name(std::move(n)) {
std::cout << "acquire " << name << '\n';
}
~Trace() { std::cout << "release " << name << '\n'; }
};
void boom() { throw std::runtime_error("disk full"); }
void inner() {
Trace a{"a"};
Trace b{"b"};
boom();
Trace c{"c"}; // never constructed, so never destroyed
}
void outer() {
Trace o{"outer"};
inner();
std::cout << "unreachable\n";
}
int main() {
try {
outer();
} catch (const std::exception& e) {
std::cout << "handler: " << e.what() << '\n';
}
std::cout << "still running\n";
}
Unwinding's only cleanup mechanism is the destructor of a fully constructed automatic object, so a resource is safe exactly when some such object owns it.
Worked examples
Unwinding destroys the pointer, not the object
Shows that only an owning automatic object turns unwinding into an actual release.
<iostream>
<memory>
<stdexcept>
struct Widget {
int id;
explicit Widget(int i) : id(i) { std::cout << "Widget " << id << " built\n"; }
~Widget() { std::cout << "Widget " << id << " destroyed\n"; }
};
void leaky() {
Widget* w = new Widget(1);
throw std::runtime_error("fail");
delete w;
}
void owned() {
auto w = std::make_unique<Widget>(2);
throw std::runtime_error("fail");
}
int main() {
try { leaky(); } catch (const std::exception&) { std::cout << "after leaky\n"; }
try { owned(); } catch (const std::exception&) { std::cout << "after owned\n"; }
}
Example explained
Line 1`Widget* w = new Widget(1);` creates two things: a heap Widget and an automatic pointer, and unwinding only ends the lifetime of the pointer.
Line 2The `delete w;` line is skipped entirely, and the absence of a "Widget 1 destroyed" line is the leak made visible.
Line 3`std::make_unique<Widget>(2)` puts the same heap object behind an automatic owner, so unwinding calls `~unique_ptr`, which calls delete.
Line 4Both functions throw the same exception from the same place; the only difference is who owns the pointer.
A constructor that throws half way
Demonstrates that completed members are unwound but the object's own destructor never runs.
<iostream>
<stdexcept>
struct Part {
const char* tag;
explicit Part(const char* t) : tag(t) { std::cout << "Part " << tag << " ctor\n"; }
~Part() { std::cout << "Part " << tag << " dtor\n"; }
};
struct Machine {
Part first{"first"};
Part second{"second"};
Machine() {
std::cout << "Machine body\n";
throw std::runtime_error("calibration failed");
}
~Machine() { std::cout << "Machine dtor\n"; }
};
int main() {
try {
Machine m;
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
}
Example explained
Line 1`first` and `second` are constructed in declaration order before the constructor body starts running.
Line 2The throw leaves the constructor, so `m` never becomes a complete object and "Machine dtor" never appears.
Line 3The runtime still destroys the two finished members in reverse order, `second` then `first`.
Line 4Because `m` was never registered as a live object of the try block's scope, nothing further happens at the closing brace.
Telling unwinding from a normal exit
Uses std::uncaught_exceptions() so one destructor can commit on success and roll back while unwinding.
<exception>
<iostream>
<stdexcept>
struct Transaction {
bool committed = false;
void commit() { committed = true; }
~Transaction() {
if (committed) std::cout << "commit\n";
else if (std::uncaught_exceptions() > 0) std::cout << "rollback (unwinding)\n";
else std::cout << "rollback (normal exit)\n";
}
};
void work(bool fail) {
Transaction t;
if (fail) throw std::runtime_error("bad row");
t.commit();
}
int main() {
try { work(false); } catch (const std::exception&) {}
try { work(true); } catch (const std::exception&) { std::cout << "handled\n"; }
Transaction abandoned;
}
Example explained
Line 1`std::uncaught_exceptions()` counts exceptions thrown but not yet handled, so a value above zero inside a destructor means this scope is being unwound.
Line 2In `work(true)` the throw skips `t.commit()`, so the destructor sees committed == false with an exception in flight and rolls back before the handler is entered.
Line 3"handled" prints after the rollback line, which confirms unwinding finishes the frame before control reaches the catch.
Line 4`abandoned` is destroyed by a plain scope exit at the end of main, and the same destructor reports it differently.
Important notes
An exception with no matching handler anywhere is not required to unwind at all; the implementation may call `std::terminate` with the stack intact, so destructors are only guaranteed for errors that are actually caught.
`std::uncaught_exceptions()` (plural, C++17) is the reliable in-flight test; the singular `std::uncaught_exception()` gives wrong answers inside nested destructors and was removed in C++20.
Common mistakes
Putting `delete p;` or `fclose(f);` as the last line of the function: a throw above it jumps to the handler, that line never executes, and the resource leaks with no diagnostic.
Acquiring a raw handle in a constructor body and releasing it in `~T()`: if the constructor throws afterwards the destructor is never called, so the handle is lost permanently.
Letting a destructor throw (or calling something that throws) while unwinding: two exceptions in flight means `std::terminate`, so the program aborts before reaching your handler.
Try it yourself
Change, predict, then run
Write a Scope class that prints "enter <name>" and "leave <name>", then a chain main -> f -> g where each function creates two Scope objects and g throws between its two, and write down the expected line order before running it. Then allocate a `new int[100]` in g and confirm from the output that unwinding reports nothing about freeing it.
Open the C++ workspaceCheck your understanding
A function locks a mutex with std::lock_guard, then does `Widget* w = new Widget;`, then throws. The caller catches the exception. What is the state afterwards?
- Both are released, since unwinding tears down everything the frame allocated
- Nothing is released; destructors run only when a function returns normally
- The mutex is unlocked, but the Widget is leaked
- The mutex stays locked until the handler finishes, then both are cleaned up
Show answer
Unwinding calls destructors of automatic objects, so `~lock_guard` runs and unlocks the mutex, while the automatic object `w` is only a pointer whose destructor is trivial, leaving the heap Widget allocated and unreachable. The first option is tempting because "the frame is destroyed" sounds like the frame's allocations go with it, but unwinding never calls operator delete on your behalf; it also runs before the handler body, not after, which rules out the last option.