The C++ Textbook

Part 3 · Types you make

Constructors, destructors, and RAII

The idea that makes C++ safe without a garbage collector.

By the end of this chapter you can

  • Explain RAII in terms of construction and destruction
  • Write a class that owns a resource and releases it exactly once
  • Identify a resource leak that RAII would have prevented

This is the most important chapter in the book. Everything C++ does about safety — smart pointers, containers, locks, files, sockets — is one idea applied over and over, and this is the idea.

It has an unhelpful name: Resource Acquisition Is Initialisation, or RAII. The name describes the mechanism rather than the benefit. A better summary is:

Tie every resource to the lifetime of an object, so that releasing it becomes the compiler’s job rather than yours.

The problem it solves

You have seen the shape twice already. Here it is once more, as small as it gets:

Every exit path is a chance to leak
#include <cstdio>
#include <iostream>

void write_report(bool fail) {
    std::FILE* file = std::fopen("/tmp/cpptb-report.txt", "w");
    if (!file) return;

    std::fputs("line one\n", file);

    if (fail) {
        std::cout << "bailing out\n";
        return;                    // the file is never closed
    }

    std::fputs("line two\n", file);
    std::fclose(file);
}

int main() {
    for (int i = 0; i < 200; ++i) write_report(true);
    std::cout << "leaked 200 file handles, and nothing complained\n";
}

Look at what did not happen. This book compiles every sample with AddressSanitizer and leak detection on, and it caught the leaked new int[1000] back in Chapter 2.5 without hesitation. Here it says nothing at all: 200 file handles were opened and abandoned, and the program exited cleanly.

That is not a flaw in the tool. LeakSanitizer reports memory that is unreachable at exit; the C runtime keeps every open FILE on an internal list, so the memory is still reachable and the handle is still, as far as the allocator is concerned, in use. The operating system closes them when the process dies, so a short program gets away with it.

A long-lived one does not. File descriptors are a per-process limit, commonly 1024. A server leaking one per request stops being able to open anything at all after a few minutes, and the error surfaces somewhere entirely unrelated to the code that caused it.

The fclose is right there. It just is not on every path out. Add a third early return next year, or an exception from something in the middle, and the bug reappears — not because anyone was careless, but because correctness depends on remembering something at every exit, and there is no mechanism holding you to it.

You cannot fix this class of bug by being careful. You fix it by making it impossible.

The mechanism

Chapter 2.4 established the guarantee this rests on: an automatic object’s destructor runs when its scope ends, on every exit path, including exceptions. There is no way out of a scope that skips it.

So: put the resource in an object. Acquire it in the constructor, release it in the destructor. The destructor guarantee becomes the release guarantee.

The same function, with nothing to remember
#include <cstdio>
#include <iostream>
#include <stdexcept>

class File {
public:
    File(const char* path, const char* mode) : handle_(std::fopen(path, mode)) {
        if (!handle_) throw std::runtime_error("could not open file");
    }

    ~File() {
        if (handle_) std::fclose(handle_);
    }

    void write(const char* text) { std::fputs(text, handle_); }

private:
    std::FILE* handle_;
};

void write_report(bool fail) {
    File file{"/tmp/cpptb-report.txt", "w"};   // acquired

    file.write("line one\n");

    if (fail) {
        std::cout << "bailing out\n";
        return;                                // released, automatically
    }

    file.write("line two\n");
}                                              // released here too

int main() {
    write_report(true);
    write_report(false);
    std::cout << "no leak on either path\n";
}

The fclose now appears exactly once, in the destructor, and runs on both paths. Add a third early return and it will run there too, without anyone touching it. Throw an exception from the middle and stack unwinding runs it.

That is the whole technique.

It is not only about memory

Memory is the most discussed resource, but the pattern applies to anything that must be given back. The standard library ships RAII types for most of them:

Resource RAII type
Heap memory, one owner std::unique_ptr<T>
Heap memory, shared std::shared_ptr<T>
A growable array std::vector<T>
Text std::string
A mutex lock std::lock_guard, std::unique_lock
A file std::fstream
A thread std::jthread (C++20)

A lock is the clearest case after memory, because the failure is not a leak but a deadlock:

