3. RAII: the core idea of C++¶
Time: ~2.5 hours Prerequisites: 02_classes_constructors_destructors.md
1. In plain words¶
RAII stands for "Resource Acquisition Is Initialisation", which is a terrible name for a simple idea: tie every resource to the lifetime of an object. Acquire the resource in the constructor. Release it in the destructor. Because the destructor runs automatically when the object goes out of scope (module 02), the resource is released automatically, on every path, including the paths you forgot about.
A resource is anything you must give back: heap memory, a file descriptor, a socket, a mutex lock,
a database cursor, a reference count, a temporary directory. In C you track each one by hand and
write goto cleanup ladders. In C++ you wrap each one once, in a small class, and never think about
it again.
Analogy: a library book with an automatic return date. You do not remember to return it; it returns itself when you leave the building. You cannot forget, and you cannot return it twice.
2. The real thing¶
2.1 The C problem¶
int process(const char* path) {
FILE* f = fopen(path, "r");
if (!f) return -1;
char* buf = malloc(4096);
if (!buf) { fclose(f); return -1; } /* path 1 */
if (read_header(f, buf) != 0) { free(buf); fclose(f); return -1; } /* path 2 */
if (validate(buf) != 0) { free(buf); fclose(f); return -1; } /* path 3 */
/* ... every new early return needs the same two lines ... */
free(buf);
fclose(f);
return 0;
}
Every exit path must release everything acquired so far, in reverse order. Add a third resource and the number of cleanup lines grows with the number of exits. Miss one and you leak; release twice and you crash. Your Berkeley DB migration engine almost certainly has functions shaped like this.
2.2 The C++ solution¶
int process(const char* path) {
FileHandle f(path, "r"); // throws if fopen fails
std::vector<char> buf(4096); // owns its memory
if (read_header(f.get(), buf.data()) != 0) return -1; // f and buf released automatically
if (validate(buf.data()) != 0) return -1; // same
return 0;
} // same
Three exits, zero cleanup lines. Add a fourth resource: still zero cleanup lines. The compiler generates the release calls, in the right order (reverse of acquisition), on every path.
2.3 The three rules of an RAII class¶
- Acquire in the constructor. If acquisition fails, throw (or make the object represent "empty"
and give it an
explicit operator bool). An object that exists must be usable. - Release in the destructor. The destructor must not throw and must handle the "moved-from" or "empty" state (releasing nothing).
- Decide copy semantics. Either delete copy (one owner:
unique_ptr-like), implement deep copy (value-like:std::string), or share with reference counting (shared_ptr-like). The default memberwise copy is wrong for raw handles. Always make the choice explicit.
2.4 Exceptions and stack unwinding¶
When an exception is thrown, the runtime walks back up the call stack ("unwinds") looking for a
matching catch. For every stack frame it passes, it runs the destructors of all fully constructed
local objects in reverse order. That is the mechanism that makes RAII complete: a return, a
break, falling off the end, and a throw all lead to the same destructor calls.
main()
└ handle_request() Guard g1; <- ~g1 runs third
└ parse() Buffer b; <- ~b runs second
└ read_field() Lock l; <- ~l runs first
throw ParseError{...};
Without RAII, a thrown exception would skip your manual cleanup code entirely. This is why "exceptions vs error codes" (module 10) is not really a choice in C++: once you use any library that throws (and the standard library does, on allocation failure), only RAII keeps the program correct.
2.5 RAII beyond memory and files¶
| Resource | Standard RAII type |
|---|---|
| heap object | std::unique_ptr<T>, std::shared_ptr<T> (module 05) |
| array of T | std::vector<T> |
| string | std::string |
| mutex lock | std::lock_guard, std::unique_lock, std::shared_lock (04_Concurrency) |
| thread that must be joined | std::jthread (C++20) or your own wrapper |
| file stream | std::ifstream / std::ofstream |
C FILE*, fd, socket, library handle |
write a 20-line wrapper, or unique_ptr with a custom deleter |
| "undo this on exit" | a scope guard (exercise 4) |
3. Code¶
code/m03_file_handle_raii.cpp is fopen/fclose done properly:
class FileHandle {
public:
FileHandle(const char* path, const char* mode) : f_(std::fopen(path, mode)) {
if (!f_) throw std::system_error(errno, std::generic_category(), std::string("fopen ") + path);
}
~FileHandle() { if (f_) std::fclose(f_); } // runs on every exit path
FileHandle(const FileHandle&) = delete; // one owner
FileHandle& operator=(const FileHandle&) = delete;
FileHandle(FileHandle&& o) noexcept : f_(o.f_) { o.f_ = nullptr; } // ownership transfer
FileHandle& operator=(FileHandle&& o) noexcept {
if (this != &o) { if (f_) std::fclose(f_); f_ = o.f_; o.f_ = nullptr; }
return *this;
}
std::FILE* get() const noexcept { return f_; } // hand the raw handle to C APIs
private:
std::FILE* f_;
};
void write_report(const char* path, bool fail_midway) {
FileHandle out(path, "w");
std::fputs("header\n", out.get());
if (fail_midway) throw std::runtime_error("disk quota exceeded (simulated)");
std::fputs("body\n", out.get());
} // fclose happens here on both paths
code/m03_socket_fd_raii.cpp is the pattern tcpkv uses for every socket, a move-only fd owner:
class SocketFd {
public:
SocketFd() noexcept = default;
explicit SocketFd(int fd) noexcept : fd_(fd) {}
~SocketFd() { reset(); }
SocketFd(const SocketFd&) = delete;
SocketFd& operator=(const SocketFd&) = delete;
SocketFd(SocketFd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {}
SocketFd& operator=(SocketFd&& o) noexcept { if (this != &o) { reset(); fd_ = std::exchange(o.fd_, -1); } return *this; }
int get() const noexcept { return fd_; }
bool valid() const noexcept { return fd_ >= 0; }
int release() noexcept { return std::exchange(fd_, -1); } // opt out: give the fd back to manual code
void reset(int fd = -1) noexcept { if (fd_ >= 0) ::close(fd_); fd_ = fd; }
private:
int fd_ = -1;
};
SocketFd make_listener(std::uint16_t port) {
SocketFd s(::socket(AF_INET, SOCK_STREAM, 0));
if (!s.valid()) throw std::system_error(errno, std::generic_category(), "socket");
// ... setsockopt, bind, listen; each failure throws and s closes itself ...
return s; // ownership travels to the caller
}
Note the std::exchange(o.fd_, -1) idiom: read the old value and store the new one in one line.
It is the standard way to write "steal and empty the source".
4. Under the hood (senior-level)¶
Zero-cost exceptions. GCC implements exceptions with tables, not with code on the normal path.
For each function, the compiler emits an entry in .eh_frame (how to restore registers and find the
caller's frame) and .gcc_except_table (which destructors to call for which range of instructions,
and which catch clauses apply). On the happy path there is no check, no flag, no extra
instruction: a function with RAII objects runs as fast as the C version without cleanup code.
When throw executes:
__cxa_allocate_exceptiongets memory for the exception object (heap, with a small emergency pool forbad_alloc), the object is copied/moved there.__cxa_throwcalls_Unwind_RaiseException(libgcc). Phase 1 ("search"): walk frames using.eh_frame, ask each frame's personality routine (__gxx_personality_v0) whether it has a matching handler. If none,std::terminate.- Phase 2 ("cleanup"): walk again; for each frame, jump to its landing pad, a block of code the
compiler generated that runs the destructors of live locals and then calls
_Unwind_Resume. - At the handler frame, control enters the
catchblock.__cxa_begin_catch/__cxa_end_catchmanage the exception object's lifetime.
Costs: a throw is expensive (microseconds: two stack walks, table lookups, often a lock inside the
unwinder for dl_iterate_phdr). Exceptions are for exceptional paths, not for control flow in a hot loop.
Landing pads are why destructors matter even without try. A function with no try block still
has landing pads if it has objects with non-trivial destructors; the pads run the destructors and
resume unwinding. A function whose locals are all trivial has no landing pad and the unwinder skips
straight through it.
noexcept on destructors. Destructors are implicitly noexcept. If one throws during unwinding
(two exceptions in flight), std::terminate is called. This is why ~FileHandle ignores fclose's
return value: there is no good way to report it. If flush errors matter, offer an explicit
close() method that can throw, and let the destructor be the fallback.
Why -fno-exceptions is not a free lunch. Some embedded teams disable exceptions. Then
new failing calls std::abort, constructors cannot report failure, and you are back to two-phase
init. For a Linux server, keep exceptions on and use them for genuinely exceptional failures.
Static vs dynamic cost of RAII. The destructor call at scope end is a direct call (or inlined).
A std::lock_guard compiles to the same pthread_mutex_lock/pthread_mutex_unlock pair you would
write by hand; look at g++ -O2 -S output to confirm there is nothing else.
5. In production¶
- Your C leak bugs, classified. Early-return leaks, error-path double-frees, "forgot to close the
cursor on the exception path", lock not released when a function bails out: all are the same bug
class, and all vanish when each resource lives in an object. In code review, a raw
fopen,malloc,socket,pthread_mutex_lockoutside a wrapper's constructor is a finding. - Ownership transfer across layers.
make_listenerreturns aSocketFdby value: the caller now owns it, and the function cannot "forget" to hand it over. In C the same function returns anintand the documentation has to say who closes it. - Interfacing with C APIs that take ownership. Use
release()to hand the raw handle to code that will close it itself (e.g. passing an fd toepollplus a struct that owns it). Document everyrelease()call site; they are the only places the compiler stops protecting you. - The destructor-order guarantee is a tool. Declare the lock guard after the object it protects, and a logger before the things that log, so they die in the right order.
- 3 AM debugging.
lsof -p <pid>shows thousands of open fds climbing over hours: some code path gets an fd fromaccept()and stores it asint. Fix:SocketFd conn(::accept(...))at the one place the fd is born; the leak is then impossible rather than merely fixed. - Valgrind / ASan after RAII. Leaks found by
valgrind --leak-check=fullin RAII code almost always point to arelease()without a matching manual close, or to ashared_ptrcycle (module 05).
6. Try it¶
- (easy) Build
m03_file_handle_raii.cpp. Add a third exit path (returnearly when the path contains "skip"). Done when you can show thatfclosestill runs without touching the function body. - (easy) Write
class DirHandlewrappingopendir/closedirwith the same shape asFileHandle. Done when it is move-only, non-copyable, and lists the entries of/tmp. - (medium) Rewrite the C
process()function from section 2.1 in C++ with RAII. Count the lines before and after. Done when the C++ version has zero explicit cleanup calls. - (medium) Write a generic
ScopeGuardthat takes a lambda in its constructor and calls it in its destructor, with adismiss()method. Use it to roll back a partially applied change (map.erase(key)if a later step fails). Done when the rollback runs onthrowand is skipped afterdismiss(). - (hard) Run
m03_socket_fd_raii.cppunderstrace -e trace=socket,bind,listen,closeand confirm exactly oneclosepersocket. Then add a bug (a copy of the fd into a secondintthat is also closed) and observeEBADF. Done when you can explain which line the kernel rejected and whySocketFdmakes the bug unwritable. - (hard) Compile
m03_file_handle_raii.cppwithg++ -std=c++17 -O2 -Sand locate the landing pad forwrite_report(look for.LLSDA,_Unwind_Resume, and thefclosecall reached only from the pad). Done when you can point at the normal-path code and show it contains no exception checks.
7. Interview questions¶
Q: What is RAII in one sentence? Bind each resource to the lifetime of an object: acquire in the constructor, release in the destructor, so the compiler releases it on every exit path, including exceptions.
Q: Why is RAII more than "remember to call cleanup"?
Because the destructor call is generated by the compiler at every scope exit, including the hidden
exits created by exceptions. Manual cleanup code is skipped by a throw; destructors are not.
Q: What happens to local objects when an exception propagates through a function? Stack unwinding runs the destructors of all fully constructed locals in reverse construction order, via compiler-generated landing pads, then continues unwinding to the next frame.
Q: Why should a destructor never throw?
If an exception is already propagating and a destructor throws a second one, the runtime calls
std::terminate. Destructors are implicitly noexcept; report cleanup failures through an explicit
close() method instead.
Q: How much does exception support cost on the non-throwing path with GCC?
Essentially nothing: GCC uses table-based ("zero-cost") unwinding. The tables live in .eh_frame
and .gcc_except_table and are consulted only when a throw happens. The throw itself costs
microseconds (two stack walks), so exceptions should not be used for routine control flow.
Q: What are the three decisions every RAII class must make? How to acquire (constructor, and what to do on failure), how to release (destructor, handling the empty state), and what copying means (deleted, deep copy, or shared). Leaving copy as the default is the classic double-free bug.
Q: You get an int fd from accept(). How do you prevent fd leaks in a server?
Wrap it immediately in a move-only owner (SocketFd conn(::accept(...))) at the point of creation.
Pass the owner by value when transferring ownership, by reference when lending it, and call
release() only where a C API takes over ownership.
8. Recap¶
- RAII: constructor acquires, destructor releases; the compiler calls the destructor on every path.
- The destructor runs for
return,break, end of scope, and during exception unwinding. - An RAII class must define copy semantics explicitly: delete, deep copy, or share.
- Destructors must not throw and must tolerate an empty/moved-from state.
std::exchange(member, empty_value)is the idiom for "steal and empty".- GCC exceptions are zero-cost on the happy path, expensive when thrown; use them for exceptional cases.
- Landing pads in
.gcc_except_tablerun destructors during unwinding. - Standard RAII types already exist for memory, strings, locks and streams; write wrappers only for raw C handles.
- In review, any raw resource acquisition outside a wrapper's constructor is a finding.