Skip to content
VLSI Mentor

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    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 is shared_ptr working correctly on a graph that has a loop in it.

2. The Cycle, Measured

Azvya Education Pvt. Ltd.VLSI Mentor
cycle.cpp — part A
    auto a = std::make_shared<NodeShared>("a");
    auto b = std::make_shared<NodeShared>("b");
    a->peer = b;
    b->peer = a;                          // the cycle
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A. 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 ran

Both 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

Two nodes each holding a shared_ptr to the other: both reference counts stay at 2, so neither reaches zero and neither destructor runs.Node ause_count = 2Node buse_count = 2Neither reaches0no destructor runsshared_ptrresult12
Figure 1 — why a cycle of shared_ptr never releases. Two nodes each hold a shared_ptr to the other, so both reference counts stay at two; neither reaches zero, neither destructor runs, and the memory is unreachable but never freed. Replacing one of the two links with a weak_ptr is what breaks the cycle.

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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
weak_ptr does NOT raise use_count:
  after weak_ptr w = a     : use_count=1  weak expired=0

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    if (auto p = w.lock()) {
        p->run();                  // guaranteed alive for as long as p exists
    }

Measured through an object's whole life:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 safely

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    if (!w.expired()) { auto p = w.lock(); p->run(); }      // WRONG shape

The 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, call lock() 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:

Azvya Education Pvt. Ltd.VLSI Mentor
cycle.cpp — part B
    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;
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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: 0

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

ShapeThe weak edge
parent owns children, each child points back to its parentthe back-pointer
an observer list, where observers also hold the subjectthe observer's reference to the subject
a cache whose entries point back to the cachethe entry's pointer to the cache
a graph or doubly-linked structure with symmetric linksone 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

  1. A reference cycle is shared_ptr working correctly on a graph with a loop — not a misuse.
  2. Measured: two mutually-referring nodes gave use_count()=2 each, no destructor output, and live=2 after the scope.
  3. No crash, no warning, no diagnostic at any level — the program reported success.
  4. Measured, same binary: cycle -> 0 leaks, raw -> 1 leak for 96 bytes, both -> 1 leak for 96 bytes.
  5. The both row is identical to raw — the cycle contributed nothing to a report from a demonstrably working tool.
  6. ASan cannot help here: detect_leaks is not supported on this platform.
  7. leaks reports unreachable blocks, and a cycle is reachable from itself — a principled limit, not a defect.
  8. A tracing collector would find it; reference counting cannot.
  9. weak_ptr observes without raising the strong count — 16 bytes, two pointers, incrementing the control block's weak count.
  10. It has no operator* or operator-> on purpose.
  11. lock() returns a shared_ptr — measured raising the count 1 → 2 → 1, so the object cannot die while you hold it.
  12. expired() leaves a gap; use it only for answers you do not act on. (Reasoning, from 14.5 §5's atomics — not measured here.)
  13. Measured: one weak back-edge gave counts 1 and 2, both destructors ran, live=0.
  14. 1 and 2, not 1 and 1 — the strong edge was never the problem.
  15. One weak edge per cycle suffices, and it is the non-owning direction.
  16. 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_ptr behaving 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: leaks finds unreachable blocks, and a cycle is reachable from itself.
  • weak_ptr observes 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.