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:
#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.
#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:
#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:
#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 pointerThe 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:
#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:
#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.