Exercises — RAII — resource acquisition is initialization — why it's the key idiom
Two ideas you will lean on constantly, restated in plain words:
- Destructor — a special member that runs automatically the moment an object dies (leaves its scope). See Constructors and Destructors.
- Stack unwinding — when a function exits for any reason (return,
break, or a thrown exception), every local object built so far is destroyed in reverse order of construction, and its destructor runs. See Stack Unwinding and Exceptions.
Everything below is just those two facts, applied harder and harder.
Level 1 — Recognition
L1.1 — Which line frees the memory?
void demo() {
auto p = std::make_unique<int>(42); // (A)
use(*p); // (B)
} // (C)Question: at which labelled point is the int on the heap released?
Recall Solution
At (C) — the closing brace. Why exactly there? p is a local object of demo, so its lifetime ends the instant control leaves demo's scope — and that scope ends at the closing brace (C). C++ guarantees that when a local object's scope ends, its destructor runs at that point; unique_ptr's destructor is the thing that calls delete on the managed int. So the release is pinned to (C) because that is where p dies, and death is when the destructor (the release code) fires. No explicit delete line exists precisely because the destructor is the release moment. See Smart Pointers - unique_ptr shared_ptr.
L1.2 — Name the RAII wrapper
For each raw resource, name the standard RAII type that owns it.
| Raw resource | RAII type? |
|---|---|
| single heap object, one owner | ? |
| heap object, shared owners | ? |
| a mutex lock | ? |
| a growable byte buffer | ? |
Recall Solution
- single owner →
std::unique_ptr<T> - shared →
std::shared_ptr<T>(reference-counted) - mutex lock →
std::lock_guard<std::mutex>(see std::lock_guard and Mutexes) - growable buffer →
std::vector<char>(orstd::string)
Why does std::vector<char> qualify as RAII? Its constructor acquires: vector calls the allocator (effectively new[]) to grab a heap buffer big enough for its elements. Its destructor releases: when the vector goes out of scope its destructor frees that buffer (effectively delete[]) automatically — you never write delete[] yourself. So a vector is a small owning object wrapped around a raw buffer: acquire-in-constructor, release-in-destructor, lifetime tied to scope. That is exactly the RAII pattern, which is why it replaces manual new[]/delete[].
The common thread: acquire in constructor, release in destructor — so scope, not a manual call, controls cleanup.
Level 2 — Application
L2.1 — Convert the leaky function
Rewrite so no leak is possible even if might_throw() throws.
void f() {
int* p = new int[100];
might_throw();
delete[] p;
}Recall Solution
void f() {
std::vector<int> v(100); // acquire in constructor
might_throw(); // throws? v's destructor still runs
} // release, alwaysWhy: new int[100] + delete[] is a manual acquire/release pair; the throw jumps over delete[]. std::vector puts the buffer inside an object, so stack unwinding frees it no matter how f exits.
L2.2 — Count destructor calls
struct Loud {
int id;
Loud(int i): id(i) {}
~Loud() { /* prints id */ }
};
void g() {
Loud a(1);
Loud b(2);
Loud c(3);
}In what order do the destructors run, and how many run in total?
Recall Solution
Total = 3 destructor calls. Order = 3, 2, 1 (reverse of construction — LIFO).
A purely textual trace, in case the figure below does not render:
| event | id 1 (a) |
id 2 (b) |
id 3 (c) |
|---|---|---|---|
| construct (top→bottom = latest→earliest) | 1st built | 2nd built | 3rd built |
| destroy order | 3rd (last) | 2nd | 1st (first) |
So construction runs 1, 2, 3 and destruction runs 3, 2, 1 — the exact reverse.
The figure makes the same point visually. What to observe: on the left, the three objects are stacked in build order — a (id 1) is created first and sits at the bottom, then b (id 2), then c (id 3) on top; the upward arrow is the direction of construction. On the right, the same stack is emptied from the top down: ~c runs first, then ~b, then ~a; the downward arrow is the direction of destruction. The two arrows point opposite ways — that opposition is the LIFO (last-in-first-out) rule. Each box is also labelled with its id, so you can follow it even without relying on colour.

