Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

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(), or begin_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 finally because destructors run during stack unwinding.
  • finally puts 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_guard is the standard's scope guard; C++23 adds std::scope_exit and friends.
  • Seeing delete, unlock(), close(), or free() 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_ptr members 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.