A lock that cannot be left held
#include <iostream>
#include <mutex>
#include <stdexcept>

std::mutex data_mutex;
int shared_value = 0;

void update(int amount, bool fail) {
    std::lock_guard<std::mutex> lock{data_mutex};   // locked here

    shared_value += amount;
    if (fail) throw std::runtime_error("update failed");

    shared_value += amount;
}                                                   // unlocked here, always

int main() {
    update(1, false);
    try {
        update(10, true);
    } catch (const std::exception& e) {
        std::cout << "caught: " << e.what() << '\n';
    }

    // If the throw had left the mutex locked, this would deadlock forever.
    update(100, false);
    std::cout << "shared_value = " << shared_value << '\n';
}

Had update locked and unlocked by hand, the throw would have jumped over the unlock and the next call would have blocked forever — a hang with no stack trace pointing at the cause. lock_guard makes that impossible.

Writing one correctly

A resource-owning class has to answer one more question than an ordinary one: what happens when it is copied? Consider the naive version:

What copying does to a naive owner
#include <cstddef>
#include <iostream>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_(new int[n]), size_(n) {}
    ~Buffer() { delete[] data_; }

    std::size_t size() const { return size_; }

private:
    int* data_;
    std::size_t size_;
};

int main() {
    Buffer a{4};
    Buffer b = a;          // compiler-generated copy: copies the POINTER
    std::cout << "both claim " << a.size() << " elements\n";
}                          // both destructors run delete[] on the same pointer

The compiler generated a copy constructor that copied both members — including the pointer. Now two objects believe they own the same allocation, and both free it. That is a double free, which corrupts the allocator’s bookkeeping. AddressSanitizer catches it here; in production it is a crash somewhere unrelated, or a security vulnerability.

There are two honest fixes.

Say the type cannot be copied, when copying makes no sense — you cannot meaningfully duplicate a mutex or a file handle:

An owner that refuses to be copied
#include <cstddef>
#include <iostream>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_(new int[n]), size_(n) {}
    ~Buffer() { delete[] data_; }

    Buffer(const Buffer&) = delete;             // no copying
    Buffer& operator=(const Buffer&) = delete;

    std::size_t size() const { return size_; }
    int& operator[](std::size_t i) { return data_[i]; }

private:
    int* data_;
    std::size_t size_;
};

int main() {
    Buffer a{4};
    a[0] = 7;
    std::cout << "a[0] = " << a[0] << ", size " << a.size() << '\n';
    // Buffer b = a;   // now a compile error rather than a run-time disaster
}

Or define what a copy means — allocate new storage and duplicate the contents. That is the subject of Chapter 3.3, and moving it instead of copying is Chapter 3.4. Either way, the compiler-generated version was wrong, and = delete makes the wrongness a compile error while you decide.

The rule of zero

Here is the part that surprises people: most classes should own nothing directly. If every member is already an RAII type, the compiler-generated destructor, copy, and move operations are all correct, and you write none of them:

A class that owns things without owning anything
#include <iostream>
#include <string>
#include <vector>

class Document {
public:
    Document(std::string title) : title_(std::move(title)) {}

    void add_line(std::string line) { lines_.push_back(std::move(line)); }

    std::size_t line_count() const { return lines_.size(); }
    const std::string& title() const { return title_; }

    // No destructor. No copy constructor. No assignment operator.
    // Every member cleans up after itself, so the defaults are correct.

private:
    std::string title_;
    std::vector<std::string> lines_;
};

int main() {
    Document doc{"notes"};
    doc.add_line("first");
    doc.add_line("second");

    Document copy = doc;                 // correct deep copy, for free
    copy.add_line("third");

    std::cout << doc.title()  << ": " << doc.line_count()  << " lines\n";
    std::cout << copy.title() << ": " << copy.line_count() << " lines\n";
}

Document manages a string and a vector of strings — heap allocations throughout — and contains no new, no delete, and no destructor. Copying it does the right thing because copying a std::string does the right thing.

This is the goal. Write raw resource management only when you are building one of these building blocks; the other 99% of the time, compose types that already manage themselves and write nothing.

Check yourself

Practice