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.
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.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.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 pointer — T* — 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.
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.
unique_ptr compiles tostd::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.
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
}
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:
| cost | what it is | why |
|---|---|---|
| extra allocation | the control block on the heap | somewhere to store the count shared by all owners (make_shared folds it into one allocation with the object — use it) |
| atomic increment / decrement | the refcount is updated with atomic operations | copies 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 size | sizeof(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.
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.
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).
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 free — unique_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
shared_ptrs from one raw pointershared_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.shared_ptr by defaultshared_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.shared_ptrweak_ptr (the back-reference / "parent" link).weak_ptr via a stale lockshared_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.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.new / deletenew in modern code is a smell — it reintroduces the manual delete obligation. Use make_unique / make_shared; let RAII own.Checkpoint exercise
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.
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
- Why can't a raw
T*express ownership, and why does that matter? (§1 — the type doesn't say whether the callee must free it; ambiguous ownership is the shared root of leak, dangle, and double-free.) - Why is
unique_ptrmove-only, and what does that prevent? (§2 — copying would create two owners and thus a double free; deleting the copy operations enforces a single owner at compile time.) - In what sense is
unique_ptrzero-overhead? (§2 — same size as a raw pointer, dereference compiles to the identical instruction, destructor is a singledelete; no refcount, no allocation, no lookup.) - Name the precise costs of
shared_ptroverunique_ptr. (§4 — a heap-allocated control block (extra allocation), atomic increment/decrement of the refcount on every copy/destroy, and twice the pointer size.) - Why is the refcount atomic, and what does that cost? (§4 — copies/destructions may occur on different threads; a non-atomic
++would be a data race (Lesson 17); atomics restrict reordering and contend across cores, so they're cheap but not free.) - Show a
shared_ptrleak and fix it. (§5 — two nodes holdingshared_ptrs to each other keep each other's count at 1 forever; make the back-reference aweak_ptrso it doesn't contribute to the count.) - What's the default ownership choice and why? (§6 — prefer
unique_ptr: cheapest and simplest (one owner); useshared_ptronly when ownership is genuinely shared, and pass non-owning arguments by reference.) - How do smart pointers close the Lesson-04 trio? (§6 — destructors always free (no leak), the object outlives all owners / a single owner (no dangle), and non-copyable or delete-once semantics (no double free); by construction, not discipline.)