Skip to content

Module 11 — Worked Examples

Every example asks you to predict before it shows you the output, because the prediction is the exercise and the output is only the marking scheme. Where a program prints more than is useful here, an excerpt is marked with ….

The programs are in examples/module-11/, each folder with its own expected-output.txt. Build any of them with:

g++ -std=c++17 -Wall -Wextra main.cpp -o program && ./program

Two of these programs demonstrate a leak that no tool reports

04-shared-and-weak builds a reference cycle on purpose. Nothing in it is invalid — the memory is reachable, so it is not an error a sanitizer will name — and the program says so in its output. The counters are the only evidence, which is the lesson.


Example 1 — one RAII object, three ways out of a function [L4 Analyze]

The problem: a resource must be released when a function ends. The function has three kinds of ending. What does manual code do on each, and what does RAII do?

Before you read on, predict: the manual version leaks on two of the three paths. Which two, and how many devices are still open after all three functions have run? Then predict the same count for the RAII version, plus the one case where no destructor should run at all.

// examples/module-11/01-raii-across-exit-paths/main.cpp
void manualAcquireRelease(bool failWhileWorking) {
    int handle = acquire("manual sensor");     // the resource
    if (failWhileWorking) {
        throw std::runtime_error("sensor read failed");   // release is never reached
    }
    release(handle);
}

void raiiThrowWhileWorking() {
    DeviceGuard guard("raii sensor");          // acquires in its constructor
    throw std::runtime_error("sensor read failed");
}                                              // the destructor runs DURING unwinding

Output (excerpt):

Part 1 - the manual version, on each exit path
  path 2, early return:
  returned 1, devices open now: 1   <- the device was never closed
  path 3, an exception:
  caught: sensor read failed
  devices open now: 2   <- leaked again

  The manual function was correct on one path out of three, and
  nothing in the program said so.

Part 2 - the same three paths, with one RAII object
  path 3, an exception:
      [device] raii sensor acquired (handle 6); open now 3
      [device] handle 6 released; open now 2
  caught: sensor read failed

  And one more, for the case nobody writes a test for: the
  acquisition itself fails. The constructor throws, so no object is
  constructed, so no destructor runs - and nothing needs releasing.
  caught: device unavailable: absent sensor

Final books
  devices still open        2
  Every one of those 2 was leaked by the manual
  functions in Part 1, on a path their author did not think about.

Why it behaves that way: RAII is not a technique, it is a consequence. The language guarantees that leaving a scope destroys its objects, and it guarantees that for all three exits — normal, early return, and unwinding. Manual code has to reproduce that guarantee at every exit point by hand, and it never does, because the exit points are added later by somebody in a hurry. The refused acquisition is the subtle case: a constructor that throws means no object, so no destructor should run, and the counter confirms that nothing was leaked in the attempt.

What to take away: put the resource in an object and stop writing release statements. The destructor is the only cleanup point guaranteed to run on the path where the program is already in trouble.


Example 2 — three memory bugs, made countable instead of fatal [L4 Analyze]

The problem: leaks, use-after-free and double frees are the three ways raw new/delete goes wrong. All three are invisible in a small run and fatal in a large one. How do you see them?

Before you read on, predict: the program's shim tracks live blocks. After all five parts have run, how many blocks are still live, and which part left each one? Then predict the count for Part 5, where a block from new[] is released with delete.

// examples/module-11/02-raw-memory-bugs-made-visible/main.cpp
int* values = new int[3];        // Part 5: an ARRAY ...
delete values;                   // ... released with the wrong form

Output (excerpt):

Part 2 - the leak: a release on only one of the exit paths
  with the early return:
      [code] returning early; the block above is now unreachable
  live blocks 1   <- one block is now unreachable, and no code can ever free it

Part 3 - use after free: keeping a pointer past the release
      [shim] USE AFTER FREE - refusing to read released memory.
             Without the shim this would return stale bytes that
             usually look plausible.
  value -1   <- the bug: a pointer that is still valid as a number but no
  longer points at anything you own

Part 4 - releasing the same block twice
      [shim] DOUBLE FREE - reported and NOT performed.

Part 5 - the form mismatch: new[] released with delete
      [shim] FORM MISMATCH - this block came from new[] and is being released as delete.
             Undefined behaviour: the element count is wrong, so
             the wrong number of destructors run.

The books
  blocks acquired      7
  blocks released      6
  blocks still live    1   (the leak from Part 2 - and nothing else)

Why it behaves that way: a real program that makes these mistakes crashes, corrupts its heap, or works perfectly until the demonstration. The program therefore uses a small block table in place of the free store: the blocks are real memory, but the shim can report a second free rather than perform it, and refuse to read released memory rather than return stale bytes that look plausible. That turns three invisible bugs into three numbers — which is also how you would attack them in your own code when no sanitizer is available.

The exception is Part 5, and it is worth dwelling on: new[] with delete is undefined behaviour, and the usual symptom is an allocator crash much later, in code with no connection to the mistake. The shim can report it because it knows how the block was allocated; in a real program nothing knows.

