all_lessons/C++/07 · Ownership & smart pointerslesson 8 / 20

Ownership and smart pointers: the payoff

Lesson 05 gave us RAII — tie a resource's lifetime to a scope and let the destructor free it deterministically. Lesson 06 gave us the rules for how a value copies and moves, so a resource-owning class can be duplicated or transferred without corrupting itself. This lesson cashes both in. The one new idea: ownership is a design decision, and C++ lets you write that decision into the type itself. A raw pointer says nothing about who frees what; a unique_ptr or shared_ptr says it in the type signature, where the compiler and every reader can see it. With that, we finally close the leak / dangling-pointer / double-free trio that Lesson 04 opened — by construction, not by discipline.

The thesis, here
A raw pointer is the cheapest possible thing — a machine address — and it tells you nothing about lifetime or responsibility. The smart pointers are RAII wrappers (Lesson 05) that encode the answer to "who frees this, and when?" in the type. The zero-overhead payoff: unique_ptr costs exactly what a hand-written new/delete pair would — no more — so safety here is genuinely free, while shared_ptr charges a precise, nameable bill (an atomic counter and an extra allocation) that you pay only when ownership is actually shared.
Linear position
Prerequisite: Lesson 06 (copy vs move, std::move, the rule of zero/three/five), which itself rests on Lesson 05 (RAII, destructors) and Lesson 04 (the heap and the leak/dangle/double-free failures). You should be comfortable with what a destructor does and what it means to move a value rather than copy it.
New capability: choose and express an ownership model in the type system — sole ownership with unique_ptr, shared ownership with shared_ptr, non-owning observation with weak_ptr — and explain the exact cost of each, so heap memory is managed correctly without ever writing delete.
The plan
Six moves. (1) Why "ownership" is a real decision, and why a raw pointer can't carry it. (2) unique_ptr: sole ownership, move-only, zero overhead — the default. (3) make_unique, and the worked conversion of a leaky Lesson-04 snippet. (4) shared_ptr: shared ownership and its named cost — the atomic refcount and the control-block allocation. (5) weak_ptr: the reference cycle that leaks even with shared_ptr, and the fix. (6) The guideline, the failure modes, and how the Lesson-04 trio is now closed.

1 · Ownership is a decision, and a raw pointer can't carry it

A raw pointerT* — is just a memory address held in a variable (Lesson 03). Look at a function that takes a Widget* and you cannot tell, from the type alone, the answer to the one question that matters for heap memory: am I supposed to free this when I'm done, or is someone else? That question is ownership — which exact part of the program is responsible for calling delete on a heap object, exactly once, after the last use and never before.

Lesson 04 showed that getting this wrong by hand produces three failures: a leak (nobody frees it — memory grows forever), a dangling pointer / use-after-free (freed too early — you read or write reclaimed storage, which is undefined behavior), and a double free (two owners both call delete — corrupting the allocator). All three are really the same root cause: ownership was ambiguous, so the bookkeeping lived only in the programmer's head. The fix is to stop relying on memory and write the decision into the type. A smart pointer is a small RAII object (Lesson 05) that holds a raw pointer and whose destructor frees it — so "when does it die?" is answered by scope, and "who owns it?" is answered by which smart-pointer type you chose.

unique_ptr<T>
Sole ownership. Exactly one owner at a time. Move-only — copying is a compile error. Frees on destruction. Zero overhead over a raw pointer.
shared_ptr<T>
Shared ownership. Several owners; the object dies when the last one does. Costs an atomic reference count plus a heap-allocated control block.
weak_ptr<T>
Non-owning observer. Watches a shared_ptr's object without keeping it alive. Used to break ownership cycles. Must be promoted to use.

2 · unique_ptr — sole ownership, move-only, zero overhead

