This page walks the RAII idiom through every kind of situation it must survive . The parent note told you what RAII is; here we prove it by hitting every corner case — the happy path, the exception path, the double-owner trap, the empty resource, the nested scope, and an exam-style twist.
Before each example, forecast : guess the answer yourself. Then check.
Every "resource lifetime" problem falls into one of these cells. We will hit each one.
Cell
What triggers it
The danger if RAII is absent
Example
A. Normal exit
function returns cleanly
none (but hand-cleanup is verbose)
Ex 1
B. Exception exit
a callee throws mid-function
resource leaks / stays locked
Ex 2
C. Early return / break
if(...) return; before cleanup
skipped release
Ex 3
D. Copy of an owner
one owner assigned/copied to another
double-free
Ex 4
E. Move (steal)
ownership transferred
double-free OR no free
Ex 5
F. Empty / null resource
acquisition failed or moved-from
destructor must be a no-op
Ex 6
G. Nested / reverse order
several owners in one scope
wrong teardown order → use-after-free
Ex 7
H. Real-world word problem
file + lock together
leak + deadlock combined
Ex 8
I. Exam twist (throwing dtor)
destructor throws while unwinding
std::terminate
Ex 9
Each cell letter appears as a tag on its example. Together they tile the whole space of things that can go wrong when a resource is acquired.
Prerequisites we lean on: Constructors and Destructors , Stack Unwinding and Exceptions , Smart Pointers - unique_ptr shared_ptr , Rule of Three Five Zero , Move Semantics , Exception Safety Guarantees , and std::lock_guard and Mutexes .
To make cleanup checkable with numbers , imagine every resource keeps a global ledger:
Definition The acquire/release ledger
Every time a resource is acquired we add +1 to a counter live. Every time it is released we subtract 1. A program is leak-free and double-free-free exactly when, at the end, ==live == 0 and it never went negative== along the way. Going negative would mean "released something we didn't own" (double-free); ending positive means "never released" (leak).
Look at the figure: the black line traces live over time. RAII keeps it a clean staircase back down to 0.
Worked example Example 1 — Cell A: normal exit
void f () {
std ::vector <int> v ( 100 ); // acquire: live 0 -> 1
use (v);
} // dtor: live 1 -> 0
Forecast: at function end, what is live? How many deletes did you write?
Constructor runs. v(100) allocates a buffer. Why this step? Acquisition is initialization — the memory is grabbed inside the constructor, live becomes 1.
use(v) returns normally. Why this step? This is the boring happy path; nothing interrupts flow.
Scope ends, destructor runs. Why this step? Leaving the block destroys locals; ~vector frees the buffer, live becomes 0.
Verify: live starts 0, +1, -1 → ends at 0. Hand-written deletes = 0. ✅
Worked example Example 2 — Cell B: exception exit
void f () {
auto p = std :: make_unique < int >( 42 ); // acquire: live 0 -> 1
might_throw (); // throws!
// ...never reached...
} // dtor STILL runs: live 1 -> 0
Forecast: after the throw propagates out of f, is the int leaked?
Acquire in constructor. make_unique allocates, live = 1. Why this step? Ownership now lives inside p, a stack object.
might_throw() throws. Why this step? This is exactly the killer the parent warned about — a hand-written delete after this line would be skipped.
Stack unwinding fires. As the exception leaves f, every local is destroyed. ~unique_ptr calls delete, live = 0. Why this step? Stack unwinding is the language guarantee RAII rides on — cleanup on any exit.
Verify: ledger 0 → +1 → -1 = 0, and it never went negative. No leak even though the code after acquisition never ran. ✅
Worked example Example 3 — Cell C: early return / break
int f ( bool skip ) {
std ::lock_guard < std ::mutex > g (m); // acquire: locks 0 -> 1
if (skip) return 1 ; // early return
do_work ();
return 0 ;
} // dtor unlocks: locks 1 -> 0
Forecast: if skip == true, does the mutex stay locked?
Acquire the lock in the constructor. locks = 1. Why this step? lock_guard locks on construction.
Hit return 1 early. Why this step? Manual unlock() written at the bottom would be jumped over — the classic multi-exit bug.
g's destructor runs at every return. Both return 1 and return 0 leave the scope, so ~lock_guard unlocks, locks = 0. Why this step? Scope-bound cleanup fires on each exit path, not just the last line.
Verify: for both skip = true and skip = false: locks ends at 0. No branch leaks the lock. ✅
Worked example Example 4 — Cell D: copying an owner (the double-free trap)
struct BadOwner {
int* p;
BadOwner () : p ( new int ( 7 )) {} // live 0 -> 1
~BadOwner () { delete p; } // frees p
// no copy control -> default copies the POINTER value
};
void f () {
BadOwner a; // live 1
BadOwner b = a; // b.p == a.p (same pointer!)
} // ~b frees, ~a frees SAME address -> double free
Forecast: how many times is the same address freed?
Construct a. live = 1, one allocation. Why this step? Establishes the resource.
Copy a into b with the compiler's default copy. Why this step? The default just copies the raw p value — now two objects believe they own one buffer.
Both destructors run (reverse order: b then a). Each calls delete p on the same address → ledger goes 1 → 0 → -1. Why this step? Going negative is the numeric signature of a double-free.
Fix (Rule of Five): delete the copy operations, or use std::unique_ptr<int> (which is non-copyable), or deep-copy. With unique_ptr the line b = a; won't compile , catching the bug at build time. See Rule of Three Five Zero .
Verify: naive ledger reaches -1 (double free); the fixed version stays 1 → 0. ✅
Worked example Example 5 — Cell E: move = steal and null
auto a = std :: make_unique < int >( 9 ); // a owns buffer, live 1
auto b = std :: move (a); // steal: b owns it, a becomes null
// ...end of scope... // ~b frees (live -> 0), ~a is a no-op
Forecast: after the move, how many owners does the buffer have? What is a now?
a acquires. live = 1. Why this step? One owner exists.
std::move(a) transfers ownership to b. b takes the pointer; a's pointer is set to nullptr . Why this step? Move cheaply hands over ownership — exactly one owner at all times, so no double-free (contrast Ex 4).
Both destroy. ~b frees once (live = 0); ~a sees nullptr and does nothing. Why this step? The steal-then-null invariant means the emptied source is safe to destroy.
Verify: ledger 0 → +1 → -1 = 0, freed exactly once; a holds nullptr. ✅
Worked example Example 6 — Cell F: empty / null resource (degenerate input)
std ::unique_ptr <int> p; // holds nullptr, NEVER acquired: live stays 0
// ...
// p goes out of scope: destructor sees nullptr, calls delete on nullptr (safe no-op)
Forecast: a default-constructed unique_ptr owns nothing — does its destructor crash?
Default construct. p holds nullptr; no acquisition , live = 0. Why this step? This is the degenerate case: an owner that owns nothing (also the state of a moved-from object from Ex 5).
Destructor runs. It does delete nullptr, which the C++ standard defines as a no-op . Why this step? A correct RAII destructor must guard the empty case (if (f) in the parent's FileHandle) so it is harmless.
Verify: ledger never moves — starts 0, ends 0. No crash on empty. ✅
Worked example Example 7 — Cell G: nested scope, reverse-order teardown
struct Logger { const char* name; int order;
Logger ( const char* n , int o ): name (n), order (o) {} // acquire "log slot"
~Logger () { /* records teardown */ } };
void g () {
Logger a ( "a" , 1 ); // constructed 1st
Logger b ( "b" , 2 ); // constructed 2nd (may use a)
} // destroyed b (2) THEN a (1) -> LIFO
Forecast: in what order do the destructors run — a then b, or b then a?
Construct a, then b. Construction order 1, 2. Why this step? Later objects may depend on earlier ones (b might use a resource a owns).
Scope ends → destroy in reverse (LIFO): b (order 2) first, then a (order 1). Why this step? Reverse order keeps every dependency valid until the dependent is gone — the dependent dies before the thing it depends on.
Verify: destruction sequence is [2, 1], the exact reverse of construction [1, 2]. ✅
Worked example Example 8 — Cell H: real-world word problem (file + lock together)
Statement. A bank server function must: (1) lock a mutex protecting the ledger, (2) open a log file, (3) call credit() which may throw . With RAII, prove that a throw from credit() leaves neither the mutex locked nor the file open.
void record_transfer () {
std ::lock_guard < std ::mutex > lk (m); // acquire lock: locks 0 -> 1
std ::ofstream log ( "audit.txt" ); // acquire file: files 0 -> 1
credit (); // may throw here
} // unwinding: ~log closes file (files -> 0), ~lk unlocks (locks -> 0)
Forecast: if credit() throws, what are locks and files afterward?
Acquire lock, then file. locks = 1, files = 1. Why this step? Two resources owned by two stack objects.
credit() throws. Why this step? This combines Ex 2 (exception) with Ex 7 (multiple owners).
Reverse-order unwinding: ~ofstream closes the file (files = 0), then ~lock_guard unlocks (locks = 0). Why this step? Both destructors fire during unwinding, in LIFO order — no leak, no deadlock.
Verify: after the throw both counters are 0. Without RAII the mutex would stay locked (locks = 1 → future deadlock) and the file leaked (files = 1). ✅
Worked example Example 9 — Cell I: exam twist (throwing destructor)
Statement. During stack unwinding caused by exception E1, a local object's destructor itself throws exception E2. What does the program do, and how does the exception-safe fix work?
struct Bomb {
~Bomb () noexcept ( false ) { throw std :: runtime_error ( "boom" ); } // BAD
};
void f () {
Bomb b;
throw std :: runtime_error ( "first" ); // E1 -> unwinding -> ~Bomb throws E2
}
Forecast: two exceptions in flight at once — what happens?
E1 is thrown , unwinding begins. Why this step? Sets up the "already handling an exception" state.
~Bomb throws E2 mid-unwind. Why this step? The runtime now has two live exceptions and no defined way to resolve them.
Result: std::terminate() is called → program aborts. Why this step? The standard forbids a second exception escaping a destructor during unwinding.
Fix: make destructors noexcept and swallow or log errors during release instead of throwing:
~ Safe () noexcept { try { risky_close (); } catch (...) { /* log, don't rethrow */ } }
Verify: the number of exceptions allowed to be in flight simultaneously is 1; a second one triggers terminate — so a correct release path must throw 0 exceptions. ✅
Recall Which matrix cell is each danger?
Leak on an exception ::: Cell B — fixed by stack unwinding running the destructor.
Skipped cleanup on if(...) return; ::: Cell C — every exit path runs the destructor.
Same address freed twice ::: Cell D — default copy of a raw-pointer owner; fix with Rule of Five / unique_ptr.
Emptied object still safe to destroy ::: Cell F — destructor guards the null case (no-op).
std::terminate from cleanup ::: Cell I — throwing destructor during unwinding; make it noexcept.
A resource is managed correctly iff its live counter ends at 0 and never goes negative . Positive = leak, negative = double-free.