all_lessons/C++/11 · Resource-safe class designlesson 12 / 20

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.

The thesis, here
A well-designed C++ type makes its cost model part of its interface. When you choose how a type owns its data, you are choosing — once, for every future caller — where the memory lives, who frees it, when it dies, and what a copy costs. Get that choice right and the compiler-generated machinery is both correct and zero-overhead; get it wrong and every user inherits a bug. Designing the type is answering the five questions on everyone's behalf.
Linear position
Prerequisite: Lesson 05 (RAII and the destructor as deterministic cleanup), Lesson 06 (copy vs move, deep vs shallow, the rule of zero/three/five), Lesson 07 (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.
The plan
Five moves. (1) The mindset: invariants established at construction and held by the type — "make the common case fast and the right thing easy." (2) Step one of the procedure: choose ownership before you write any member. (3) Step two: prefer the rule of zero so members manage themselves and you write no special members. (4) A const-correctness primer — just enough to mark reads as reads (the full treatment is Lesson 16). (5) A full worked design, blank file to finished type: a 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.

Why "in the type" beats "by discipline"
Lesson 04 showed that manual 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.

Owns, lifetime tied to mine — value member
Store it by value: 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.
Owns, but heap / polymorphic / optional — unique_ptr
Store a 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.
Ownership genuinely shared — shared_ptr
Store a 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.
Does not own — reference or non-owning pointer
If something else owns it and outlives your object, hold a reference (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.

Worked: the rule of zero in action
// 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.

FormMeaningWhy 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."

The type, designed to its invariant
#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.

Why it is leak- and exception-safe — traced
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

Writing a destructor but forgetting copy/move
If you declare ~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).
A move that forgets to blank the source
If the move constructor copies 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.
Reaching for shared_ptr by default
Shared ownership has an atomic refcount cost (Lesson 07) and makes lifetime hard to reason about — "who frees this?" becomes "whoever happens to drop the last reference." Use a value member or unique_ptr unless ownership is genuinely shared.
Holding a reference to something that dies first
A non-owning member must outlive your object. Store a reference to a temporary or a local that is destroyed before you, and every use dangles (Lesson 04). Non-owning members shift a lifetime obligation onto the caller — document it.
Doing real work the constructor can't undo
If a constructor acquires two resources and the second acquisition throws, the first must already be RAII-managed or it leaks (the destructor doesn't run for a never-finished object). Make every member self-cleaning so a throw mid-construction unwinds cleanly.
Forgetting const on reads
A non-const handle() 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

Try it
Take the 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.

Takeaway
Designing a resource-safe type is a fixed sequence, not an art. First decide ownership for each thing the type holds — own it by value, own it through 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