std::unique_ptr<T> (from <memory>) owns a single heap object. There is, at any instant, exactly one unique_ptr pointing at a given object — that uniqueness is what makes ownership unambiguous. To enforce it, the type is move-only: its copy constructor and copy assignment are deleted (two copies would mean two owners → double free), but it has a move constructor that transfers ownership, leaving the source empty (recall Lesson 06: move = steal the guts, null out the source).

#include <memory>

std::unique_ptr<Widget> a = std::make_unique<Widget>(42); // a owns a heap Widget
// auto b = a;                  // COMPILE ERROR: cannot copy a unique_ptr
auto b = std::move(a);          // OK: ownership moves a -> b; a is now empty (nullptr)
// a is safe to touch: it is nullptr, not dangling. b will free the Widget at scope exit.

The crucial claim is that this safety is free. A unique_ptr<T> holds exactly one pointer — sizeof(unique_ptr<T>) == sizeof(T*) for the default deleter — and its destructor is a single delete. There is no reference count, no allocation, no runtime lookup. Dereferencing (*p, p->field) compiles to the identical machine instruction as dereferencing a raw pointer; the optimizer inlines it away entirely. This is the zero-overhead principle from Lesson 00 made concrete: you asked for automatic, deterministic freeing and exactly one owner, and you got it at the cost of a hand-written new/delete pair — not a byte or a cycle more.

Worked example — what unique_ptr compiles to
A function taking std::unique_ptr<Widget> p by value is taking ownership: the caller must std::move into it. At the end of the function, p goes out of scope and its destructor runs delete on the held pointer. The generated code for the body is: load the pointer, (if non-null) call ~Widget() and operator delete. That is byte-for-byte what you would write by hand with a raw pointer and a manual delete p; — except the compiler now guarantees the delete runs on every exit path, including an early return or a thrown exception (stack unwinding, Lesson 05). The discipline Lesson 04 said was unwinnable is now the type's job.

3 · make_unique, and converting a leaky Lesson-04 snippet

Always create a unique_ptr with std::make_unique<T>(args...) rather than unique_ptr<T>(new T(args...)). make_unique forwards its arguments to T's constructor, does the single allocation, and hands you the owning pointer — it never leaves a raw new result exposed for an instant, which closes a subtle exception-ordering leak and reads more cleanly. Here is the payoff promised in Lesson 04: a raw-pointer routine that leaks, fixed.

Leaky — Lesson 04 style (raw new/delete)
void process(int id) {
    Widget* w = new Widget(id);   // heap alloc
    if (!w->valid()) {
        return;                   // BUG: leaks w —
    }                             // the delete below is skipped
    w->run();
    if (w->failed())
        throw std::runtime_error("boom"); // BUG: leaks w
    delete w;                     // only reached on the happy path
}
Fixed — sole ownership in the type
void process(int id) {
    auto w = std::make_unique<Widget>(id); // owns the heap Widget
    if (!w->valid())
        return;                   // ~unique_ptr frees w here
    w->run();
    if (w->failed())
        throw std::runtime_error("boom"); // unwinding frees w here
    // no delete: ~unique_ptr frees w at the closing brace
}

The leaky version frees w only on the one code path that reaches the final delete; the early return and the throw both skip it, leaking the Widget every time they fire. The fixed version has no delete at all — and yet frees correctly on all three exit paths, because w's destructor runs whenever w's scope ends, by whatever route. The leak is not "less likely"; it is impossible, because there is no longer a manual step that an exit path can skip. That is the difference between fixing a bug and removing the category.

4 · shared_ptr — shared ownership, and its named cost

Sometimes one owner genuinely isn't enough: several independent parts of a program need to keep the same object alive, and you cannot say in advance which one finishes last (e.g. an entry in a cache also referenced by two in-flight requests). That is shared ownership, and std::shared_ptr<T> expresses it. Unlike unique_ptr, a shared_ptr is copyable; each copy is a co-owner. The object is destroyed when the last shared_ptr to it is destroyed — not before (no dangling), not twice (no double free).

