Lesson 4 of 5
Every other language with exceptions needs finally. C++ does not, and the reason is the whole
argument of Part II arriving at its conclusion.
When an exception unwinds the stack (lesson 15.1), destructors run. If every resource is owned by an object, every resource is released — without anyone writing cleanup code on the failure path.
The comparison
#include <iostream>
#include <memory>
#include <mutex>
#include <stdexcept>
#include <string>
#include <vector>
// Simulated resources that report their own acquisition and release.
struct Connection {
std::string name;
static int open_count;
explicit Connection(std::string n) : name(std::move(n)) {
++open_count;
std::cout << " opened " << name << "\n";
}
~Connection() {
--open_count;
std::cout << " closed " << name << "\n";
}
};
int Connection::open_count = 0;
void work_with_raw() {
Connection* c = new Connection("raw");
int* buffer = new int[100];
throw std::runtime_error("failure during work");
delete[] buffer; // never reached
delete c; // never reached
}
void work_with_raii() {
auto c = std::make_unique<Connection>("raii");
std::vector<int> buffer(100);
throw std::runtime_error("failure during work");
}
int main() {
std::cout << "RAII:\n";
try {
work_with_raii();
} catch (const std::exception& e) {
std::cout << " caught: " << e.what() << "\n";
}
std::cout << " connections still open: " << Connection::open_count << "\n";
std::cout << "raw pointers:\n";
try {
work_with_raw();
} catch (const std::exception& e) {
std::cout << " caught: " << e.what() << "\n";
}
std::cout << " connections still open: " << Connection::open_count << " <-- leaked\n";
return 0;
}
RAII:
opened raii
closed raii
caught: failure during work
connections still open: 0
raw pointers:
opened raw
caught: failure during work
connections still open: 1 <-- leaked
Two functions doing the same thing. One leaks, one does not, and neither contains any cleanup code on the failure path.
work_with_raw has delete[] buffer; and delete c; written at the bottom. They never run — the
throw leaves the function before reaching them. The connection stays open and the 400-byte buffer
stays allocated, forever.
work_with_raii has no delete at all. The unique_ptr and the vector are destroyed during
unwinding, and their destructors do the work.
Built with -fsanitize=address, the leak is not a matter of opinion:
Direct leak of 400 byte(s) in 1 object(s) allocated from:
Direct leak of 32 byte(s) in 1 object(s) allocated from:
SUMMARY: AddressSanitizer: 432 byte(s) leaked in 2 allocation(s).
Both leaks come from work_with_raw. Lesson 11.3's tooling, confirming lesson 15.1's mechanics.
Why finally is unnecessary
In Java or Python the cleanup is written at the call site, every time:
// Java — the caller must remember, on every path:
// Connection c = open();
// try {
// work(c);
// } finally {
// c.close();
// }
Miss one finally and you leak. There are as many chances to forget as there are call sites.
In C++ the cleanup is written once, in the destructor, and it applies everywhere the type is used:
// Every one of these releases correctly on every exit path, including a throw:
// std::unique_ptr<T> — deletes
// std::vector<T> — frees the buffer
// std::lock_guard — unlocks
// std::fstream — closes
// std::jthread (C++20) — joins
The difference is where the responsibility lives. finally puts it on every user; RAII puts it
on the type's author, once.
This is the answer to a question Module 8 raised and did not settle: why does C++ not have garbage collection, and why is that not a disaster? Because deterministic destruction handles every resource — memory, locks, files, sockets, transactions — and a garbage collector handles only one.
RAII is not only about memory
The pattern generalises to anything with an acquire/release pair. This is a scope guard, and it is worth having in any codebase:
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <string>
#include <utility>
// A generic scope guard: run an action unless it is explicitly dismissed.
// This is the RAII pattern applied to arbitrary cleanup, not just memory.
template <typename F>
class ScopeGuard {
F action;
bool active = true;
public:
explicit ScopeGuard(F f) : action(std::move(f)) {}
~ScopeGuard() {
if (active) {
action();
}
}
void dismiss() { active = false; }
ScopeGuard(const ScopeGuard&) = delete;
ScopeGuard& operator=(const ScopeGuard&) = delete;
};
int rows_in_table = 0;
bool transaction_open = false;
void insert_row() { ++rows_in_table; }
void risky_transaction(bool should_fail) {
transaction_open = true;
std::cout << " transaction begun\n";
// Roll back automatically unless we reach the commit.
ScopeGuard rollback([starting = rows_in_table] {
rows_in_table = starting;
transaction_open = false;
std::cout << " rolled back\n";
});
insert_row();
insert_row();
if (should_fail) {
throw std::runtime_error("constraint violated");
}
rollback.dismiss(); // reached only on success
transaction_open = false;
std::cout << " committed\n";
}
int main() {
std::cout << "successful transaction:\n";
risky_transaction(false);
std::cout << " rows: " << rows_in_table
<< ", transaction open: " << std::boolalpha << transaction_open << "\n";
std::cout << "failing transaction:\n";
try {
risky_transaction(true);
} catch (const std::exception& e) {
std::cout << " caught: " << e.what() << "\n";
}
std::cout << " rows: " << rows_in_table
<< ", transaction open: " << transaction_open << "\n";
// std::lock_guard is the same idea, already in the standard library.
std::mutex m;
{
std::lock_guard<std::mutex> lock(m);
std::cout << "locked; the guard unlocks on any exit path\n";
}
std::cout << "unlocked\n";
return 0;
}
successful transaction:
transaction begun
committed
rows: 2, transaction open: false
failing transaction:
transaction begun
rolled back
caught: constraint violated
rows: 2, transaction open: false
locked; the guard unlocks on any exit path
unlocked
On success the guard is dismissed and nothing rolls back. On failure it rolls back and the row count returns to what it was. The failing transaction inserted two rows and the guard undid both, leaving the count at the 2 from the earlier successful transaction.
Three things make that work:
The guard captures the starting state in its lambda (lesson 14.2's init-capture), so it knows what to restore.
dismiss() is called only on the success path. Everything before it is provisional. This is the
copy-then-commit shape from lesson 15.2, expressed as a destructor rather than a swap.
The guard is non-copyable. Two guards running the same rollback would undo twice. Deleting the
copy operations is the same reasoning as std::unique_ptr (lesson 10.2).
std::lock_guard at the bottom is the standard library's version of exactly this idea. C++23 adds
std::scope_exit, std::scope_fail, and std::scope_success to <scope>, which are the general
form — until then, the twenty lines above are the usual thing to write.
The rule this gives you
If you write
new,lock(),open(), orbegin_transaction()and have to remember a matching release, you have made the error path your problem.
Wrap it instead. The wrapper is usually under thirty lines, and after that the exception safety of every function using it is automatic.
Concretely, the things that should never appear in ordinary code:
// delete p; — use unique_ptr or a container
// mutex.unlock(); — use lock_guard or scoped_lock
// file.close(); — fstream closes itself
// free(buffer); — use vector or string
// thread.join(); — use jthread (C++20)
Each of those is a manual release that an exception can skip.
Where RAII needs care
A destructor must not throw (lesson 15.3). If cleanup can fail, catch inside the destructor and log. Terminating the program is worse than a lost log line.
Constructors must complete or clean up after themselves. If a constructor throws, the object was
never constructed, so its destructor does not run — but the destructors of members already
constructed do. That is why a class holding two raw pointers is hard to make safe and a class
holding two unique_ptrs is trivially safe:
// Dangerous: if the second new throws, the first allocation leaks.
// Widget() : a(new int[100]), b(new int[100]) {}
//
// Safe: if the second throws, a's destructor still runs.
// Widget() : a(std::make_unique<int[]>(100)), b(std::make_unique<int[]>(100)) {}
Order of destruction is reverse order of construction, within a scope and within a class. A guard declared after the resource it protects is destroyed first, which is usually what you want.
Try it
Write a FileWriter that opens a file, writes to it, and must always close it — plus a temporary
file that should be deleted on failure but kept on success. Use RAII for both. Then write the
same thing with manual cleanup and count how many close/remove calls the error paths need.
Checkpoint
- C++ needs no
finallybecause destructors run during stack unwinding. finallyputs cleanup on every caller; RAII puts it on the type's author, once.- Measured: the raw-pointer version leaked 432 bytes and left a connection open; the RAII version leaked nothing, and neither had cleanup code on the failure path.
- Deterministic destruction handles every resource, not just memory — which is why C++ does not need a garbage collector to be safe.
- A scope guard generalises RAII to arbitrary cleanup;
dismiss()marks the success path. - A guard must be non-copyable, or the cleanup runs twice.
std::lock_guardis the standard's scope guard; C++23 addsstd::scope_exitand friends.- Seeing
delete,unlock(),close(), orfree()in ordinary code is a design smell. - A destructor must not throw — catch and log inside it.
- If a constructor throws, the destructor does not run, but already-constructed members' do.
- Two
unique_ptrmembers are trivially safe where two raw pointers are not. - Destruction is reverse order of construction; declare guards so the dependency order works out.
Questions about this lesson
Log in to ask a question.