What to take away: the three habits that remove all five bugs are in the program's own summary — do not call new/delete yourself; if a class owns a raw pointer, write the Rule of Three or delete its copies; and never keep a raw pointer to something you do not own. The rest of this module is the third habit, expressed in types.


Example 3 — unique_ptr: what it costs and what it forbids [L2 Apply]

The problem: the same code written with raw pointers and with unique_ptr. What changes at run time, and what changes at compile time?

Before you read on, predict: how many bytes does a unique_ptr occupy compared with a raw pointer? Then answer the design question: of these three functions, which one tells the reader that ownership is being transferred?

// examples/module-11/03-unique-ownership/main.cpp
double area(const Shape& shape);                       // borrow, cannot be null
void print(const Shape* shape);                        // borrow, may be null
void take(std::unique_ptr<Shape> shape);               // OWN: the ownership moves in

Output (excerpt):

Part A - what a unique_ptr costs and what it forbids
  sizeof(std::unique_ptr<int>)   8
  sizeof(int*)                   8
  same size: yes   <- exclusive ownership is not paid for at run time
  std::is_copy_constructible<std::unique_ptr<Shape>> -> false
  std::is_move_constructible<std::unique_ptr<Shape>> -> true
  So `auto copy = owner;` does not compile. That compiler error is
  the feature: it stops the second owner existing at all.

Part B - the three signatures, and what each one promises
  borrowed (const&): Rectangle 3 x 4, area 12
  took ownership of Rectangle 3 x 4, area 12; live shapes while THIS parameter owns it: 1
  after the move, the caller's pointer is null: true

Part E - the raw-pointer version of Part C, for comparison
      early return: live shapes 1   <- one shape is now unreachable: a leak
  the same two calls, with one word changed - `Shape*` becomes
  `std::unique_ptr<Shape>`, and `new` becomes `make_unique`:
      early return: live shapes 1   <- released anyway

The books
  shapes created   10
  shapes alive     1   <- exactly the one Part E leaked between its two calls

Why it behaves that way: unique_ptr stores one pointer and nothing else, so the ownership information is free. What it buys is a compile error instead of a memory bug: the copy constructor is deleted, so a second owner cannot exist. Part E is the module's argument in one comparison — the same function, changed from Shape*/new to unique_ptr/make_unique, stops leaking on the early-return path with no release statement written anywhere.

Note also what Part B establishes: the same object passes through three different signatures, and only one of them takes it. Reading a unique_ptr parameter tells you the callee keeps the object; reading T& tells you it must not.

What to take away: exclusive ownership costs nothing and is checked by the compiler. Make it the default for anything created with new, and pass references when the callee is only looking.


Example 4 — the cycle that no tool reports [L4 Analyze]

The problem: two objects own each other with shared_ptr. Both objects are reachable, nothing is dangling, and memory grows forever. What exactly happens, and why is a leak detector quiet?

Before you read on, predict: the program builds three nodes with owning links in both directions, then lets the local shared_ptrs die. How many destructor lines do you expect — and what does each node's use_count() print before the scope ends?

// examples/module-11/04-shared-and-weak/main.cpp
struct Node {
    std::shared_ptr<Node> next;        // owning link forward
    std::shared_ptr<Node> previous;    // owning link BACK - the mistake
    std::string name;
};

Output (excerpt):

Part B - the cycle: a leak that no pointer can be blamed for
      [graph] first use_count 2, second use_count 3, third use_count 2
      [graph] leaving the function: three shared_ptrs are about to die
  alive now 3   <- three nodes have leaked

  No Node object is unreachable by the usual test. first holds second,
  second holds first, and the pair's two counts are each kept above
  zero by the other. A leak detector reports these as 'still reachable'
  rather than 'definitely lost', which is why cycles are so often missed.

Part C - breaking the cycle with a weak_ptr
      [graph] first use_count 1, second use_count 1, third use_count 1
      [node] third destroyed; alive now 5
      [node] second destroyed; alive now 4
      [node] first destroyed; alive now 3
  a weak_ptr does not keep the node alive: use_count 1, expired false
  lock() succeeded while the owner existed: watched, and use_count is now 2
  the owner is gone: expired true, lock() returns null

Why it behaves that way: a shared_ptr destroys its object when its count reaches zero. In a cycle each count is held above zero by the other link, so no count ever reaches zero — there is no moment at which the last owner disappears. Changing one link to weak_ptr removes it from the count, so the forward owners are the only owners, and the nodes die in reverse order as you would expect.

The second half of Part C is the mechanism you will use in practice: weak_ptr does not extend the lifetime, lock() gives you a temporary owner when the object still exists, and after the last owner dies expired() is true and lock() returns an empty pointer. Note the safe sequence — lock(), then check, then use — rather than expired(), then use.

What to take away: owning links must form no cycle. Where a class needs a link back up a relationship — a Loan to its item, a child to its parent, a cached entry to its cache — that link is a weak_ptr or a reference, never a second shared_ptr.


Example 5 — two control blocks, one object [L4 Analyze]

The problem: the object is created once, two shared_ptrs refer to it, both counts print 1, and the object is destroyed twice. Where did the second count come from?