It does this with a reference count: a small integer recording how many shared_ptrs currently own the object. Copying one increments it; destroying one decrements it; when it hits zero, the object is deleted. The count, plus other bookkeeping, lives in a heap-allocated control block that all the co-owners point to, separate from the managed object. So a shared_ptr is two pointers wide (one to the object, one to the control block), and it is not free:

costwhat it iswhy
extra allocationthe control block on the heapsomewhere to store the count shared by all owners (make_shared folds it into one allocation with the object — use it)
atomic increment / decrementthe refcount is updated with atomic operationscopies and destructions can happen on different threads; a plain ++ would be a data race (Lesson 17). Atomics are cheap but not free — they restrict CPU/compiler reordering and contend across cores
2× pointer sizesizeof(shared_ptr) == 2 * sizeof(void*)object pointer + control-block pointer
auto p = std::make_shared<Widget>(42); // refcount = 1, one allocation for object+control block
{
    auto q = p;        // copy: atomic increment, refcount = 2 — q and p co-own
}                      // q dies: atomic decrement, refcount = 1
// p dies at its scope end: atomic decrement -> 0 -> delete the Widget

Prefer std::make_shared<T>(args...) over shared_ptr<T>(new T(...)): it allocates the object and the control block together in a single heap allocation instead of two, which is both faster and avoids a leak window. The point is not that shared_ptr is bad — it is that it has an itemized bill, and you should reach for it only when you are actually buying shared ownership.

5 · weak_ptr — breaking the reference cycle that leaks

Reference counting has one classic failure: a cycle. If object A holds a shared_ptr to B and B holds a shared_ptr back to A, each keeps the other's count at (at least) 1 forever. When every outside handle is gone, the two refcounts are still 1 each — pointing only at each other — so neither ever reaches zero. Nothing frees them. That is a leak, the very failure we thought refcounting cured.

Leaks — a two-node cycle
struct Node {
    std::shared_ptr<Node> other;  // strong both ways
};
void make_cycle() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();
    a->other = b;   // b refcount = 2
    b->other = a;   // a refcount = 2
}   // a,b locals die: each refcount drops 2 -> 1, never 0.
    // Both Nodes leak forever.
Fixed — one link is non-owning
struct Node {
    std::shared_ptr<Node> child; // owns
    std::weak_ptr<Node>   parent;// observes, no count
};
void no_cycle() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();
    a->child = b;    // b refcount = 2
    b->parent = a;   // a refcount STAYS 1 (weak)
}   // a dies -> 0 -> frees a; that drops b -> 0 -> frees b. No leak.

std::weak_ptr<T> is a non-owning observer: it points at an object managed by some shared_ptr but does not contribute to the reference count, so it never keeps the object alive. The rule for cyclic structures is: make one direction owning (shared_ptr) and the back-reference non-owning (weak_ptr). In a tree, a parent owns its children, children observe their parent. Because a weak_ptr doesn't keep the object alive, the object may already be gone when you look — so you can't dereference a weak_ptr directly. You call .lock(), which atomically checks the count and returns a shared_ptr: non-null (a real co-owner for the duration) if the object is still alive, or null if it has been destroyed. That check-then-use is exactly how weak_ptr turns a potential dangling access into a safe, observable "it's gone."

if (auto p = node->parent.lock()) {   // p is a shared_ptr; non-null only if parent alive
    p->run();                          // safe: p co-owns for this block
}                                      // else: parent already destroyed — handled, not UB

6 · The guideline, and how the Lesson-04 trio is now closed

The default is unambiguous: prefer unique_ptr. It is the cheapest (zero overhead), it states the simplest and most common ownership model (one owner), and a program where every heap object has exactly one owner is the easiest to reason about. Reach for shared_ptr only when ownership is genuinely shared — when you truly cannot name a single owner that outlives all uses — and pay its atomic-and-allocation bill knowingly. Use weak_ptr to break any cycle the sharing introduces. Pass non-owning arguments as a plain reference or raw pointer (a function that only looks at a Widget should take const Widget& or Widget*, not a smart pointer — it isn't taking ownership, so it shouldn't say it is).

