C++ Part I · Module 14 · Smart Pointers and Ownership
std::weak_ptr and Breaking Reference Cycles
Every failure in Module 10 came from someone forgetting to release something. Reference counting removes that class of bug entirely — nobody has to remember, because the count does the remembering. This chapter is the one failure the count creates rather than prevents, and it is the only one in this module with that property. Two objects that each hold a shared_ptr to the other keep each other alive forever: each one's count is held above zero by the other, so neither is ever destroyed, and no amount of care at the call sites helps because every individual shared_ptr in the program is behaving exactly as documented. Measured here as both counts stuck at 2, neither destructor running, and two objects still live when the program exits. The part worth seeing is not the leak but what the tools said about it. The leak detector that found a raw new in the very same binary — verified working, on a control case, in the same run — reported zero leaks for zero total leaked bytes on the cycle. AddressSanitizer cannot help either on this platform. So this is a leak that is invisible to the instruments that caught every other leak in this curriculum, which makes knowing the shape by eye the only real defence. The fix is a second pointer type that can see an object without keeping it alive, and asking it for the object is itself the interesting part: the answer has to be a yes-or-no and a usable pointer at the same time, because anything less is a race.
Advanced20 min readC++Part IFundamentalsOwnership
Part I · Module 14 · Chapter 14.6 · std::weak_ptr and Breaking Reference Cycles
1. The Failure the Count Creates
Chapter 14.5 §3 measured the rule: the object is destroyed when the strong count reaches zero. Everything about shared_ptr follows from it, including this chapter's bug.
Consider what keeps a count above zero: another shared_ptr existing. Now suppose the thing holding that shared_ptr is the object at the other end:
struct NodeShared {
const char* name;
std::shared_ptr<NodeShared> peer; // a SHARED back-reference
};a->peer = b and b->peer = a. Each object is now held alive by the other, so neither count can fall to zero, so neither is destroyed, so neither releases its peer, so neither count falls. Nothing is wrong with any individual pointer — each is doing precisely what Chapter 14.5 documented.
A reference cycle is not a misuse of
shared_ptr. It isshared_ptrworking correctly on a graph that has a loop in it.
2. The Cycle, Measured
auto a = std::make_shared<NodeShared>("a");
auto b = std::make_shared<NodeShared>("b");
a->peer = b;
b->peer = a; // the cycleA. two shared_ptrs referring to each other:
+ a
+ b
a.use_count()=2 b.use_count()=2
leaving the scope...
live objects after the scope: 2 <-- NEITHER destructor ranBoth counts are 2 — one from the local variable, one from the other node. Leaving the scope destroys the two locals, taking each count from 2 to 1, and there it stops. No - a or - b line ever printed, and the live-object counter says 2 after the scope ended.
Note what did not happen: no crash, no diagnostic, no warning at any level. The program ran to completion and reported success.
3. Why Neither Count Reaches Zero
The arrow between the nodes points both ways, and that is the entire bug. Destroying a requires b's peer to go away, which requires b to be destroyed, which requires a's peer to go away. Each precondition is the other's conclusion.
This is also why no cleverness at the call site helps. The code that created the cycle is correct, the code that destroys the locals is correct, and the destructor that would break the cycle is the one thing that never runs.
4. What the Tools Said
This is the part worth remembering. The same binary, three modes — a cycle only, a raw new int[20] only, and both — under macOS's leaks:
cycle -> Process: 0 leaks for 0 total leaked bytes.
raw -> Process: 1 leak for 96 total leaked bytes.
both -> Process: 1 leak for 96 total leaked bytes.Read those three lines carefully, because the third is the one that matters.
The tool works. The raw row proves it: a deliberately leaked new int[20] was found and reported. Chapter 10.6 §6 established this negative-control habit, and Chapter 14.4 §10's callout argued it is the difference between clean and I did not measure anything.
The cycle alone reports nothing. 0 leaks for 0 total leaked bytes — on a program that leaked two 80-byte objects and two control blocks.
And the both row is identical to raw. Same count, same byte total. The cycle contributed nothing at all to a report produced in the same run, by a working tool, on a program that was simultaneously leaking something the tool caught. There is no interpretation in which that is a configuration problem.
AddressSanitizer is not an alternative here:
AddressSanitizer: detect_leaks is not supported on this platform.Chapter 10.6 §5 recorded this platform limit already. So on this toolchain the cycle is invisible to both instruments.
5. Why leaks Cannot See It
The reason is not a defect, and understanding it tells you which tools ever could help.
leaks works by reachability. It scans the stack, registers and globals for anything that looks like a pointer, follows every such pointer into the heap, follows pointers found there, and reports blocks nothing pointed to. A classic leak — Chapter 10.6 §2's shapes — is unreachable: the last pointer to it was overwritten or went out of scope.
A cycle is reachable from itself. a is pointed to by b->peer, and b is pointed to by a->peer. Every block in the cycle has a pointer to it living in the heap, so by the only definition leaks has, nothing is lost.
So this is a leak in the sense that matters — the memory is never returned and the program's footprint grows — but not in the sense the tool is built to detect. The two definitions diverge exactly and only on cycles. That is also why a tracing garbage collector would find it and a reference-counting scheme cannot: tracing starts from the roots and asks what is reachable from outside, which a cycle is not.
Which leaves eyes. The shape — two owning pointers in opposite directions — has to be recognised while the code is being written, which is why §10 lists where it comes from.
6. std::weak_ptr: Observation Without Ownership
std::weak_ptr<T> points at an object managed by a shared_ptr without contributing to the strong count. Chapter 14.5 §3 measured that:
weak_ptr does NOT raise use_count:
after weak_ptr w = a : use_count=1 weak expired=0It is the same 16 bytes as a shared_ptr — two pointers, object and control block — and it increments the control block's weak count instead. That second count is what Chapter 14.5 §2 listed and §7 measured the consequence of: the control block must survive while any weak_ptr exists, because something has to remain to answer is the object still there.
What a weak_ptr cannot do is dereference. There is no operator* and no operator->, and the omission is deliberate — between your check and your use, the last shared_ptr could go away.
7. lock(): How You Ask Safely
lock() returns a shared_ptr: non-empty if the object is alive, empty if it is gone.
if (auto p = w.lock()) {
p->run(); // guaranteed alive for as long as p exists
}Measured through an object's whole life:
A. a default-constructed weak_ptr:
expired=1 use_count=0 lock()==nullptr -> 1
B. while the object is alive:
expired=0 use_count=1
lock() succeeded: id=7, and use_count is now 2
after the lock went out of scope, use_count=1
leaving the owner's scope...
- Model 7
C. after the owner is gone:
expired=1 use_count=0
lock() returned empty -- the object is gone, and we asked safelyThe line to notice is in B: use_count is now 2. lock() did not merely report that the object was alive — it became an owner, temporarily. That is the whole design. For as long as you hold the shared_ptr that lock() returned, the object cannot be destroyed, whatever any other part of the program does. When the lock goes out of scope the count drops back to 1, and in C the object is gone and lock() says so by returning empty.
And in A, a weak_ptr that never pointed at anything behaves the same way as an expired one — expired=1, lock() empty. There is no separate "never set" case to handle.
8. expired() Is Not a Substitute
if (!w.expired()) { auto p = w.lock(); p->run(); } // WRONG shapeThe problem is the gap. expired() answers a question about the past; by the time the next statement runs, the last shared_ptr elsewhere may have gone and the answer may be stale. Then p is empty and p->run() dereferences null.
This is reasoning rather than a measurement, and it is worth saying which. I have not demonstrated the race here, because doing so would need the concurrency machinery Module 26 introduces. But the reasoning rests on something Chapter 14.5 §5 did measure: the count is manipulated by atomic instructions precisely because other threads change it underneath you. If the count can change between two of your statements — and the atomics exist because it can — then a check in one statement does not constrain the next.
lock() has no gap. It consults the count and takes a reference as one atomic operation, so a non-empty result is a guarantee rather than a report.
Use
expired()only for a question you do not act on — a diagnostic, a log line, pruning a list of dead observers. To use the object, calllock()and test its result.
Even single-threaded, the lock() form is better shape: one operation, one test, and the pointer you tested is the pointer you use.
9. Breaking the Cycle, Measured
Make one edge weak:
struct NodeWeak {
const char* name;
std::shared_ptr<NodeWeak> next;
std::weak_ptr<NodeWeak> back; // weak, so no cycle
};
auto a = std::make_shared<NodeWeak>("a");
auto b = std::make_shared<NodeWeak>("b");
a->next = b;
b->back = a;B. the same shape with a weak back-reference:
+ a
+ b
a.use_count()=1 b.use_count()=2
leaving the scope...
- a
- b
live objects after the scope: 0The counts are 1 and 2, not 1 and 1, and the asymmetry is the proof that the fix worked.
a's count is 1 — only the local variable. b->back is weak and contributed nothing, which is exactly what changed. b's count is still 2, because a->next is a genuine strong edge and b really is owned by two things: the local b and the node a. That edge was never the problem.
Now the teardown works. Destroying the locals takes a to 0, so a is destroyed; destroying a releases a->next, taking b from 1 to 0, so b is destroyed. Both destructors ran, in that order, and live objects after the scope: 0.
One weak edge per cycle is sufficient, and it should be the one that is conceptually not ownership — which in practice it almost always obviously is.
10. Where Cycles Come From
They are not exotic. Four shapes account for most of them, and each has an obvious weak edge.
| Shape | The weak edge |
|---|---|
| parent owns children, each child points back to its parent | the back-pointer |
| an observer list, where observers also hold the subject | the observer's reference to the subject |
| a cache whose entries point back to the cache | the entry's pointer to the cache |
| a graph or doubly-linked structure with symmetric links | one direction, chosen deliberately |
The pattern across all four: a cycle appears when a relationship that is not ownership is spelled as ownership. The child does not own its parent; the observer does not own what it observes; the entry does not own the cache. Chapter 14.1's argument was that a pointer type is a statement about ownership, and a cycle is what happens when that statement is made carelessly in both directions.
Which leads to the practical rule: in a bidirectional relationship, decide which direction is ownership, and make the other one weak — or, as the next chapter argues, a plain raw pointer.
11. What to Actually Do
In any bidirectional relationship, name the owning direction and make the other weak.
Default the back-pointer to weak — child to parent, observer to subject, entry to cache.
Use lock() and test the result, never expired() followed by use.
Use expired() only for answers you do not act on — logs, pruning, diagnostics.
Do not expect leaks or ASan to find a cycle. Measured: 0 leaks for 0 total leaked bytes.
Run a negative control before trusting any silence.
And if a structure genuinely needs ownership in both directions, reconsider the structure — that is a statement that two objects are each responsible for the other, which is not a thing.
12. Mistakes Worth Making Once
Two shared_ptrs pointing at each other. Measured: counts 2 and 2, live=2, no destructors.
Trusting a clean leaks run. Measured 0 leaks on a program leaking two objects.
Expecting ASan to catch it. detect_leaks is not supported on this platform.
if (!w.expired()) w.lock()->run(); — a stale answer and a null dereference.
Dereferencing a weak_ptr. There is no operator->; this is deliberate.
Making both edges weak. Then nothing owns the object and it dies immediately.
Expecting both counts to be 1 after the fix. Measured 1 and 2 — the strong edge is still real and still correct.
Assuming a live weak_ptr delays the destructor. It delays only the storage, and only with make_shared — Chapter 14.5 §7.
Spelling a back-pointer as ownership because it was the easiest thing to type.
13. Interview Relevance
"What problem does weak_ptr solve?" Reference cycles — and the strong answer explains that a cycle is shared_ptr behaving correctly on a looped graph, not a misuse.
"Walk me through why the cycle leaks." Each object's count is held above zero by the other, so neither destructor runs, so neither releases its reference. Measured: both counts 2, two objects live at exit.
"Will a leak detector find it?" No, and knowing why is the discriminator: leaks reports unreachable blocks, and a cycle is reachable from itself. Measured as 0 leaks next to a 1 leak control in the same binary.
"Why lock() rather than expired()?" lock() checks and takes a reference atomically; expired() answers about the past and leaves a gap.
"What does lock() do to the count?" Raises it while the returned shared_ptr lives — measured 1 → 2 → 1 — which is what makes the object safe to use.
"Which edge should be weak?" The non-owning one: child to parent, observer to subject. One per cycle suffices.
"What are the counts after fixing it?" 1 and 2 — a good follow-up, because 1 and 1 is the intuitive wrong answer.
14. What You Should Be Able to Say Now
- A reference cycle is
shared_ptrworking correctly on a graph with a loop — not a misuse. - Measured: two mutually-referring nodes gave
use_count()=2each, no destructor output, and live=2 after the scope. - No crash, no warning, no diagnostic at any level — the program reported success.
- Measured, same binary:
cycle -> 0 leaks,raw -> 1 leak for 96 bytes,both -> 1 leak for 96 bytes. - The
bothrow is identical toraw— the cycle contributed nothing to a report from a demonstrably working tool. - ASan cannot help here:
detect_leaks is not supported on this platform. leaksreports unreachable blocks, and a cycle is reachable from itself — a principled limit, not a defect.- A tracing collector would find it; reference counting cannot.
weak_ptrobserves without raising the strong count — 16 bytes, two pointers, incrementing the control block's weak count.- It has no
operator*oroperator->on purpose. lock()returns ashared_ptr— measured raising the count 1 → 2 → 1, so the object cannot die while you hold it.expired()leaves a gap; use it only for answers you do not act on. (Reasoning, from 14.5 §5's atomics — not measured here.)- Measured: one weak back-edge gave counts 1 and 2, both destructors ran, live=0.
- 1 and 2, not 1 and 1 — the strong edge was never the problem.
- One weak edge per cycle suffices, and it is the non-owning direction.
- A tool's silence has three causes — nothing wrong, check disabled, bug unrepresentable — and only a negative control tells them apart.
15. Exercises
Exercise 1 — the mechanism. Explain in three sentences why two mutually-referring shared_ptrs never destroy their objects, without using the word "leak".
Exercise 2 — predict the counts. Two nodes, both edges strong, both locals in scope. Give both use_count() values, then give them again after the locals are destroyed.
Exercise 3 — the tool. A colleague runs leaks, sees 0 leaks, and declares the program clean. Say what you would ask for, and why the both row of §4's table settles it.
Exercise 4 — reachability. Explain why a cycle is not a leak by leaks's definition, and name a kind of collector that would catch it.
Exercise 5 — fix the shape. if (!w.expired()) { auto p = w.lock(); p->run(); } — say what is wrong and rewrite it.
Exercise 6 — the asymmetry. After making the back-edge weak, the counts are 1 and 2. Explain both numbers and say why 1 and 1 would be wrong.
Exercise 7 — choose the weak edge. For each of §10's four shapes, name the edge you would make weak and say why that direction is not ownership.
Exercise 8 — both weak. Say what happens if you make both edges of the pair weak, and when the objects are destroyed.
Exercise 9 — the three silences. Give a concrete example of each cause of a clean tool run from this module, and the control that would distinguish it.
16. Summary
- A cycle is
shared_ptrbehaving correctly on a looped ownership graph — every individual pointer is right. - Measured: both counts 2, no destructors, live=2 at scope exit, with no diagnostic of any kind.
- Measured in one binary:
cycle -> 0 leaks for 0 total leaked bytes,raw -> 1 leak for 96 bytes,both -> 1 leak for 96 bytes— identical to raw. - ASan's leak detection is unavailable on this platform, so both instruments are blind to it.
- The limit is principled:
leaksfinds unreachable blocks, and a cycle is reachable from itself. weak_ptrobserves without owning and deliberately cannot be dereferenced.lock()checks and takes a reference atomically — measured raising the count 1 → 2 → 1.expired()leaves a gap.- Measured: one weak back-edge gave counts 1 and 2 and destroyed both objects — the strong edge was never the problem.
- Decide the owning direction in every bidirectional relationship, and run a negative control before trusting any silence.
A weak_ptr solved the cycle by expressing I need to see this object but I do not own it, and that sentence should look familiar, because it is the same sentence Chapter 14.1 §5 attached to a const T& parameter and a plain T*. Which raises a question this module has been circling since its first page and has not yet answered head-on: if a raw pointer says exactly that, when should you use one, and when does a weak_ptr earn its 16 bytes and its lock() call instead? The next chapter is the argument that raw pointers are not legacy and have a precise remaining job — observing, never owning — along with the measurement that tells the two cases apart, which turns out to be a question about lifetimes rather than about taste.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