Before you read on, predict: what does each use_count() print, and how many times is the object destroyed? Then predict which single change removes the bug: replacing new with make_shared, replacing the second shared_ptr with a copy of the first, or adding a virtual destructor.

// examples/module-11/04-shared-and-weak/main.cpp, Part D
Node* raw = new Node("one-object-two-control-blocks");
std::shared_ptr<Node> first(raw);
std::shared_ptr<Node> second(raw);      // compiles. Two control blocks.

Output (excerpt):

Part D - the other shared_ptr mistake: two control blocks for one object
  first.use_count()  1
  second.use_count() 1
  Both say 1, because each is counting in its own control block.
  Each believes it is the only owner, so each will delete the same
  object. The reference count does not protect you here: it is stored
  per control block, and the mistake is having two.
      [deleter] refusing to destroy "one-object-two-control-blocks" again: it was already destroyed.
  second deletions refused 1

  This is why `std::shared_ptr<T> p(new T)` should never be written, and
  `std::make_shared<T>()` should be - and why a factory that returns a
  shared_ptr must RETURN a shared_ptr, rather than handing out a raw
  pointer that each caller wraps for itself.

Why it behaves that way: the count is not a property of the object; it is a property of the control block that the shared_ptr created. Construct two shared_ptrs from one raw pointer and you have two counts, each protected and each wrong. make_shared creates the object and its single control block together, which is why it is both faster and correct — and it is why a factory should return shared_ptr rather than something the caller must wrap.

What to take away: one object, one control block. make_shared, enable_shared_from_this and shared_from_this() are the three ways to guarantee it; shared_ptr<T>(raw) is the way to break it (and it compiles, silently, which is the trap).


Example 6 — resources that are not memory [L5 Design]

The problem: a C library hands out a device handle that must go back through its close function, not through delete. And a file descriptor is an int, so there is nothing to point at. What does ownership look like for each?

Before you read on, predict: which of the two resources can be managed with std::unique_ptr<T, Deleter>? Then predict the size of unique_ptr<DeviceHandle, void(*)(DeviceHandle*)> compared with unique_ptr<DeviceHandle, DeviceCloser> where DeviceCloser is an empty class.

// examples/module-11/05-custom-deleters/main.cpp
using DevicePtr = std::unique_ptr<DeviceHandle, void (*)(DeviceHandle*)>;   // 16 bytes: stores the fn
using CompactPtr = std::unique_ptr<DeviceHandle, DeviceCloser>;             //  8 bytes: empty class

class DescriptorGuard {          // for an int file descriptor: no unique_ptr can hold this
public:
    explicit DescriptorGuard(const char* path);
    ~DescriptorGuard() noexcept { if (fd_ >= 0) { ::close(fd_); } }
    DescriptorGuard(const DescriptorGuard&) = delete;
    DescriptorGuard& operator=(const DescriptorGuard&) = delete;
private:
    int fd_ = -1;
};

Output (excerpt):

Part B - the function-pointer deleter
  open: id 1, devices open 1
  leaving the scope now - the deleter runs, not delete[]:
      [device] closing "thermometer" (id 1); open now 0

Part C - the three exit paths again, with a deleter in place
  after an early return: devices open 0
  after an exception: devices open 0 (caught: reading the flow sensor failed)
  and the case where the resource never existed: the C API returned
  null, the factory threw, and no unique_ptr was ever constructed

Part D - what the deleter costs, and how to make it free
  sizeof(std::unique_ptr<DeviceHandle, void(*)(DeviceHandle*)>) 16
  sizeof(std::unique_ptr<DeviceHandle, DeviceCloser>)          8
  sizeof(DeviceHandle*)                                        8

Part E - a resource that is not a pointer: a deleter cannot help
  a file descriptor in POSIX is an int ... There is nothing to point at,
  so `unique_ptr<T, D>` has nothing to hold. Write the small RAII class
  instead - the same three lines example 01 used.

How to choose, in one table
  resource is a pointer you got from `new`        unique_ptr<T>
  resource is a pointer from a C API              unique_ptr<T, Deleter>
  resource must outlive any single owner          shared_ptr<T, Deleter>
  resource is not a pointer (int handle, fd)      a hand-written RAII class
  resource is a value you can simply own          a member, by value

Why it behaves that way: the deleter is a second template argument, and it is stored in the unique_ptr when it is a function pointer — which is why the size doubles. A stateless functor needs no storage at all: the deleter's type already says how to release, so the empty class occupies no bytes. Prefer the functor; use the function pointer when the deleter must be chosen while the program runs.

A file descriptor breaks the pattern entirely, and the honest answer is to stop reaching for a smart pointer: a smart pointer needs something to point at. Fifteen lines of RAII class, the same shape as Example 1's guard, is smaller than a workaround.

What to take away: ownership is a property of a resource, not of memory. Naming the resource's release function, and putting it in a destructor or a deleter, is the whole technique — and the one case that cannot be solved with a smart pointer is the resource that is not a pointer.


Next: Misconceptions and mistakes — nine beliefs that feel obviously true, and the diagnostic that contradicts each one.