RAII: the central idea of C++
Lesson 04 ended on a defeat: with raw new/delete, freeing memory correctly is unwinnable by discipline alone. A single early return, or an exception thrown between the new and the matching delete, silently leaks — and the more error paths a function grows, the more places you must remember to free. The one new idea in this lesson dissolves that whole problem: tie a resource's lifetime to the lifetime of a local object, so that cleanup is not something you remember to do but something the language does for you, deterministically, on every exit path. This is RAII, and it is the idea that organizes everything that follows.
new/delete, and the three failure modes (leak, dangling pointer / use-after-free, double free), and you have seen that an early return or a thrown exception between new and delete leaks. You should also recall from Lesson 02 that automatic (stack) objects are destroyed at scope exit, and from Lesson 03 what a pointer is.New capability: design a type whose constructor acquires a resource and whose destructor releases it, so cleanup runs automatically and deterministically at scope exit — including on early return and on a thrown exception — and explain why this is the one discipline no garbage-collected language can teach you.
FileHandle that owns a file and watch it survive the exact early-return and exception paths that leaked in Lesson 04. (4) Contrast RAII against the two alternatives the rest of the world uses — garbage collection and try/finally — and show why both are weaker. (5) Generalize: any resource, not just memory, and a second worked example, ScopedTimer.1 · The hole Lesson 04 left
Here is the shape of the problem from Lesson 04, made concrete. A function opens a file, does some work, and must close it before returning. With the raw C-style API, "close it" is a statement you write, and you must write it on every path out of the function:
// Lesson-04 style: manual cleanup, written by hand on every exit path.
void process(const char* path) {
std::FILE* f = std::fopen(path, "r"); // ACQUIRE the resource
if (!f) return; // (nothing to free yet — fine)
char line[256];
if (!std::fgets(line, sizeof line, f)) {
return; // BUG: early return — we never fclose(f). LEAK.
}
if (line[0] == '#') {
throw std::runtime_error("comment"); // BUG: throw — fclose(f) skipped. LEAK.
}
// ... real work ...
std::fclose(f); // the ONLY path that frees it
}
There are three exits from this function and only one of them frees the file. The leak is not a typo — it is the structural fact that cleanup is decoupled from the resource. Every new return, every break, every exception that can propagate through is one more place the author must remember to insert an fclose. As Lesson 04 argued, this does not scale: a function with five error paths needs the cleanup repeated five times, and the sixth path someone adds next year will forget. Discipline does not compose.
What is missing is a hook the language guarantees to run — a single place to write "close the file" that fires no matter how control leaves the function. C++ has exactly such a hook, and it is attached not to the function but to an object.
2 · The destructor, and the one rule that makes RAII work
Recall from Lesson 02 that a local variable has automatic storage duration: it lives on the stack, and it is destroyed automatically when the scope it was declared in ends. C++ lets a class define exactly what "destroyed" means by giving it a destructor — a special member function named ~ClassName(), taking no arguments, that the compiler calls automatically at the end of the object's lifetime. Its mirror image is the constructor, the function that runs when the object is created. The whole technique is just this pairing:
return, on break, and crucially on a thrown exception passing through. You cannot forget it; you cannot skip it.The name RAII stands for Resource Acquisition Is Initialization: you acquire the resource in the act of initializing an object, which binds the resource's lifetime to that object's scope. The name is famously bad — the load-bearing half is the release in the destructor, not the acquisition — but the idea is precise and it is the most important one in the language.
The reason the destructor cannot be skipped on an exception is stack unwinding. When a throw happens, control does not simply jump to the handler; the runtime walks back up the call stack frame by frame, and as it leaves each scope it runs the destructor of every fully-constructed automatic object in that scope, in reverse order of construction, before moving to the next frame up. That is the mechanism. (Lesson 15 builds the entire treatment of exception safety on top of this; for now just hold the fact: unwinding runs destructors, so an RAII object cleans up even while an exception is tearing the stack down.)
3 · Worked example: a FileHandle that cannot leak
Now rewrite the leaky process with a tiny class that owns the file. The constructor opens; the destructor closes. Nothing else changes about the logic.
class FileHandle {
std::FILE* f_;
public:
explicit FileHandle(const char* path, const char* mode)
: f_(std::fopen(path, mode)) {} // ACQUIRE in the constructor
~FileHandle() { // RELEASE in the destructor
if (f_) std::fclose(f_); // runs at scope exit, always
}
bool ok() const { return f_ != nullptr; }
std::FILE* get() const { return f_; }
// We OWN the file, so forbid copying for now — a copy would mean two
// handles closing the same FILE* (a double free). Lesson 06 fixes this
// properly with move semantics; Lesson 07 makes ownership a type.
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
};
void process(const char* path) {
FileHandle file(path, "r"); // constructor opens the file
if (!file.ok()) return; // early return — ~FileHandle() still runs
char line[256];
if (!std::fgets(line, sizeof line, file.get())) {
return; // early return — ~FileHandle() runs → fclose
}
if (line[0] == '#') {
throw std::runtime_error("comment"); // throw — unwinding runs ~FileHandle()
}
// ... real work ...
} // normal exit — ~FileHandle() runs → fclose
process and note that the destructor fires on every one:
Notice what made this work: file is a plain automatic (stack) object. We never wrote new or delete; the resource it owns happens to be a FILE*, but the handle lives on the stack and the stack's destruction rules do the rest. This is the modern-C++ stance the orientation promised: you almost never call delete by hand — you build a small owning object and let its destructor run.
4 · Why not garbage collection, and why not try/finally
If you come from Python, Java, JavaScript, Go, or C#, you have two reflexes for this problem, and it is worth seeing precisely why C++ chose neither.
| Approach | When does cleanup run? | What it covers | The catch |
|---|---|---|---|
| Garbage collection (Java, Go, Python, JS) | Eventually — at some unspecified later time the collector chooses, possibly never before exit | Memory only | Non-deterministic and memory-only: the GC reclaims heap memory when it gets around to it, but it does not promptly close your file, release your lock, or flush your socket. Those are not memory, and the collector neither knows nor cares about them. |
try/finally (Java, Python, JS) | At the end of the try block, deterministically | Anything you write in finally | Manual and repeated: you write the cleanup, at every site that acquires, and you must remember the finally each time. Same "discipline doesn't compose" failure as §1, just with a guaranteed-to-run block. |
| RAII (C++) | Deterministically at scope exit, on every path including exceptions | Any resource — memory, files, locks, sockets, timers, transactions | You write the cleanup once, in the destructor, when you design the type. Every use site gets it for free. |
The GC point is the sharp one. A garbage collector solves memory leaks — and only at a time it chooses. But a file handle, a database connection, a held mutex, a GPU buffer: these are scarce resources where when you release matters as much as whether. Holding a file open "until the GC runs" can mean holding it for seconds, or until the program exits — which is why GC languages had to bolt on a second mechanism (try/finally, with in Python, try-with-resources in Java, defer in Go, using in C#) precisely because the collector cannot do deterministic, prompt, non-memory cleanup. RAII needs no second mechanism: the same scope rule that frees memory frees everything.
finally/defer block papered over it for everything else. C++ makes object lifetime the tool for managing every resource, which is why a C++ programmer's instinct — "wrap the resource in a type whose destructor releases it" — transfers back to Go's defer, Python's context managers, and Rust's ownership model (a direct, enforced descendant of RAII). Learning it once rewires how you think about cleanup in every language.5 · Generalize: any resource, and a second worked example
Memory is just one resource. The acquire-in-constructor / release-in-destructor pattern applies to anything with a "set up" and a "tear down" that must be paired: a mutex (lock / unlock — Lesson 17 makes RAII locks the only sane way to hold one), a database transaction (begin / commit-or-rollback), a buffer borrowed from a pool, even something as abstract as "the duration of a code block." Here is a ScopedTimer that measures how long its scope took — the cleanup is not freeing anything, it is reporting, and it must run on every exit just the same:
class ScopedTimer {
const char* name_;
std::chrono::steady_clock::time_point start_;
public:
explicit ScopedTimer(const char* name)
: name_(name), start_(std::chrono::steady_clock::now()) {} // ACQUIRE: start clock
~ScopedTimer() { // RELEASE: report
auto end = std::chrono::steady_clock::now();
auto us = std::chrono::duration_cast<std::chrono::microseconds>(end - start_).count();
std::printf("%s took %lld us\n", name_, static_cast<long long>(us));
}
};
double risky_compute(const std::vector<double>& data) {
ScopedTimer t("risky_compute"); // clock starts here
if (data.empty())
return 0.0; // timer still reports on this early return
if (data[0] < 0)
throw std::domain_error("neg"); // timer still reports while unwinding
// ... expensive work ...
return /* result */ 0.0;
} // timer reports here on normal exit
You construct one local ScopedTimer at the top of any scope you want to measure and forget about it; the destructor prints the elapsed time when the scope ends, on every path, including the throw. No finally, no remembering to stop a stopwatch, no duplicated reporting code on each return. This is the same mechanism as FileHandle — the only difference is what "release" means. That generality is why RAII is called the idea of C++ rather than just a memory trick.
6 · Common mistakes / failure modes
std::terminate and dies. Destructors must not throw — swallow or log errors in cleanup, never propagate them. (Lesson 15 returns to this.)auto* fh = new FileHandle(...); defeats the whole point — now the handle itself leaks unless you delete it, and you are back to Lesson 04. Keep the RAII object on the stack (automatic storage) so its destructor is guaranteed.FileHandle allowed copies, two handles would hold the same FILE* and both destructors would fclose it — a double free (Lesson 04). We = deleted copying as a stopgap; Lesson 06 (move) and Lesson 07 (smart pointers) solve it properly.Checkpoint exercise
process and the §3 FileHandle version. (1) Compile both with -fsanitize=address (AddressSanitizer) — or run them under valgrind — feeding a file whose first line starts with # so the throw path fires, and confirm the raw version reports a leaked FILE* while the RAII version reports none. (2) Add a std::printf("opening\n") in the constructor and std::printf("closing\n") in the destructor of FileHandle, then trigger each of the four exit paths from the §3 trace and confirm "closing" prints exactly once every time. (3) As a thought experiment: write what the equivalent ScopedTimer would look like in Python using a try/finally, and count how many lines you must repeat at each call site versus the C++ version's zero.Where this points next
RAII says "the owning object's destructor frees the resource." That immediately raises a question we dodged with = delete: what happens when you copy or pass such an object? If two FileHandles end up owning the same file, both destructors fire and you have the double free from Lesson 04 back again. Lesson 06 — copy, move, and value semantics — answers it: it defines what copying an owning object should mean (deep copy vs. transfer), introduces move semantics so ownership can be handed off cheaply instead of duplicated, and gives you the rule of zero/three/five for deciding which special functions a resource-owning class needs. RAII is the what; copy and move are the how a value carrying a resource flows through your program.
return, and a thrown exception, because stack unwinding runs destructors (the hook Lesson 15 builds exception safety on). This makes cleanup deterministic and automatic instead of a discipline you must repeat at every exit — directly curing the leak failure mode from Lesson 04 — and it applies to any resource (files, locks, sockets, timers), not just memory. It is strictly stronger than garbage collection (non-deterministic and memory-only, so GC languages still bolt on finally/defer/with for prompt non-memory cleanup) and than try/finally (manual and repeated at every call site). It is the discipline no garbage-collected language forces you to learn — and once you have, you see it in Go's defer, Python's context managers, and Rust's ownership everywhere.Interview prompts
- What does RAII stand for, and which half of the name actually does the work? (§2 — Resource Acquisition Is Initialization; the load-bearing half is release in the destructor, which runs deterministically at scope exit — the name emphasizes the acquisition but the guarantee is about cleanup.)
- Why does an RAII object clean up even when an exception is thrown? (§2 — stack unwinding: a
throwwalks back up the stack and runs the destructor of every fully-constructed automatic object in each scope it leaves, in reverse order, before reaching the handler — Lesson 15 builds on this.) - How does RAII fix the leak from the manual
new/deleteversion ofprocess? (§1, §3 — it moves the single cleanup statement from every exit path into the destructor, written once; the destructor then fires on all exits, so there is no cleanup left to forget.) - Why isn't garbage collection enough — what does it fail to do that RAII does? (§4 — GC is non-deterministic and memory-only; it does not promptly release files, locks, or sockets, which is exactly why GC languages add
try/finally,with,defer, orusingfor deterministic non-memory cleanup.) - How does RAII differ from
try/finally? (§4 — both are deterministic, buttry/finallyis manual and must be repeated at every acquisition site, whereas RAII writes the cleanup once in the type and every use site inherits it.) - Why must a destructor never throw? (§6 — if it throws while an exception is already unwinding the stack, the program calls
std::terminate; cleanup errors must be swallowed or logged, not propagated.) - Why did we keep the
FileHandleon the stack rather thannewit, and why delete its copy constructor? (§3, §6 — a heap-allocated handle would itself need a manualdelete, reintroducing the leak; copying would let two handles close the sameFILE*— a double free — until move/ownership in Lessons 06–07 handle it correctly.)