Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 30

Breaking Circular References with `std::weak_ptr`

Recommended resources

Hello! Welcome to the next lesson in our journey through C++ resource management.

In our last lesson, we built a basic reference-counted smart pointer from scratch, which demystified the core mechanics of std::shared_ptr. We ended on a critical observation: our implementation, like std::shared_ptr itself, is vulnerable to a specific type of memory leak caused by circular references.

Today, we'll tackle that problem head-on. The learning outcome for this lesson is to use std::weak_ptr to break circular references involving std::shared_ptr. You will learn what a std::weak_ptr is, how it works under the hood by interacting with the shared_ptr's control block, and how to use it safely to observe an object without creating ownership claims that lead to leaks.

The Problem: Circular References

When two or more objects hold std::shared_ptrs to each other in a cycle, they create a situation where their reference counts can never reach zero, even after all external pointers to them are gone. This means their destructors are never called, and their memory is leaked.

To see this in action, let's study a classic example.

Memory management using Smart Pointers in C++ - Part 2

The article "Memory management using Smart Pointers in C++ - Part 2" by Pratik Parvati provides a perfect illustration of this problem. Please read the first part to understand how a circular dependency with std::shared_ptr leads to a memory leak.

Read the section titled "Cyclic reference problem with shared_ptr". Pay close attention to the MyClassA and MyClassB example. Trace the reference counts as the useClassAnClassB function exits to see why neither object is destroyed.

