5.2.12 · D4C++ Programming

Exercises — Smart pointers — unique_ptr (sole ownership), shared_ptr (shared ownership, ref count), weak_ptr (break cycles)

2,259 words10 min readBack to topic

Before we start, one picture to fix vocabulary. Every shared_ptr carries two arrows: one to the object and one to a small bookkeeping struct called the control block which stores two integers — the strong count (real owners) and the weak count (observers).

Figure — Smart pointers — unique_ptr (sole ownership), shared_ptr (shared ownership, ref count), weak_ptr (break cycles)

Read this figure now: the blue arrows are the shared_ptrs pointing at the object; the yellow box is the control block; strong is the number of blue owners, weak is the number of green (non-owning) observers. The object is deleted the instant strong hits 0. The control block itself is freed only when both strong == 0 and weak == 0.


Level 1 — Recognition

Recall Solution L1.1

(a) ==unique_ptr — sole ownership, cheapest, "unique by default." (b) weak_ptr — non-owning observer; does not raise the strong count. (c) shared_ptr== — shared lifetime with a reference count.

Recall Solution L1.2

use_count() reports the strong count. Line (1) sets it to 1; each copy (b=a, c=b) is +1. So after line (3) the count is . All three names point at the same object and the same control block. Answer: 3.


Level 2 — Application

Recall Solution L2.1

Track strong line by line:

  • A: strong = 1 (just a).
  • B: copy → strong = 2 (a, b).
  • C: copy → strong = 3 (a, b, c).
  • D: c leaves inner scope → strong = 2.
  • E prints 2.
  • F: b leaves scope → strong = 1.
  • G prints 1. After G, a dies at end of program → strong = 0 → the int is deleted exactly once.
Recall Solution L2.2

Wrap the resource in a unique_ptr; its destructor runs on every exit path — normal return, early return, or a thrown exception. This is exactly RAII.

void f() {
    auto p = std::make_unique<Widget>();
    if (broken()) return;   // p's destructor runs -> no leak
    use(p.get());           // pass the raw pointer without giving up ownership
}                           // p's destructor runs here too

No hand-written delete survives, so leaks become impossible on this path.


Level 3 — Analysis

Recall Solution L3.1

Constructing a shared_ptr from a raw pointer creates a brand-new control block. Doing it twice from the same raw makes two independent control blocks, each with strong = 1.

  • a.use_count() reports 1.
  • b.use_count() reports 1. Neither knows about the other. When a dies it deletes the Widget (its count → 0); when b dies it deletes the same, already-freed Widget again → double-free → undefined behaviour / crash. Fix: create one shared_ptr and copy it, so both share one control block:
auto a = std::make_shared<Widget>();
auto b = a;   // strong = 2, one control block
Recall Solution L3.2

Copying a shared_ptr copies both pointers verbatim, then does cb->strong++. So:

  • distinct ptr values: 1 (both aim at the same object).
  • distinct cb values: 1 (both aim at the same control block — that is why the count is shared). Contrast with L3.1: two raw-pointer constructions → still 1 distinct ptr, but 2 distinct cb — the source of the bug. See Reference-counting.

Level 4 — Synthesis

Recall Solution L4.1

Follow the strong counts. Look at the figure below.

Figure — Smart pointers — unique_ptr (sole ownership), shared_ptr (shared ownership, ref count), weak_ptr (break cycles)
  • After make_shared: object-1 strong = 1 (name n1), object-2 strong = 1 (name n2).
  • n1->next = n2; copies into object-1's field → object-2 strong = 2.
  • n2->prev = n1; copies into object-2's field → object-1 strong = 2.
  • n1, n2 leave scope → each drops by 1: object-1 strong = 1, object-2 strong = 1.

(a) Object-1's strong count is 1 (never 0) — it is held alive by object-2's prev field, which is held alive by object-1's next field, and so on. A mutual, self-sustaining loop. (b) No — neither is freed. Both leak forever. Plain Reference-counting cannot collect cycles (unlike tracing Garbage-collection). (c) Make one back-pointer non-owning: change prev to std::weak_ptr<Node>. A weak_ptr does not bump the strong count, so after n1, n2 leave scope both counts reach 0 and both objects are freed.

Recall Solution L4.2
  • n1->next = n2; → object-2 strong = 2 (as before; next is still owning).
  • n2->prev = n1; → object-1 strong unchanged at 1; the weak assignment bumps only the weak count (to 1).
  • n1, n2 leave scope: object-1 strong = 0object-1 deleted. Deleting object-1 destroys its next field → object-2 strong = 1... then the name n2 was already gone, so object-2 strong = 0object-2 deleted. Both freed, no leak. Final strong counts: object-1 = 0, object-2 = 0.

Level 5 — Mastery

Recall Solution L5.1
  • B: w observes s but adds 0 to the strong count.
  • C: w.use_count() reports the strong count of the managed object, which is still just sprints 1.
  • D: s.reset() drops the last strong owner → strong = 0 → the int is deleted. (w is now expired; the object is gone but the control block survives because weak = 1.)
  • E: w.lock() tries to promote to a shared_ptr. Since strong is already 0, it cannot revive the object → returns an empty shared_ptrif is false.
  • The else runs → prints expired. This is why we lock() instead of dereferencing a weak_ptr directly: between "is it alive?" and "use it," another thread could reset the last owner. lock() atomically either pins the object or reports it gone — no dangling read.
Recall Solution L5.2

Ownership flows down the tree (parent owns children), and the back-pointer flows up (child sees parent). So:

struct Node {
    std::vector<std::shared_ptr<Node>> children;  // owns downward
    std::weak_ptr<Node>                parent;    // observes upward
};
  • Why shared_ptr for children? A parent genuinely co-owns its children (and other structures may too).
  • Why weak_ptr for parent? If it were shared_ptr, parent↔child would form a cycle → strong count of the parent never hits 0 → leak (exactly L4.1). Weak adds 0 to the strong count, so when the parent's last owner dies its count reaches 0 and the whole subtree tears down.
  • Rule-of-Zero: because every member is a smart pointer (or a container of them), the compiler-generated destructor, copy, and move already do the right thing. You write no destructor, no delete, no manual copy/move — "zero" special members. RAII plus smart pointers gives correct cleanup for free.

A child reaches its parent safely with if (auto p = parent.lock()) { ... }.


Recall One-line self-check

Own forward with shared_ptr, observe backward with weak_ptr, and reach for unique_ptr first ::: because sole ownership is cheapest and clearest; escalate to shared/weak only when lifetime is genuinely shared or you must break a cycle.