The trio from Lesson 04, closed
Leak — the destructor of unique_ptr/shared_ptr always runs on scope exit (even on exception), so nothing is forgotten; the only remaining leak is the shared_ptr cycle, which weak_ptr breaks. Dangling / use-after-free — a unique_ptr frees only when its single owner dies; a shared_ptr's object lives until the last owner is gone, so a co-owner can never be left holding a freed pointer; a weak_ptr forces a liveness check via .lock() before use. Double freeunique_ptr is non-copyable, so two owners cannot exist; shared_ptr deletes exactly once, when the count reaches zero. The three failures Lesson 04 said discipline could not prevent are now prevented by the types.

Common mistakes / failure modes

Two shared_ptrs from one raw pointer
shared_ptr<T> a(raw); shared_ptr<T> b(raw); creates two independent control blocks for one object → double free. Each object should enter shared ownership once, via make_shared.
Using shared_ptr by default
Reaching for shared_ptr "to be safe" pays the atomic + allocation cost for ownership that was never shared. The honest default is unique_ptr; shared ownership is the exception you justify.
A cycle of shared_ptr
Mutual strong references never reach refcount zero → leak. Make exactly one direction weak_ptr (the back-reference / "parent" link).
Dereferencing a weak_ptr via a stale lock
Storing the shared_ptr from .lock() for a long time defeats the point (it keeps the object alive). .lock() per use, check for null, use within that scope.
Smart pointer in a non-owning parameter
Taking shared_ptr<T> by value just to read the object forces a refcount bump and lies about ownership. Take const T& when you only observe.
Still writing new / delete
A raw new in modern code is a smell — it reintroduces the manual delete obligation. Use make_unique / make_shared; let RAII own.

Checkpoint exercise

Try it
Take the leaky process from §3 and the shared_ptr cycle from §5, put each in its own main, and run both under -fsanitize=address,leak (or valgrind). Confirm the raw-pointer version reports a leak on the early-return and throw paths, and the cycle version reports two leaked Nodes. Now apply the fixes (make_unique; one link to weak_ptr) and confirm both reports go silent. Then predict, before compiling: what does sizeof(std::unique_ptr<int>) print versus sizeof(std::shared_ptr<int>) on a 64-bit machine, and why? (Answer: 8 vs 16 — one pointer vs object-pointer plus control-block-pointer.)

Where this points next

We can now say who owns a heap object and be sure when it dies — Movement II (ownership & lifetime) is complete. The next question is the one ownership doesn't answer: given that an object lives somewhere on the heap, how is it laid out, and what does reaching it cost? Lesson 08 turns to data layout and the cache — alignment and padding, the cache line, why contiguous memory crushes pointer-chasing — and it is what finally explains, in nanoseconds, why one well-owned data structure can be a hundred times faster than another that is equally correct. Ownership got the lifetime right; layout gets the speed right.

Takeaway
Ownership — which part of the program frees a heap object, exactly once, at the right time — is a real design decision that a raw pointer cannot express, and smart pointers (RAII wrappers from Lesson 05) write it into the type. unique_ptr is sole ownership: move-only (so two owners can't exist), with the same size and dereference cost as a raw pointer — zero-overhead safety, and the default. shared_ptr is shared ownership via an atomic reference count in a heap-allocated control block; it deletes when the last owner dies, at the named cost of an extra allocation and atomic increments/decrements, so use it only when ownership is genuinely shared. weak_ptr is a non-owning observer that breaks the reference cycle two mutual shared_ptrs would leak; promote it with .lock() before use. Together — with make_unique/make_shared as the way to construct them — they close Lesson 04's trio by construction: leaks, dangling pointers, and double frees become impossible in the type rather than merely unlikely with discipline.

Interview prompts