Programming Languages · Memory and Ownership
Destructors, Drop Order, and What Unwinding Actually Runs
The order in which destructors run, how exception propagation finds them, and why a destructor that throws or panics changes the shape of the program.
Attaching reclamation to scope exit is a single design decision with a large surface. Once a language promises that a value’s cleanup runs when the value dies, it owes an answer to four questions: in what order do destructors run within a scope, what happens when an exit is not a normal return, what happens when the cleanup itself fails, and what the compiler must emit to make any of this work under exceptions. The answers differ between languages, and the differences show up in code that looks identical.
Reverse declaration order, and why it is not arbitrary
Within a block, objects are destroyed in the reverse of their construction. The rule is forced by dependency: a later object may hold a pointer or reference into an earlier one, so destroying forward would leave the later object pointing at freed storage during its own destructor.
C++ states the rule for automatic objects in terms of declaration order within the same scope, and for class members in terms of declaration order within the class. Members are destroyed in reverse declaration order regardless of the order in which the constructor’s member-initializer list names them:
struct Pair {
int a;
int b;
// Both are initialized in declaration order, so `b` is read uninitialized.
Pair(int x) : b(x), a(b) {}
~Pair() { /* runs body first, then destroys b, then a */ }
};
That initializer list produces a read of an uninitialized b, because a is constructed first and its initializer refers to a member that does not exist yet; GCC and Clang report the mismatch under -Wreorder. Base classes are destroyed after members, and the destruction of a complete object is: derived destructor body, derived members in reverse, base destructor.
Rust follows the same principle with a different grain. Locals drop in reverse declaration order, fields of a struct drop in declaration order, and tuple elements drop in index order. The difference from C++ is real and catches people: a Rust struct’s fields drop in the order they are written, not reversed. For locals the reverse order follows the same dependency argument as in C++, since a later binding may borrow an earlier one; for fields of a struct that argument does not apply in the same way, and the language chose declaration order instead. Both languages guarantee that a value’s own destructor body runs before its fields are destroyed, so the body can still inspect them.
Order matters most in the presence of shared state. A global logger destroyed before the objects that log to it produces a use-after-destruction during shutdown, and neither language orders destruction across translation units or across static initializers. C++ has the static destruction order fiasco as a named hazard; the standard fixes order only within a single translation unit, in reverse of construction, and leaves the interleaving across translation units unspecified. The workaround in both languages is to keep destructible globals out of shutdown paths, or to leak them deliberately.
Cleanup on the way out
A return from the middle of a function runs the destructors of every object constructed so far in the frames being exited, in reverse order. So does a goto that leaves a scope, and so does a break out of a loop whose body declared the object.
Exceptions are the hard case. When a throw occurs, the runtime must find a handler, and everything between the throw point and the handler must be destroyed. The mechanism is a two-pass protocol defined by the Itanium C++ ABI and used by GCC and Clang on Linux and macOS; MSVC uses a structurally similar scheme with different tables.
Pass one walks the stack from the throw point upward, frame by frame, consulting each function’s exception table. The table maps instruction address ranges to landing pads and to a filter, and the filter decides whether this frame handles the exception type. Pass one finds the handler without running any destructors, which is what makes it safe for a handler to inspect the exception object before unwinding begins.
Pass two walks the same frames again, this time transferring control to the cleanup landing pads so that destructors run. Each landing pad cleans up the objects live at that point and then tells the unwinder to resume the walk to the next frame. The handler at the top runs last, after every intervening destructor has completed.
The cost is proportional to frames crossed, not to a single jump. Each frame requires a table lookup, a call to the personality routine, and the destructor calls themselves. That is why a throw that crosses many frames is orders of magnitude more expensive than a return, and why the cost is usually irrelevant when exceptions signal rare failures and ruinous when they signal ordinary conditions.
The tables are the reason exception support changes binary size. .eh_frame holds unwind descriptors for functions the unwinder may have to walk through, and .gcc_except_table holds the call-site ranges and landing pads. A build with -fno-exceptions omits the exception table, so a throw whose handler is outside such a frame has no cleanup information to consult; the unwinder stops there and the runtime calls std::terminate. On the Itanium ABI, .eh_frame is also what makes backtracing work, so debuggers and profilers depend on it even when the program never throws.
Rust’s panic path is the same machine
Rust’s panic! unwinds using the same platform mechanism, with its own personality routine in the compiler’s runtime crate. Locals in frames being unwound are dropped, which is what releases their resources. A Mutex is not left held: its lock is released during the unwind, and the mutex is marked poisoned so that a later lock returns an error rather than silently continuing with state that was in the middle of being modified.
Two details differ from C++. First, a panic may be configured to abort instead of unwind, in which case no destructors run; the setting is per-crate and affects the whole binary when linking. Second, unwinding across an FFI boundary is undefined behaviour unless the function is declared to catch it, so every extern "C" function that might panic must be wrapped. The wrapper is std::panic::catch_unwind for code that can tolerate the panic, or the boundary must be declared extern "C-unwind" when both sides agree to propagate.
The drop order during a panic is the reverse of construction for whatever was fully constructed. A value whose constructor panicked partway through is not dropped, because its invariants were never established; the fields already initialized are dropped individually. C++ has the same rule for partially constructed objects: members constructed before the throw are destroyed, the object’s own destructor is not called.
When cleanup itself fails
A destructor that throws while another exception is in flight calls std::terminate in C++. This is not an arbitrary restriction. The unwinder is midway through pass two, with an active exception object and a partially unwound stack; a second exception has no place to go. The standard therefore makes it a hard error, and the practical rule is that destructors must not throw, which is why every standard container’s destructor is noexcept.
Throwing from a destructor during normal scope exit is permitted but hazardous: the exception propagates from an implicit destructor call at the end of a block, and if the destructor was invoked as part of destroying an array or a container, the remaining elements are still destroyed and the process may lose track of which ones completed.
Rust’s equivalent is a panic inside Drop::drop. A panic raised while the thread is already unwinding aborts the process rather than attempting a second unwind. A panic raised during normal execution propagates to the caller normally, which makes a panicking destructor a hazard rather than a guaranteed abort: the failure surfaces wherever the enclosing scope was left, not where the destructor ran. Because drop cannot be declared fallible, a destructor that needs to report failure must provide an explicit close method, and the pattern of “release resources in a method that returns a result, leave the destructor to a last-resort path” appears in the standard library for files and sockets.
The other direction is cleanup that blocks or re-enters. A destructor that takes a lock can deadlock if the lock is already held by the same thread and is not recursive, and a destructor that calls back into user code can observe objects in a half-destroyed state. Both languages allow it; neither prevents it.
The reclamation boundary
Destructors are the point where memory reclamation and resource reclamation meet. Freeing the bytes is the easy part: delete on a pointer calls the destructor and then operator delete. The hard part is that the same mechanism must also close file descriptors, release locks, flush buffers, and decrement reference counts, and each of those operations has its own failure semantics that a void-returning destructor cannot express.
That mismatch explains several design choices elsewhere in this cluster. Reference counting puts the destructor at the point where the last reference dies, which may be an arbitrary thread at an arbitrary time, so the destructor must be thread-safe and must not assume anything about the calling context. Arena allocation deliberately runs no per-object destructor at all, so any object holding an external resource must not be placed in an arena without an explicit release step. And the borrow checker exists in part to guarantee the precondition that makes destructors sound: that no reference to a value outlives the value, so the destructor cannot run while a live reference is being read.
The cost accounting is simple enough to state. A scope with no destructible locals emits no cleanup code, so the mechanism is free where it is unused. A scope with one destructor emits a call at each exit path, and the compiler usually merges the paths into a single epilogue. Under exceptions, the cost moves into the tables: an entry per call site in .gcc_except_table, plus the landing pad code itself, which is why exception-heavy code grows noticeably and -fno-exceptions shrinks it. The price is paid at compile time and in binary size, and the runtime cost is zero until a throw actually happens.
References
- Itanium C++ ABI: Exception Handling — Itanium C++ ABI project
- ISO/IEC 14882:2017 — Programming languages — C++ — ISO/IEC
- The Rust Reference: Destructors — The Rust Project