As the article demonstrates, when useClassAnClassB finishes, the shared_ptrs a and b go out of scope.

  • The destructor for a is called, and the reference count for the MyClassA object drops from 2 to 1 (because b's internal pointer b->a still exists).
  • The destructor for b is called, and the reference count for the MyClassB object drops from 2 to 1 (because a's internal pointer a->b still exists).

The result: both objects still have a reference count of 1. They are keeping each other alive, but they are inaccessible from anywhere else in the program—a classic memory leak.


The Solution: A "Weak" Reference

To solve this, we need a way for one object to hold a reference to another without claiming ownership. This is precisely what std::weak_ptr is for. It's a non-owning smart pointer that observes an object managed by a std::shared_ptr but does not affect its lifetime.

How weak_ptr Works Internals

Remember the control block from our std::shared_ptr discussions? To support weak_ptr, the control block actually contains two reference counters:

  1. Strong Reference Count (use_count): This is the count of std::shared_ptr instances owning the object. When this count drops to zero, the managed object is deleted.
  2. Weak Reference Count: This is the count of std::weak_ptr instances observing the object.

A std::weak_ptr only affects the weak count. It never increments or decrements the strong count. The control block itself is only deleted when both the strong count and the weak count drop to zero.

Shared and Weak Pointer Control Block Relationship
This diagram illustrates the relationship. Both the `shared_ptr` and the `weak_ptr` point to the same control block. The `shared_ptr` influences the `shared_count` (strong count), while the `weak_ptr` influences the `weak_count`. The managed object's lifetime depends only on the `shared_count`.

This two-counter mechanism is the key to breaking cycles. An object in a cycle can hold a weak_ptr to the next object, "observing" it without preventing its destruction.


Using std::weak_ptr Safely

You cannot dereference a weak_ptr directly with * or ->. This is by design. Because a weak_ptr doesn't own the object, the object could be deleted at any time by the last shared_ptr that owns it, leaving the weak_ptr dangling. Accessing a dangling pointer would lead to undefined behavior, the very thing smart pointers are designed to prevent.

So, how do you safely access the object? You must temporarily "promote" the weak_ptr to a shared_ptr. This is done with the lock() method.

std::weak_ptr<MyClass> weak_p = ...;

// To safely access the object:
if (std::shared_ptr<MyClass> shared_p = weak_p.lock()) {
    // If lock() succeeds, shared_p is a valid shared_ptr.
    // The strong reference count is temporarily incremented.
    // The object is guaranteed to exist inside this if-block.
    shared_p->do_something();
} else {
    // The object has already been destroyed.
    // lock() returned a null shared_ptr.
}

The lock() operation is atomic. It checks if the object still exists and, if so, increments the strong reference count and returns a valid shared_ptr all in one uninterruptible step. This is critical for thread safety, a topic we will delve into deeply when building our thread pool.

To see a clear explanation of this mechanism, let's turn to a video.

Weak Pointers in C++ (std::weak_ptr)

The video "Weak Pointers in C++" from The Cherno provides an excellent walkthrough of the weak_ptr mechanics.

Watch from 08:34 to 11:08. Pay close attention to how lock() is used to create a temporary shared_ptr and why this is necessary for safe access, especially in a multithreaded environment.

Deep Dive: Why lock() is Essential for Concurrency

You might wonder, "Why not just check if the pointer has expired and then use it?" like this:

// ANTI-PATTERN: This is NOT thread-safe!
if (!weak_p.expired()) {
    // Problem: a different thread could destroy the object *right here*
    // after the check but before we create a shared_ptr.
    std::shared_ptr<MyClass> shared_p(weak_p); // This can lead to an exception or UB
    shared_p->do_something();
}

This introduces a race condition. The lock() method avoids this by performing the check and the increment as a single atomic operation. Let's watch a segment from a CppCon talk that explains this exact point.

Back to Basics: Smart Pointers and RAII - Inbal Levi - CppCon 2021

In her CppCon 2021 talk "Back to Basics: Smart Pointers and RAII", Inbal Levi explains precisely why the if(expired()) pattern is unsafe in a multi-threaded context and why lock() is the correct, safe approach.

Watch from 00:24:50 to 00:28:13. Focus on the slide that shows the context switch between two threads and how it leads to a dangling pointer if lock() is not used.

This detail is a perfect example of the "under the hood" thinking required for robust systems programming, especially with concurrency. The lock() pattern is fundamental to using weak_ptr correctly.


Breaking the Cycle in Code

Now that we understand the weak_ptr and the lock() pattern, let's apply it to fix our original memory leak. The solution is to identify one of the links in the reference cycle and replace it with a std::weak_ptr. This designates one of the relationships as non-owning, breaking the "ownership cycle".

Let's return to the dev.to article to see the corrected code.

Memory management using Smart Pointers in C++ - Part 2

Now, let's see the solution in action. The same article demonstrates how replacing one shared_ptr with a weak_ptr breaks the cycle.

Read from the heading "Weak pointer std::weak_ptr<>" to the end of the code block showing the corrected MyClassB. Notice that MyClassB now holds a std::weak_ptr<MyClassA>. Trace the reference counts again to confirm that the destructors are now called.

By changing MyClassB's member to a weak_ptr, we state that MyClassA owns MyClassB, but MyClassB only observes MyClassA. When the useMyClassAnMyClassB function ends:

  1. The shared_ptr a goes out of scope. The strong count for the MyClassA object becomes 1 (held by b->a). Wait, b->a is a weak_ptr, so it doesn't count. The strong count for MyClassA object becomes 0. The MyClassA object is destroyed.
  2. The destructor of MyClassA destroys its member a->b, which is a shared_ptr<MyClassB>. This drops the strong count of the MyClassB object to 0. The MyClassB object is destroyed.
  3. The original shared_ptr b from the function scope goes out of scope, but the object it managed is already gone.

The cycle is broken, and the memory is correctly reclaimed.

Test your understanding!

Imagine a Parent class and a Child class. A parent can have multiple children, and each child needs to know its parent.

struct Parent; // Forward declaration

struct Child {
    // What kind of pointer to Parent should go here?
    ??? parent; 
};

struct Parent {
    // A Parent can have many children, so let's use a vector of shared_ptrs.
    std::vector<std::shared_ptr<Child>> children;
};

What type should you use for the parent member in the Child struct to avoid a circular reference? Why?

Show answer

You should use std::weak_ptr<Parent> for the parent member.

The Parent object "owns" its Child objects via the std::vector<std::shared_ptr<Child>>. This is a strong ownership relationship. If the Child also held a std::shared_ptr<Parent>, you would have a circular reference: the parent owns the child, and the child owns the parent.

By using std::weak_ptr<Parent>, the child can observe its parent without creating an ownership claim. When the Parent object is destroyed, its vector of shared_ptrs to children is cleared, destroying the children (assuming no other shared_ptrs exist). The weak_ptr in each child will then expire, correctly reflecting that the parent is gone.


Conclusion

You have now learned about one of the most common pitfalls of std::shared_ptr and its elegant solution, std::weak_ptr. This is a critical tool for designing complex object relationships in C++ without leaking memory.

Key Takeaways:

  • Circular references between std::shared_ptr instances create memory leaks because strong reference counts never reach zero.
  • std::weak_ptr is a non-owning, observing smart pointer that does not affect an object's lifetime.
  • The shared_ptr control block contains both a strong and a weak reference count to manage the lifetimes of the object and the control block itself.
  • The only safe way to access the object managed by a weak_ptr is via the atomic lock() method, which temporarily creates a shared_ptr.
  • To break a reference cycle, identify one link and change it from a shared_ptr to a weak_ptr.

What's Next?

In this module, we've explored std::unique_ptr for exclusive ownership, std::shared_ptr for shared ownership, and now std::weak_ptr for non-owning observation. In the next and final lesson of this module, we will put it all together. We will take a piece of C-style code that uses raw pointers and manual memory management and refactor it into modern, safe, and expressive C++ using the smart pointers you've just mastered.