Level 3 — Analysis
L3.1 — Spot the double-free
class Buf {
int* p;
public:
Buf(int n): p(new int[n]) {}
~Buf() { delete[] p; }
// no copy/move written
};
void bad() {
Buf a(10);
Buf b = a; // (*)
}Explain exactly what goes wrong at scope end.
Recall Solution
The compiler generates a default copy constructor at (*), which copies the pointer value p. Now a.p and b.p hold the same address. At scope end both destructors run: b's delete[] frees the block, then a's delete[] frees the same, already-freed block → double free (undefined behaviour). See Rule of Three Five Zero.
Fix (Rule of Five): either delete the copy operations, deep-copy the buffer, or store the data in a std::vector<int> (which copies correctly and needs no hand-written destructor).
L3.2 — Where does the mutex unlock?
std::mutex m; // the shared mutex
void transfer() {
std::lock_guard<std::mutex> lk(m); // (A) acquire the lock on m
debit();
credit(); // throws here
log(); // (never reached)
} // (B)credit() throws. Does the mutex stay locked? At which point does it unlock?
Recall Solution
The mutex unlocks — at (B), during stack unwinding. When credit() throws, the exception propagates out of transfer; before leaving the scope, the local lk is destroyed, and lock_guard's destructor calls m.unlock(). Without RAII a manual unlock() after credit() would be skipped, leaving the mutex locked forever → deadlock. See std::lock_guard and Mutexes and Exception Safety Guarantees.
L3.3 — The throwing destructor during unwinding
struct Risky {
~Risky() { throw std::runtime_error("boom"); } // destructor throws
};
void h() {
Risky r; // built first
throw std::logic_error("first"); // starts unwinding
} // ~Risky runs during unwinding... then throwsAn exception is already unwinding the stack when ~Risky runs and also throws. What does the program do, and why?
Recall Solution
The program calls std::terminate (which by default aborts). Why: when throw std::logic_error("first") fires, stack unwinding begins and ~Risky runs while an exception is already in flight. If a destructor throws a second exception during that unwinding, C++ has two live exceptions and no rule for which to propagate — so the language gives up and calls std::terminate. Fix / rule: a release-only destructor must never let an exception escape; mark it noexcept and swallow or log any error inside it. This is exactly why every RAII destructor you write below is marked noexcept. See Stack Unwinding and Exceptions and Exception Safety Guarantees.
Level 4 — Synthesis
L4.1 — Build a move-only file wrapper
Write a class File that owns a FILE*, opens in the constructor (throws on failure), closes in the destructor, forbids copying, and supports moving.
Recall Solution
class File {
FILE* f;
public:
explicit File(const char* name) // (1) acquire
: f(std::fopen(name, "r")) {
if (!f) throw std::runtime_error("open failed");
}
~File() noexcept { if (f) std::fclose(f); } // (2) release, always, noexcept
File(const File&) = delete; // (3) no copy
File& operator=(const File&) = delete; // (4)
File(File&& o) noexcept : f(o.f) { o.f = nullptr; } // (5) steal + null
File& operator=(File&& o) noexcept {
if (this != &o) {
if (f) std::fclose(f); // release our old handle first
f = o.f; // steal
o.f = nullptr; // leave source empty
}
return *this;
}
FILE* get() const { return f; }
};Why each piece: the constructor ties acquisition to birth; the destructor ties release to death (and is marked noexcept — recall L3.3: a release-only destructor that throws during stack unwinding calls std::terminate); copy is deleted so two Files can never fclose the same handle; move steals the pointer and nulls the source so exactly one owner ever closes. The move-assignment must close its own current handle first (otherwise that one leaks) before stealing. See Move Semantics and Rule of Three Five Zero.
L4.2 — Ownership walkthrough
Describe, step by step, what happens to the owned handle through: construct A, then B = std::move(A), then end of scope.
Recall Solution
Trace the single handle H through each event:
- Construct
A.Aopens/acquires handleH. State:AownsH;Bdoes not exist yet. B = std::move(A). The move constructor steals:B.f = A.f(soBnow ownsH), then nulls the source:A.f = nullptr. State:BownsH;Aholdsnullptr.- End of scope — destructors run in reverse (LIFO), so
Bfirst, thenA.~Bsees a real handle and closesH(fclose).~Aseesnullptr, so itsif (f)guard is false and it closes nothing.
Invariant preserved: across the whole sequence there was exactly one acquire (step 1) and exactly one release (step 3, by B). A never double-closes because the move nulled it. This is why "steal and null" is a single indivisible step — nulling the source is what keeps the one-owner-one-release invariant true. See Move Semantics.
The same flow of ownership, written as a plain text table (no diagram needed):
| event | A holds |
B holds |
handle H |
|---|---|---|---|
after construct A |
H |
— | open, owned by A |
after B = move(A) |
nullptr |
H |
open, owned by B |
scope end → ~B |
nullptr |
(dying) | closed by ~B |
scope end → ~A |
(dying, nullptr) |
gone | already closed; ~A closes nothing |
Reading the table top to bottom: ownership of H moves from A to B, and only B ever closes it — a single acquire and a single release.
Level 5 — Mastery
L5.1 — Predict the reference count
auto p = std::make_shared<int>(7); // (A) count = ?
{
auto q = p; // (B) count = ?
auto r = std::move(q); // (C) count = ? what is q now?
} // (D) count = ?
// (E) count = ?Give the reference count at each point and the state of q after (C).
Recall Solution
- (A) count = 1 — only
p. - (B) count = 2 —
qis a copy, so it shares ownership (count incremented). - (C) count = 2 — a move transfers
q's share tor; the total number of owners is unchanged. After the moveqis empty (q == nullptr), so it holds no share. - (D) end of inner scope:
randqare destroyed.qwas empty (no decrement);rreleases its share → count = 1. - (E) count = 1 — only
premains.
The heap int is freed only when the count reaches 0, i.e. when p finally dies. Copy raises the count; move relocates a share without changing it. See Smart Pointers - unique_ptr shared_ptr.
L5.2 — The strong exception-safety guarantee
A function does two side effects that must both succeed or neither. Which RAII pattern gives the strong guarantee (on failure, state is unchanged), and why does "commit last" matter?
Recall Solution
The pattern: do all the work that can throw on a side copy / staging area first, then perform a single non-throwing commit (a swap or a noexcept move) as the very last step. If any early step throws, stack unwinding destroys the staging RAII objects and the original state is left completely untouched — that is the strong guarantee (on failure, observable state is exactly as it was before the call).
Why "commit last" is the whole trick: the commit is the only step that mutates the real state, and it is chosen to be an operation that cannot throw (a swap of pointers, or a noexcept move). Because it cannot throw, once you reach it success is certain — there is no half-mutated state possible. Everything before the commit touches only the throwaway copy, so a failure there discards the copy and changes nothing real.
Contrast — the basic guarantee: if instead you mutate the real state directly and an exception fires halfway, you get the weaker basic guarantee: no leak and no corruption (all invariants still hold, resources freed by RAII), but the object may be left in a valid-yet-changed state — some of the work happened, some didn't. Basic = "nothing leaks, still usable"; strong = "nothing leaks and it looks like the call never happened." The commit-last-with-a-noexcept-step recipe is precisely what upgrades basic to strong. See Exception Safety Guarantees.
L5.3 — Order-of-destruction dependency puzzle
void h() {
Logger log; // opens a log file
Session s(log); // s writes to log during its own destruction
}Is this safe? Justify using destruction order.
Recall Solution
Safe. log is constructed first, s second. Destruction is reverse order: s first, then log. So when ~Session runs and writes its final message, log is still alive (its destructor hasn't run yet). The dependency "s depends on log" is honoured precisely because the depended-upon object (log) is destroyed last. Reverse-order destruction is exactly what makes such dependency chains valid. Reordering the declarations (Session before Logger) would break it.
Active recall
Recall Rapid self-check
- In L1.1, why is there no explicit
delete? (the destructor is the release moment) - In L2.2, how many destructors run and in what order? (3; ids 3,2,1)
- In L3.3, what happens if a destructor throws during unwinding? (std::terminate)
- In L3.1, what UB occurs and why? (double free — shared pointer value)
- In L5.1, what is the count at (C) and the state of
q? (2; q is empty) - In L5.2, what upgrades basic to strong? (work on a copy, commit last with a noexcept swap/move)
- In L5.3, why is writing to
logfrom~Sessionsafe? (log outlives s — destroyed last)