04 · RAII Deep Dive¶
Smart pointers and
std::lock_guard are both instances of one idiom:
Resource Acquisition Is Initialization. Bind a resource's lifetime to an
object's lifetime — acquire it in the constructor, release it in the
destructor — and the language's own scope rules guarantee cleanup, even when
an exception unwinds the stack. This module generalizes RAII beyond memory
and mutexes to any resource, and covers the exception-safety guarantees it
enables.
Why manual cleanup fails¶
#include <cstdio>
#include <stdexcept>
void processFile(const char* path, bool shouldFail) {
FILE* f = std::fopen(path, "r");
if (!f) throw std::runtime_error("cannot open file");
if (shouldFail) {
throw std::runtime_error("something went wrong mid-processing");
// fclose(f) below is NEVER reached -- the file handle leaks.
}
std::fclose(f);
}
Every early return, every exception thrown between acquisition and release,
is a leak waiting to happen. Wrapping every function in try { ... } finally
{ cleanup(); } (a keyword C++ doesn't even have) is exhausting and easy to
get wrong once a function has several resources or several exit points.
A minimal RAII wrapper¶
#include <cstdio>
#include <stdexcept>
#include <utility>
class FileHandle {
public:
explicit FileHandle(const char* path, const char* mode) : file_(std::fopen(path, mode)) {
if (!file_) throw std::runtime_error("failed to open file");
}
~FileHandle() {
if (file_) std::fclose(file_); // ALWAYS runs -- normal return or exception
}
// Resources that aren't naturally shareable should be move-only:
// deleting copy prevents two FileHandles from both fclose()-ing the same FILE*.
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&& other) noexcept : file_(other.file_) { other.file_ = nullptr; }
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
if (file_) std::fclose(file_);
file_ = other.file_;
other.file_ = nullptr;
}
return *this;
}
FILE* get() const { return file_; }
private:
FILE* file_;
};
void processFile(const char* path, bool shouldFail) {
FileHandle f(path, "r"); // acquired here
if (shouldFail) {
throw std::runtime_error("something went wrong mid-processing");
// f's destructor STILL runs during stack unwinding -- file closed, guaranteed.
}
// ... use f.get() ...
} // f's destructor runs here on the normal path
Notice the shape: this is the exact pattern std::unique_ptr uses for
memory, std::lock_guard uses for mutexes, and std::ifstream uses for
files internally. Once you've written one RAII wrapper, you recognize the
idiom everywhere in the standard library.
Scope guards for one-off cleanup¶
Writing a whole class for a resource you only clean up in one place is overkill. A small "run this lambda on scope exit" wrapper covers that case generically:
#include <iostream>
#include <functional>
#include <utility>
class ScopeGuard {
public:
explicit ScopeGuard(std::function<void()> onExit) : onExit_(std::move(onExit)) {}
~ScopeGuard() { if (onExit_) onExit_(); }
ScopeGuard(const ScopeGuard&) = delete;
ScopeGuard& operator=(const ScopeGuard&) = delete;
void dismiss() { onExit_ = nullptr; } // cancel cleanup, e.g. after a successful commit
private:
std::function<void()> onExit_;
};
void demo(bool commit) {
std::cout << "acquiring temp resource" << std::endl;
ScopeGuard cleanup([] { std::cout << "rolling back temp resource" << std::endl; });
if (commit) {
std::cout << "committed successfully" << std::endl;
cleanup.dismiss(); // success path: skip the rollback
}
} // if not dismissed, cleanup's destructor prints the rollback message here
int main() {
demo(true);
std::cout << "---" << std::endl;
demo(false);
}
// acquiring temp resource
// committed successfully
// ---
// acquiring temp resource
// rolling back temp resource
This "acquire, then guard a rollback that's cancelled on success" shape is extremely common for things like temporary files, database transactions, or partially-built objects — commit cancels the guard, any early exit (including an exception) lets it fire.
The three exception-safety guarantees¶
RAII is what makes these guarantees achievable in practice, not just theory.
| Guarantee | Promise | Example |
|---|---|---|
| Basic | No leaks, no corruption; object is left in some valid state | std::vector::push_back leaves the vector valid even if the value's copy throws |
| Strong | Operation either fully succeeds or has no visible effect — like a transaction | std::vector::push_back when reallocation is needed: old buffer is untouched if a move/copy throws mid-way |
| Nothrow | The operation is guaranteed never to throw | std::vector::pop_back, swap, most destructors, moves marked noexcept |
Destructors in particular should almost never throw. If a destructor throws
while the stack is already unwinding from another exception, the program
calls std::terminate() immediately — there's no way to have two exceptions
in flight at once. This is why ~FileHandle() above doesn't check
fclose's return value with a throw; cleanup code swallows its own
errors or logs them, it doesn't propagate them.
Rule of Zero, revisited¶
The best RAII code is code you don't have to write by hand at all:
#include <memory>
#include <string>
#include <vector>
// No destructor, no copy/move operations written -- and none needed.
// unique_ptr, string, and vector each already manage their own resource
// correctly, so the compiler-generated destructor/copy/move for Report
// just calls each member's, in order. This is RAII composing for free.
class Report {
public:
Report(std::string title) : title_(std::move(title)), data_(std::make_unique<std::vector<int>>()) {}
private:
std::string title_;
std::unique_ptr<std::vector<int>> data_;
};
Reach for a hand-written destructor (and therefore the full Rule of Five)
only when your class directly owns a non-RAII resource — a raw FILE*, an
OS handle, a socket descriptor. Everywhere else, compose existing RAII types
and let the compiler write the boilerplate.
Cheat sheet¶
| Resource | RAII wrapper |
|---|---|
| Heap memory | std::unique_ptr / std::shared_ptr |
| Mutex lock | std::lock_guard / std::unique_lock / std::scoped_lock |
| File handle | std::fstream family, or a custom wrapper around FILE* |
| One-off cleanup action | A small ScopeGuard with a cancellable lambda |
| A group of resources | Compose the above as data members (Rule of Zero) |
Traps¶
A throw inside a destructor is close to unforgivable. If it fires
during unwinding from another exception, std::terminate() ends the program
with no further recovery possible.
Freeing a resource twice is as bad as never freeing it. Move-only RAII
types (like FileHandle above) must null out the source's handle in their
move constructor/assignment, or both the moved-from and moved-to objects will
try to release the same resource in their destructors.
RAII protects against exceptions and early returns — it doesn't replace
validating results. A destructor still shouldn't ignore an error signal
silently; log it, or provide an explicit close()/commit() method that can
report failure, with the destructor as the guaranteed fallback.
How It Actually Works¶
RAII isn't a library feature — it's a direct consequence of one guarantee
the C++ standard makes about object lifetime: a local (automatic-storage)
object's destructor is called deterministically, at the exact point
execution leaves its scope, whether by falling off the end of a block, an
early return, a break/continue, or — critically — an exception
unwinding past it (see Level 1's exception module). No garbage collector,
no "eventually" — the compiler knows the object's scope at compile time and
emits a direct destructor call at every point control can leave that scope,
including duplicating that call across every early-exit path.
For an object with multiple members, destruction order is the exact
reverse of construction order — this is a hard guarantee, not an
implementation detail, and it's what makes RAII compose safely: if a class
holds a std::lock_guard constructed after a std::ofstream, the lock is
released before the file stream's destructor runs and closes the file,
matching the dependency direction you'd expect. The same reverse-order rule
applies to a chain of nested scopes and to base/member subobjects within one
object (members destroyed in reverse declaration order, then base classes
destroyed after all members, mirroring construction which does bases first).
This determinism is also why C++ doesn't need a finally block the way
Java does: wrapping a resource in a small RAII type and simply letting it go
out of scope achieves the same guaranteed cleanup, but works uniformly
whether the scope ends normally or via an exception three functions up the
call stack — the unwinding mechanism runs every intervening destructor
regardless of how far it has to unwind, without any explicit cleanup code at
each call site.
Exercise¶
Write a MutexUnlocker RAII class that's the mirror image of lock_guard:
it takes an already-locked std::unique_lock<std::mutex>&, unlocks it in
its constructor, and re-locks it in its destructor — useful for temporarily
releasing a lock around a slow, non-thread-sensitive operation (like a
network call) inside an otherwise-locked function. Then extend the
ScopeGuard above into a tiny TransactionGuard used around three
sequential steps (e.g. printing "step 1/2/3 committed"); trigger a simulated
failure after step 2 and confirm the guard's rollback message fires while
steps 1–2's "commits" do not get undone individually — only the guard's own
single rollback action runs.