Intuition What this page is for
The parent note taught the rule : grab in the constructor, drop in the destructor. This page proves you can survive every situation the exam (and real code) throws at you — normal exits, exceptions, copies, inheritance, ordering, and the sneaky degenerate cases where beginners leak, double-free, or deadlock. We first list every kind of scenario in one table, then work one example per cell.
Definition RAII (so this page stands on its own)
RAII = Resource Acquisition Is Initialization. A design rule where ==acquiring a resource (memory, file, lock) is the act of constructing an object, and releasing it is the act of destroying that object==. Because C++ guarantees a destructor runs whenever an object's lifetime ends, the cleanup becomes automatic on every exit path. Throughout this page, "an RAII wrapper" just means "a small object whose constructor grabs a resource and whose destructor drops it."
Think of "cases" the way a trig topic has quadrants. For destructors, the "quadrants" are the different exit paths and ownership situations a resource can be in. If you can handle every row below, nothing can surprise you.
Cell
Scenario class
The tricky part
Worked in
A
Normal scope exit (happy path)
destructor just fires at }
Ex 1
B
Exception thrown mid-function
stack unwinding must still clean up
Ex 2
C
Early return before end
cleanup must not be skipped
Ex 3
D
Multiple objects — destruction order
LIFO, reverse of construction
Ex 4
E
Base + members inside one object
derived → members → base
Ex 5
F
Copying a resource-owning object
shallow copy → double-free (Rule of Three)
Ex 6
G
Polymorphic delete through base pointer
needs virtual destructor
Ex 7
H
Degenerate/zero input (size 0, nullptr)
destructor must still be safe
Ex 8
I
Limiting case — nested scopes, exception during unwinding
how far cleanup reaches
Ex 9
J
Real-world word problem — file + lock together
multiple RAII guards stacked
Ex 10
K
Exception thrown inside a constructor
only built subobjects are destroyed
Ex 11
L
Destructor that throws during unwinding
double-exception → std::terminate
Ex 12
Every example below is tagged with its cell letter. Together they cover all twelve .
To make destruction visible we use a class that prints when it dies. This is the standard exam trick — a destructor with a cout so you can read the order.
struct Tracer {
std ::string name;
Tracer ( std :: string n ) : name (n) { std ::cout << "ctor " << name << " \n " ; }
~Tracer () { std ::cout << "dtor " << name << " \n " ; }
};
Definition "Prints on death"
A destructor is just a function that runs at end-of-life. By putting a print statement inside it, the order and timing of destruction become observable output — exactly what exam questions test.
Worked example 1 — the happy path (Cell A)
void demoA () {
Tracer x ( "x" );
std ::cout << "body \n " ;
} // <-- x dies here
Forecast: what three lines print, and in what order?
ctor x — Why this step? the constructor runs the moment x is created, before the body.
body — Why? the function body runs next.
dtor x — Why? reaching } ends x's lifetime, so its destructor fires automatically.
Verify: exactly one ctor and one matching dtor. Nothing leaks — the } did the work for free. Output: ctor x / body / dtor x.
This is the whole reason RAII exists . See Exception Safety & Stack Unwinding .
Figure s01 (described in words): a horizontal timeline of one function call. A blue box "ctor x (acquire)" sits at the left; a red box "throw boom!" in the middle; a dashed gray box "ctor y (never runs)" on the right; and an orange box "dtor x (release)" below. A red arrow leaves the throw box and shoots out of the function past y; an orange arrow also leaves the throw and curves back down to dtor x. The takeaway printed in green: "x built → x destroyed, even without reaching the closing brace." The picture shows that the exception skips everything after the throw except the destructors of already-built locals.
Worked example 2 — cleanup during unwinding (Cell B)
void demoB () {
Tracer x ( "x" );
throw std :: runtime_error ( "boom" ); // jumps out
Tracer y ( "y" ); // never reached
}
Forecast: does x's destructor run even though we never reached } normally?
ctor x — object built. Why this step? normal construction before the throw.
throw — control leaves the function. Why? an exception aborts the rest of the body.
During stack unwinding , every fully-constructed local dies. x qualifies → dtor x. Why? the standard guarantees destructors of built locals run while the exception propagates.
y is never constructed , so it has no destructor call. Why? you only destroy what you built.
Verify: output is ctor x / dtor x. No y lines at all. In figure s01, the red exception arrow jumps out but the orange arrow still triggers dtor x on the way. A manual delete written after the throw would have been skipped — that's the leak RAII prevents.
Worked example 3 — return in the middle (Cell C)
int demoC ( bool quit ) {
Tracer x ( "x" );
if (quit) return 1 ; // early exit
return 2 ;
}
Forecast: with quit == true, does x still get destroyed?
ctor x. Why this step? construction happens first regardless of branches.
quit is true → return 1. Why? we take the early exit path.
Before the function actually returns its value, x goes out of scope → dtor x. Why? any exit (including return) ends the enclosing scope, so locals are destroyed.
Verify: output ctor x / dtor x, and the returned value is 1. Both return 1 and return 2 paths destroy x — no path leaks.
Worked example 4 — reverse order (Cell D)
void demoD () {
Tracer a ( "a" );
Tracer b ( "b" );
Tracer c ( "c" );
}
Forecast: guess the death order before reading.
Construction order is a, b, c → prints ctor a ctor b ctor c. Why this step? declarations run top to bottom.
At }, destruction is reversed → c, b, a. Why? c was built last, so it dies first; this guarantees a (which c might depend on) is still alive while c is torn down.
Verify: destructor output is dtor c / dtor b / dtor a — the exact reverse of a b c.
When a single object is destroyed there is a strict internal order. See Virtual Functions & Polymorphism and Constructor — initialization .
Figure s02 (described in words): a vertical stack of four boxes representing one Widget object. From bottom to top: blue "Base subobject", green "member m1", green "member m2", orange "Widget body". A blue downward arrow on the left is labelled "CONSTRUCT: base → members → body" . An orange upward arrow on the right is labelled "DESTROY: body → m2 → m1 → base" . Caption: "Destruction is the exact mirror of construction." The image makes the mirror symmetry literal — you build from the bottom up and tear down from the top down.
Definition Destruction order
inside one object
the derived destructor body runs first,
then its members are destroyed (reverse of declaration order),
then the base class destructor runs.
It is the exact mirror image of construction (base → members → derived body).
Worked example 5 — derived, members, base (Cell E)
struct Base { ~ Base (){ std ::cout << "~Base \n " ; } };
struct Widget : Base {
Tracer m1{ "m1" };
Tracer m2{ "m2" };
~Widget (){ std ::cout << "~Widget body \n " ; }
};
void demoE (){ Widget w; }
Forecast: in what order do ~Widget body, dtor m2, dtor m1, ~Base appear?
~Widget body — Why this step? the most-derived destructor body runs first, while everything it might use is still alive.
dtor m2 then dtor m1 — Why? members die in reverse declaration order (m1 declared before m2, so m2 dies first).
~Base — Why? the base is the foundation; it is torn down last, mirroring that it was built first.
Verify: exact output ~Widget body / dtor m2 / dtor m1 / ~Base. In figure s02 the orange upward arrow lists exactly this order — construction goes down the stack (base at bottom), destruction pops from the top.
Common mistake The shallow-copy double-free
A default copy just copies the pointer value , not the buffer. Two objects now believe they own the same memory. When both destructors run, delete[] fires twice on one buffer → undefined behaviour.
Figure s03 (described in words): on the left, two object boxes — blue "a.data" and orange "b.data (copy of a)". On the right, a single green box "one int[4] buffer". A blue arrow and an orange arrow both point from the two objects to the same green buffer, labelled in red "same address" . Below: "delete[] (b) then delete[] (a) → SAME buffer freed twice = double free" , and a gray caption "new[] count = 1 but delete[] count = 2." The figure shows visually why two owners of one buffer is the bug.
Worked example 6 — why one destructor forces three functions (Cell F)
class IntArray {
int* data; size_t n;
public:
IntArray ( size_t s ) : data ( new int [s]), n (s) {}
~IntArray (){ delete[] data; }
// BUG: no copy constructor / copy assignment defined
};
void demoF (){
IntArray a ( 4 );
IntArray b = a; // shallow copy: b.data == a.data
} // both destructors delete the SAME buffer
Forecast: how many new[] calls and how many delete[] calls happen? Are they balanced?
IntArray a(4) → one new int[4]. Why this step? the constructor acquires.
IntArray b = a → default copy sets b.data = a.data. Why? with no copy constructor, the compiler copies the pointer bits , not the array. Now a.data == b.data.
At }, b dies → delete[] on the buffer. Then a dies → delete[] on the same buffer again. Why? LIFO destroys both, each thinking it owns the memory.
Verify: new[] count = 1 , delete[] count = 2 → mismatch of 1 ⇒ double-free bug. As figure s03 shows, both arrows hit one buffer. The Rule of Three fix (Rule of Three / Five / Zero ): supply a deep-copy copy constructor and copy assignment so each object gets its own buffer, making delete[] count = 2 over 2 distinct buffers. Even simpler — Rule of Zero : store a std::vector<int> or unique_ptr and delete nothing yourself. See also Move Semantics .
Worked example 7 — the virtual-destructor trap (Cell G)
struct Base { /* ~Base non-virtual! */ ~ Base (){ std ::cout << "~Base \n " ; } };
struct Derived : Base {
int* buf = new int [ 10 ];
~Derived (){ delete[] buf; std ::cout << "~Derived \n " ; }
};
Base * p = new Derived ();
delete p; // ???
Forecast: with a non-virtual ~Base, which destructors run when we delete p?
p has static type Base*. Why this step? the compiler decides which destructor to call using the pointer's type unless the destructor is virtual.
Because ~Base is not virtual, delete p calls only ~Base. Why? without a vtable entry for the destructor, there is no runtime dispatch to ~Derived.
~Derived is skipped → buf is leaked (its delete[] never runs). Why? the derived cleanup never executed.
Verify: buggy output is just ~Base (missing ~Derived) → buf leaked. Fix: declare virtual ~Base(){...}. Then delete p dispatches to ~Derived first, then ~Base, giving ~Derived / ~Base and freeing buf. Rule: any class deleted through a base pointer needs a virtual destructor.
Worked example 8 — size 0 and null pointers (Cell H)
IntArray z ( 0 ); // new int[0] is legal and non-null-ish
// ...
int* q = nullptr ;
delete[] q; // deleting nullptr is a legal no-op
Forecast: is a zero-size allocation or a delete[] on nullptr a crash?
new int[0] is legal — it returns a valid, unique pointer you must still delete[]. Why this step? the language allows empty allocations so generic code (size decided at runtime) needs no special-case.
~IntArray runs delete[] data. Even when n == 0, data is a valid pointer → safe. Why? the destructor doesn't care about the size; it frees exactly what new[] returned.
delete[] nullptr; (and delete nullptr;) is defined to do nothing . Why? so you never need if (p) delete p; guards — the language already checks.
Verify: number of crashes = 0 . new[]/delete[] still pair up 1-to-1 for the zero-size object. A destructor written as delete[] data; is therefore safe for both empty and null cases with no extra if.
Worked example 9 — unwinding across nested scopes (Cell I)
void demoI () {
Tracer outer ( "outer" );
{
Tracer inner ( "inner" );
throw std :: runtime_error ( "x" ); // thrown in inner scope
}
Tracer never ( "never" );
}
Forecast: does both inner and outer get destroyed, or just inner?
ctor outer, then enter inner block, ctor inner. Why this step? normal construction top-down.
throw in the inner scope. Unwinding first destroys locals of the current (innermost) scope → dtor inner. Why? unwinding peels scopes one layer at a time, innermost first.
The exception is still not caught inside demoI, so unwinding continues out of the function, destroying outer → dtor outer. Why? every enclosing scope with built locals is unwound until a handler is found.
never is never constructed → no destructor. Why? code after the throw doesn't run.
Verify: output ctor outer / ctor inner / dtor inner / dtor outer — reverse order, both cleaned, never absent. Cleanup reaches all fully-built locals in all enclosing scopes.
Worked example 10 — two stacked RAII guards (Cell J)
Problem (word form): A logging function must (1) lock a shared mutex so two threads don't interleave, and (2) open a log file, write one line, and always close it — even if writing throws. Write it with RAII and predict the cleanup order.
std ::mutex m;
struct File { // RAII file wrapper
FILE * f;
File ( const char* path ){ f = std :: fopen (path, "a" ); }
~File (){ if (f) std :: fclose (f); } // guaranteed close
};
void logLine ( const std :: string & s ) {
std ::lock_guard < std ::mutex > g (m); // guard 1: LOCK (constructed 1st)
File file ( "app.log" ); // guard 2: OPEN (constructed 2nd)
write (file, s); // may throw
} // destruction: ~File CLOSES, then ~lock_guard UNLOCKS
Forecast: if write throws, in what order do the file close and the mutex unlock happen — and are both guaranteed?
Construct g → locks m. Why this step? RAII: acquire the lock by constructing an object, so we can never forget to unlock (std::lock_guard & scope guards ).
Construct file → opens the file. Why? acquisition = initialization, so opening lives in the constructor and its matching close lives in the destructor.
write(file, s) throws. Why does this matter? it is exactly the path where a manual m.unlock() / fclose() written after write would be skipped, causing a permanent deadlock and a leaked handle.
Stack unwinding destroys the two locals in reverse construction order: file first → ~File runs fclose (closes the handle); then g → ~lock_guard runs unlock (releases the mutex). Why this order? LIFO guarantees the file is closed before the lock is released, so no other thread can grab the mutex and see a half-written file.
Verify: cleanup sequence = close file, then unlock mutex (2 releases, both unconditional). Count of leaked file handles = 0 , count of permanently-held locks = 0 , even on the throwing path. The if(f) guard in ~File also makes a failed fopen (returns nullptr) safe — ties back to Cell H. This is the everyday shape of RAII: stack the guards, let the destructors unwind in reverse.
A subtle but exam-favourite case: what gets cleaned up if the object is only half-built when it throws?
Definition The half-built object rule
If a constructor throws before finishing , the object was never fully constructed , so its own destructor never runs . But every base class and member that was already fully constructed is destroyed, in reverse order. You are cleaned up to exactly the point you reached — no further, no less.
Worked example 11 — throw during member initialization (Cell K)
struct Bad { Bad (){ throw std :: runtime_error ( "nope" ); ~ Bad ? } };
struct Holder {
Tracer a{ "a" }; // member 1 — constructs fine
Tracer b{ "b" }; // member 2 — constructs fine
Bad c; // member 3 — THROWS in its constructor
Tracer d{ "d" }; // member 4 — never started
~ Holder (){ std ::cout << "~Holder \n " ; } // never runs!
};
void demoK (){ try { Holder h; } catch (...) { std ::cout << "caught \n " ; } }
Forecast: which destructors run — ~Holder? a? b? d?
a constructs → ctor a. b constructs → ctor b. Why this step? members initialize top-down in declaration order.
c (the Bad) throws inside its own constructor. Why does this stop everything? Holder h is now only partially built, so d is never started.
~Holder does not run. Why? h was never a complete object; a destructor only fires for objects whose constructor finished .
But the members that did finish are unwound in reverse: dtor b then dtor a. Why? they were fully constructed, so they must be released — the language cleans up exactly the built subobjects.
Verify: output ctor a / ctor b / dtor b / dtor a / caught. Number of ~Holder calls = 0 ; number of member destructors run = 2 (b, a); d never appears. Practical rule: put each raw resource in its own RAII member — then a throw mid-construction still cleans up the earlier members automatically (Rule of Zero again).
The one place RAII can bite back: a destructor must never let an exception escape while another exception is already in flight.
Common mistake Two exceptions at once = instant crash
If the stack is already unwinding because of exception #1, and a destructor being run by that unwinding throws exception #2, C++ cannot choose which to propagate. It gives up and calls ==std::terminate()== — your program dies immediately, with no further cleanup. Therefore destructors should be noexcept (which they are by default) and must swallow or handle their own errors.
Worked example 12 — the throwing destructor (Cell L)
struct Risky {
~Risky () noexcept ( false ) { // opting OUT of the default
throw std :: runtime_error ( "dtor throws" );
}
};
void demoL () {
Risky r;
throw std :: runtime_error ( "first" ); // exception #1 starts unwinding
} // unwinding destroys r -> ~Risky throws exception #2 -> std::terminate
Forecast: does the program catch "first", catch "dtor throws", or crash?
throw "first" begins stack unwinding. Why this step? an exception in the body triggers cleanup of locals.
Unwinding destroys r, which calls ~Risky. Why? r was fully constructed, so its destructor must run.
~Risky throws while exception #1 is still propagating → two live exceptions. Why is this fatal? the runtime cannot merge or pick between them, so it invokes std::terminate().
Verify: neither catch block is reached; the program terminates (count of exceptions successfully caught = 0 ). The fix: keep the default ~Risky() noexcept and handle failures inside the destructor, e.g. try { risky_close(); } catch(...) { /* log, swallow */ }. A well-behaved RAII destructor never throws — it is the last line of defence, not a new source of errors.
Recall Self-test: name the cell
A function throws after building two locals p then q. Which die and in what order? ::: Both die; q first, then p (LIFO during unwinding — Cell B/D/I).
You wrote only a destructor for a class holding a char*. What breaks on X b = a;? ::: Shallow copy aliases the pointer → double delete at end → double-free. Fix with Rule of Three or Rule of Zero (Cell F).
delete p; through Base* only prints ~Base. What is missing? ::: A virtual destructor on Base; without it ~Derived is skipped and its resources leak (Cell G).
Is delete[] nullptr; safe? ::: Yes — it is a defined no-op; no guard needed (Cell H).
Inside one object, what order: base dtor, member dtors, derived body? ::: Derived body → members (reverse decl) → base (Cell E).
A constructor throws after two members are built. Does the object's own destructor run? ::: No — the object was never fully constructed; only the already-built members are destroyed in reverse (Cell K).
Why must a destructor be noexcept? ::: If it throws during stack unwinding of another exception, C++ calls std::terminate() and the program dies (Cell L).
Mnemonic Cheat-sheet for the exam
"Built last, buried first." Destruction is always the reverse of construction — for locals in a scope and for members/base inside one object. For polymorphism: "Base pointer? Virtual dtor, or the derived stays buried." For constructors: "Only what finished gets buried." And for destructors themselves: "A goodbye never throws."
Cell B unwinding cleans up
Cell E derived members base
Cell K only built parts die