Resource-safe class design: assembling the movement
The last three lessons handed you the pieces in isolation: RAII binds a resource's lifetime to a scope (Lesson 05), copy and move control how values flow (Lesson 06), smart pointers put ownership in the type (Lesson 07), and a non-virtual method is just a function call with a hidden this (Lesson 09). This lesson is the assembly: a single, repeatable procedure for designing a type that owns a resource and cannot leak it, double-free it, or dangle — and that you can hand to a teammate who will use it correctly by accident. The one new idea is the design sequence itself: decide ownership first, then let the rule of zero spare you from writing any special members at all.
unique_ptr / shared_ptr and ownership as a type-level decision), and Lesson 09 (a class as zero-overhead abstraction, the implicit this, constructors establishing invariants).New capability: design a resource-owning type from a blank file to a finished, leak- and exception-safe class by following a fixed sequence — pick ownership, prefer the rule of zero, fall back to the rule of five only when a raw resource forces it, establish the invariant in the constructor, and add
const where reading must not mutate.TempFile that owns an OS file handle, showing why it needs the rule of five and proving it is leak- and exception-safe. Then the bridge: this type works with any payload, which is what templates (Lesson 12) generalize.1 · The mindset: an invariant the constructor establishes and the type holds
An invariant is a fact about an object that is true for its entire lifetime — from the moment its constructor finishes to the moment its destructor begins. "This vector's size is never greater than its capacity." "This TempFile's file descriptor is either a valid open file or the sentinel -1, never garbage." A class exists to establish an invariant in its constructor and then protect it: private data so outsiders cannot break it, member functions that move from one valid state to another. Design a type well and illegal states become unrepresentable; design it badly and every caller is one typo away from corruption.
Stroustrup's design slogan — "make the common case fast and the right thing easy" — is the practical form of this. The common case (create the object, use it, let it go out of scope) should compile to tight machine code with no overhead you didn't ask for. The right thing (cleaning up exactly once, even when an exception fires) should be what happens automatically, with the user writing nothing. The whole point of resource-safe design is to push the correctness into the type so the caller cannot get it wrong — not because they are careful, but because the type left them no other path.
new/delete is unwinnable by willpower: any early return or thrown exception between the new and the delete leaks. RAII (Lesson 05) moved the cleanup into a destructor that runs no matter how the scope exits. Resource-safe class design takes the last step: it bundles the resource, its invariant, and its cleanup into one type, so that "use this correctly" stops being a rule a programmer must remember and becomes a property the compiler enforces. Discipline does not scale across a team or across years; types do.2 · Step one — choose ownership before you write a single member
Before naming a field or a method, answer one question for each thing your type holds: does my type own this, or merely refer to it? Ownership means your type is responsible for the resource's lifetime — it created it (or took it over) and it must free it. Referring means someone else owns it and outlives you; you just look. This single decision determines the member's type, and getting it right up front is what makes everything afterward fall into place.
std::string name;, std::vector<int> data;, Buffer buf;. The member lives and dies with the enclosing object, on the same storage. Cheapest and simplest; the default you should reach for first.std::unique_ptr<T> when the thing must live on the heap (size unknown at compile time), must be a base-class pointer for runtime polymorphism (Lesson 10), or may legitimately be absent. Sole ownership, zero overhead over a raw pointer, move-only.std::shared_ptr<T> only when several objects must keep the same resource alive and none is the clear sole owner. You pay an atomic reference count (Lesson 07). Rare — reach for it last, not first.T&, can't be null, can't rebind) or a non-owning const T*. You free nothing. The hazard: it must outlive you, or you dangle (Lesson 04).Notice that four of the most common bugs in C — leaking, double-freeing, dangling, and unclear cleanup responsibility — are not handled by these choices; they are defined away. A value member cannot be double-freed because no one calls delete on it. A unique_ptr cannot leak because its destructor frees automatically. A reference frees nothing, so it cannot double-free. The ownership decision is where resource safety is won or lost; everything after it is mechanism.
3 · Step two — prefer the rule of zero so you write no special members
The special member functions are the six the compiler can generate for you: the default constructor, the destructor, the copy constructor, the copy assignment operator, the move constructor, and the move assignment operator. Lesson 06 introduced the rule of three/five — if your class manually manages a resource (a raw new'd pointer, a file descriptor), you must correctly write the destructor, both copy operations, and both move operations, because the compiler's defaults will do the wrong thing (a shallow copy of an owning raw pointer leads to a double-free).
But the modern, preferred form is the rule of zero: design your class so that you write none of the special members. You achieve it by holding your resources in members that already manage themselves — std::vector, std::string, std::unique_ptr, std::shared_ptr. Each of those already has a correct destructor, copy, and move. When your class is built only from such members, the compiler-generated specials are memberwise: the generated destructor destroys each member (each member's own destructor frees its resource), the generated copy copies each member (each member copies itself correctly — deep where it should be deep), the generated move moves each member. They are correct and zero-overhead, because each is just a sequence of the members' own operations the compiler would have inlined anyway.
// Holds a name and a heap-allocated configuration. Owns both.
class Session {
public:
Session(std::string name, std::unique_ptr<Config> cfg)
: name_(std::move(name)), cfg_(std::move(cfg)) {} // establishes the invariant
// NO destructor, NO copy/move written. The compiler generates them.
private:
std::string name_; // owns its chars; manages itself
std::unique_ptr<Config> cfg_; // owns the Config; frees it on destruction
};
// Destruction: ~Session destroys cfg_ (deletes the Config) then name_ (frees chars). Leak-free.
// Move: Session b = std::move(a); // moves name_ and cfg_ — a pointer swap, no Config copied.
// Copy: unique_ptr is move-only, so Session is automatically move-only too. The compiler
// does NOT generate copy operations, and any attempt to copy a Session fails to COMPILE.
That last line is the rule of zero paying a bonus: because one member (unique_ptr) is non-copyable, Session is non-copyable by construction, with no work and no runtime check. The type's copy-ability is derived from its members' — exactly the right answer, for free.So the procedure has a clear default: compose self-managing members and write nothing. You only fall through to the rule of five when a member is a raw resource — an OS handle, a file descriptor, a C library pointer freed by a C function — that no standard type manages for you. Even then, the better move is usually to wrap that one raw resource in its own tiny rule-of-five type, so every other class stays at the rule of zero. The next section builds exactly such a type.
4 · A const-correctness primer (just enough for now)
const-correctness means marking, in the type system, which operations are allowed to modify an object and which only read it — and letting the compiler enforce that promise. The full treatment is Lesson 16; here is the minimum you need to design a type today.
| Form | Meaning | Why it matters in a type |
|---|---|---|
int size() const; | A const member function: promises not to modify the object. Inside it, this points to a const object, so it may only call other const methods and read members. | Lets callers use the method on a const object or through a const reference. Reads should be const; writes should not. |
void f(const T& x) | Take the argument by const reference: no copy is made (cheap, Lesson 06) and f cannot modify the caller's object. | The default way to pass a large object you only read. Free to pass, safe by contract. |
const T* p; | A pointer to a const T: you may read through p but not write (Lesson 03). | The right type for a non-owning member that observes but must not mutate. |
The engineering payoff is the same "push correctness into the type" idea: a method marked const documents and enforces that calling it is safe on shared, read-only data — the compiler rejects an accidental write at compile time rather than letting it corrupt a value at runtime. In the worked design below, every method that only reads is marked const.
5 · Full worked design: a leak-proof, exception-safe TempFile
We design a type start to finish. Requirement: own a temporary OS file by its file descriptor (an int handle the operating system hands back from open; you must hand it back with close or you leak a kernel resource). The descriptor is a raw resource — no standard type manages it — so this is the case that forces the rule of five. We follow the procedure exactly.
Step 1 — ownership. A TempFile owns exactly one descriptor: it opens it, and it must close it precisely once. There is no sole-owner standard type for a file descriptor, so the member is a raw int fd_ and we accept responsibility for its lifetime ourselves.
Step 2 — rule of zero or five? The member is a raw resource, so the rule of zero does not apply — the compiler's default destructor would not call close (leak), and its default copy would duplicate the int so two TempFiles name the same descriptor and both close it (double-free of a handle). We must write the special members: this is the rule of five. The invariant we establish and hold: fd_ is either a valid open descriptor, or -1 meaning "owns nothing."
#include <fcntl.h>
#include <unistd.h>
#include <utility> // std::exchange
#include <stdexcept>
class TempFile {
public:
// Constructor ESTABLISHES the invariant: after this runs, fd_ is a valid open fd.
explicit TempFile(const char* path)
: fd_(::open(path, O_RDWR | O_CREAT | O_TRUNC, 0600)) {
if (fd_ < 0) throw std::runtime_error("open failed"); // never construct in a broken state
}
// Destructor RELEASES exactly once (RAII, Lesson 05). Runs on every scope exit, incl. exceptions.
~TempFile() { if (fd_ != -1) ::close(fd_); }
// --- move: transfer ownership, leave the source owning nothing (fd_ == -1) ---
TempFile(TempFile&& other) noexcept
: fd_(std::exchange(other.fd_, -1)) {} // steal fd, blank the source
TempFile& operator=(TempFile&& other) noexcept {
if (this != &other) {
if (fd_ != -1) ::close(fd_); // free what we currently hold
fd_ = std::exchange(other.fd_, -1); // take theirs; blank the source
}
return *this;
}
// --- copy: DISABLED. A file descriptor has a single owner; copying it is meaningless ---
TempFile(const TempFile&) = delete;
TempFile& operator=(const TempFile&) = delete;
int handle() const { return fd_; } // const: reading the handle does not modify the object
private:
int fd_{-1}; // the invariant: valid open fd, or -1 == owns nothing
};Walk the five questions through it. Where does it live? The TempFile object itself is typically a stack value; the resource it owns is a kernel descriptor. Who owns it? Exactly one TempFile at a time — copying is = deleted, so the compiler refuses to create a second owner. When does it die? Deterministically, in the destructor, at scope exit. What does a move cost? One int swap — std::exchange reads other.fd_, stores -1 into it, and returns the old value; the moved-from object holds nothing and its later destructor sees -1 and closes nothing. What does it compile to? Trivially — there is no overhead beyond the bare open/close you would have called by hand.
void use() {
TempFile a("/tmp/scratch"); // fd opened; invariant holds
risky_step(); // (A) suppose this throws
write_lots(a.handle());
} // a goes out of scope here
At (A) an exception fires. The stack unwinds (Lesson 05): every fully-constructed local in scope has its destructor run as control leaves the function. So ~TempFile() runs, sees fd_ != -1, calls close — the descriptor is freed even though we never reached the end of the function. No leak on the exception path, and we wrote zero try/finally. Had the constructor itself thrown (open failed), no TempFile object would exist, so no destructor runs and nothing is double-closed — a half-built object is never observable. This is the rule of five and RAII working together: the type is correct on every exit path, by construction.Now the bonus: any class that wants a temp file just holds a TempFile by value and goes straight back to the rule of zero — the raw-resource complexity is sealed inside this one small type. That is the recommended shape of a real codebase: a thin layer of rule-of-five wrappers around raw resources, and everything above them at the rule of zero.
6 · Common mistakes / failure modes
~T() to free a raw resource but leave the default copy, the compiler copies the raw handle and you get a double-free. The rule of five is all-or-nothing: a user-declared destructor is the signal you must address copy and move too (here: delete copy, write move).fd_ but does not set other.fd_ = -1, both objects' destructors close the same descriptor — double-free. std::exchange exists precisely to steal-and-blank in one expression and make this hard to get wrong.unique_ptr unless ownership is genuinely shared.const on readshandle() can't be called on a const TempFile or through a const&, infecting every caller with non-constness. Mark every read-only method const from the start; retrofitting it later is a painful sweep (Lesson 16).Checkpoint exercise
TempFile above and (1) delete the move constructor and move assignment, then try to write TempFile make() { return TempFile("/tmp/x"); } and store the result in a std::vector<TempFile>. Predict the compile error and explain it in ownership terms. (2) Put back the move operations but remove the std::exchange's blanking (copy fd_ plainly, leave other.fd_ untouched), compile with -fsanitize=address, run code that moves a TempFile, and watch for a double-close. (3) Rewrite Session from §3 to hold a TempFile by value and confirm you can keep the rule of zero — no special members — even though TempFile itself needed the rule of five. Explain why that layering works.Where this points next
We designed a type that owns one specific resource, a file descriptor. But look back at Session: it owned a std::string and a std::unique_ptr<Config> with no special members — the ownership logic did not depend on what the members were, only on the fact that they manage themselves. That independence is the seed of the next movement. A unique_ptr<Config> and a unique_ptr<Session> are the same code with the type swapped in; a container that owns "any payload" should be written once and reused for every element type. Lesson 12 makes that explicit with templates — compile-time polymorphism that stamps out a concrete type per payload with zero runtime cost, the generic counterpart to the resource-safe design you just learned to do by hand.
unique_ptr (heap / polymorphic / optional), share it through shared_ptr only when ownership is genuinely shared, or merely refer to it — because that one choice defines away leaks, double-frees, and dangles before any code exists. Then prefer the rule of zero: compose members that manage themselves (vector, string, unique_ptr) and write no special members at all — the compiler's memberwise destructor, copy, and move are then both correct and zero-overhead. Fall through to the rule of five only for a raw resource no standard type owns, and even then wrap it in one tiny RAII type so everything above stays at the rule of zero. Establish the invariant in the constructor (never construct a broken object), release deterministically in the destructor, and mark every read const. The result — proven by the TempFile trace — is leak- and exception-safe on every exit path, by construction: the right thing is what happens automatically, and the common case stays fast. That is "make the common case fast and the right thing easy" made into code.Interview prompts
- What is the first decision you make when designing a resource-owning type, and why does it come before everything else? (§2 — ownership: own-by-value / unique_ptr / shared_ptr / non-owning reference; it fixes each member's type and defines away leaks, double-frees, and dangles before any other code is written.)
- State the rule of zero and explain how it can leave a class both correct and zero-overhead. (§3 — compose self-managing members and write no special members; the compiler-generated memberwise destructor/copy/move are just the members' own operations, which are correct and would have been inlined anyway.)
- When are you forced off the rule of zero onto the rule of five, and what's the better structural response? (§3, §5 — when a member is a raw resource no standard type manages, e.g. a file descriptor; wrap that one resource in a tiny rule-of-five RAII type so every other class stays at the rule of zero.)
- Why does the
TempFilemove usestd::exchange(other.fd_, -1)rather than a plain copy offd_? (§5, §6 — it steals the descriptor and blanks the source in one step so the moved-from object owns nothing and its destructor closes nothing; a plain copy leaves both objects owning the same fd → double-close.) - Trace why
TempFiledoes not leak when an exception is thrown between construction and end of scope. (§5 — stack unwinding runs the destructor of every fully-constructed local as control leaves the scope;~TempFilesees a valid fd and closes it, with no try/finally written.) - What does marking a member function
constpromise, and why mark reads const from the start? (§4 — it promises not to modify the object and is the only form callable on a const object or through a const reference; retrofitting const later is a painful cascading sweep — Lesson 16.) - Why prefer a value member or
unique_ptrovershared_ptrby default? (§2, §6 — shared_ptr costs an atomic refcount and makes lifetime ambiguous ("whoever drops the last reference frees it"); sole or value ownership is cheaper and keeps "who frees this, when" answerable.)