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:
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.