Solving Circular References with std::weak_ptr
Learn modern memory management with circular dependency issues with std::shared_ptr, and std::weak_ptr.
What Is a Circular Dependency Between std::shared_ptr Objects?
A circular dependency is a loop of ownership: object A holds a std::shared_ptr to object B, and object B holds one back to A. Each object is keeping the other alive, so neither owner count can ever reach zero, and neither object is ever deleted. The program leaks both, quietly, with no error and no crash.
std::weak_ptr, also declared in <memory>, is the tool that breaks the loop. It refers to an object managed by std::shared_ptr without owning it, so it never props the object up. Replacing one link of the loop with a std::weak_ptr is all it takes to make the whole chain collapse on schedule.
The Only Rule That Decides When an Object Dies
Everything in this lesson follows from a single mechanism you already met in the previous lesson. Alongside a shared allocation sits a control block holding the owner count, and std::shared_ptr maintains it mechanically:
| Event | Effect on the owner count |
|---|---|
std::make_shared creates the first handle |
Set to 1 |
| A handle is copied, or assigned into a data member | Raised by 1 |
| A handle is destroyed or reassigned | Lowered by 1 |
| The count reaches 0 | The object is destroyed immediately |
Nothing in that table asks who holds the handle, and that is the entire problem. A std::shared_ptr that lives inside another object counts exactly the same as one sitting in main(). If it lives inside an object that is itself only kept alive by the count it is contributing to, the arithmetic can never unwind.
The owner count is not a measure of whether anything can still reach the object. It is a count of handles that currently exist. A handle stranded inside an unreachable object still counts, which is why the leak is invisible to the counting machinery.
Watching a Cycle Form
Below, a Playlist owns the Track it features, and each Track keeps a pointer back to the playlist it came from. Both relationships are perfectly reasonable on their own. Both being std::shared_ptr is what causes the damage. The two handles in main() live in an inner block, so their destruction happens well before the program ends:
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
class Track;
class Playlist
{
public:
explicit Playlist(std::string_view label) : m_label{ label }
{
std::cout << "playlist " << m_label << " opened" << '\n';
}
~Playlist()
{
std::cout << "playlist " << m_label << " closed" << '\n';
}
void setFeatured(const std::shared_ptr<Track>& featured) { m_featured = featured; }
private:
std::string m_label{};
std::shared_ptr<Track> m_featured{};
};
class Track
{
public:
explicit Track(std::string_view title) : m_title{ title }
{
std::cout << "track " << m_title << " cached" << '\n';
}
~Track()
{
std::cout << "track " << m_title << " evicted" << '\n';
}
void setOwner(const std::shared_ptr<Playlist>& owner) { m_owner = owner; }
private:
std::string m_title{};
std::shared_ptr<Playlist> m_owner{};
};
int main()
{
{
auto mix{ std::make_shared<Playlist>("Late Shift") };
auto opener{ std::make_shared<Track>("Cold Harbour") };
mix->setFeatured(opener);
opener->setOwner(mix);
std::cout << "owners of the playlist: " << mix.use_count() << '\n';
std::cout << "owners of the track: " << opener.use_count() << '\n';
} // mix and opener are destroyed here
std::cout << "block finished" << '\n';
return 0;
}
playlist Late Shift opened
track Cold Harbour cached
owners of the playlist: 2
owners of the track: 2
block finished
Neither destructor ever runs. Two objects were built, the block they were created in has been left behind, and nothing was cleaned up. Follow the counts through the closing brace to see why:
| Step | Owners of the playlist | Owners of the track |
|---|---|---|
| Both objects created | 1 (mix) |
1 (opener) |
| The two links are set | 2 (mix, the track's member) |
2 (opener, the playlist's member) |
opener is destroyed |
2 | 1 (the playlist's member) |
mix is destroyed |
1 (the track's member) | 1 |
| Block exits | 1 | 1 |
Both counts stall at 1, and the handle keeping each count above zero lives inside the other object. Neither can be reassigned, because no code anywhere can still name either object. Their destructors are the only thing that would release the remaining handles, and their destructors are exactly what the remaining handles prevent.
A leaked cycle produces no diagnostic. The program compiles cleanly, runs to completion, and reports success. The only symptoms are destructors that never fire and memory that never comes back, which is why the destructor tracing above is worth reaching for whenever ownership looks circular.
What Counts as a Cycle
Ownership that comes back around like this is a circular reference, also called a cyclical reference or a cycle: start at any member of the loop, follow the pointers, and you arrive back where you began. Every object in the chain refers to the next, and the final link refers back to the start. The references need not be C++ references. Pointers, smart pointers, database keys, or array indices all form the same shape; a std::shared_ptr is only special in that the loop it forms has a lifetime consequence.
The example above is the two-object case. A cycle of three works identically: the playlist owns a track, the track owns a mixing note, and the note owns the playlist. Every count stalls at 1, for the same reason. Length changes nothing, because the loop has no beginning where the unwinding could start.
The shortest cycle possible has one object in it. An object holding a std::shared_ptr to itself owns itself, and that is enough:
#include <iostream>
#include <memory>
class LoopClip
{
public:
LoopClip() { std::cout << "clip armed" << '\n'; }
~LoopClip() { std::cout << "clip discarded" << '\n'; }
void repeatInto(const std::shared_ptr<LoopClip>& target) { m_repeat = target; }
private:
std::shared_ptr<LoopClip> m_repeat{};
};
int main()
{
{
auto clip{ std::make_shared<LoopClip>() };
clip->repeatInto(clip); // the clip is now one of its own owners
std::cout << "owners of the clip: " << clip.use_count() << '\n';
} // clip is destroyed here
std::cout << "block finished" << '\n';
return 0;
}
clip armed
owners of the clip: 2
block finished
One statement raised the count to 2, and only one of those two handles was ever reachable. Clearing m_repeat would release the clip, but m_repeat can only be reached through the clip, and the clip can only be reached through a handle that no longer exists.
The Handle That Does Not Count
std::weak_ptr is a handle to an object owned by std::shared_ptr that takes no part in owning it. It sees the same control block, so it can always tell you whether the object is still there, but it never appears in use_count() and never delays a destructor by a single instruction.
std::shared_ptr |
std::weak_ptr |
|
|---|---|---|
| Keeps the object alive | Yes | No |
Included in use_count() |
Yes | No |
Usable directly with -> and * |
Yes | No, convert it first |
| Can report whether the object still exists | Not needed, it owns one | Yes, through expired() |
That is the whole of the design. std::weak_ptr was added for precisely the situation this lesson opened with, and using it means deciding which single link in a loop expresses ownership and which merely expresses knowledge.
A
std::weak_ptr does keep the small control block alive, since that is where the answer to "is the object gone?" is stored. The managed object itself is destroyed the moment the last owning handle disappears, regardless of how many observers remain.
Cutting the Cycle
Only one line of the leaking program has to change. A playlist genuinely owns its featured track, so that link stays a std::shared_ptr. A track does not own the playlist it came from; it only needs to know about it, so that link becomes a std::weak_ptr:
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
class Track;
class Playlist
{
public:
explicit Playlist(std::string_view label) : m_label{ label }
{
std::cout << "playlist " << m_label << " opened" << '\n';
}
~Playlist()
{
std::cout << "playlist " << m_label << " closed" << '\n';
}
void setFeatured(const std::shared_ptr<Track>& featured) { m_featured = featured; }
private:
std::string m_label{};
std::shared_ptr<Track> m_featured{};
};
class Track
{
public:
explicit Track(std::string_view title) : m_title{ title }
{
std::cout << "track " << m_title << " cached" << '\n';
}
~Track()
{
std::cout << "track " << m_title << " evicted" << '\n';
}
void setOwner(const std::shared_ptr<Playlist>& owner) { m_owner = owner; }
private:
std::string m_title{};
std::weak_ptr<Playlist> m_owner{}; // observes the playlist without owning it
};
int main()
{
{
auto mix{ std::make_shared<Playlist>("Late Shift") };
auto opener{ std::make_shared<Track>("Cold Harbour") };
mix->setFeatured(opener);
opener->setOwner(mix);
std::cout << "owners of the playlist: " << mix.use_count() << '\n';
std::cout << "owners of the track: " << opener.use_count() << '\n';
} // mix and opener are destroyed here
std::cout << "block finished" << '\n';
return 0;
}
playlist Late Shift opened
track Cold Harbour cached
owners of the playlist: 1
owners of the track: 2
playlist Late Shift closed
track Cold Harbour evicted
block finished
The playlist now has one owner rather than two: opener->setOwner(mix) stored an observer, which the count ignores. Destruction then cascades. mix goes out of scope, the playlist count falls to 0, the playlist is destroyed, and destroying it destroys the m_featured member inside it, which drops the track from 2 owners to 1. opener had already gone by then, so the track is released as well, and both messages appear before the block is even finished.
When two objects need to refer to each other, decide which one owns the other and give only that direction a
std::shared_ptr. The back link, the parent pointer, the "who registered me" field: make those std::weak_ptr. Ownership loops are broken by design decisions, not by cleanup code.
Reaching the Object Through lock()
A std::weak_ptr cannot be dereferenced. It provides neither operator-> nor operator*, and for good reason: between one instruction and the next, nothing stops the last owner from disappearing, so there is no moment at which the class could honestly promise you a valid object.
Broken on purpose. observer is a std::weak_ptr, and -> is not part of its interface:
#include <iostream>
#include <memory>
#include <string>
class Playlist
{
public:
const std::string& label() const { return m_label; }
private:
std::string m_label{ "Late Shift" };
};
int main()
{
auto mix{ std::make_shared<Playlist>() };
std::weak_ptr<Playlist> observer{ mix };
std::cout << observer->label() << '\n';
return 0;
}
s.cpp:19:26: error: base operand of '->' has non-pointer type 'std::weak_ptr<Playlist>'
The way in is lock(), which returns a std::shared_ptr to the object. That returned handle is a genuine owner, so for as long as you hold it the object cannot be destroyed underneath you, and when it goes out of scope the count drops back. Watch the temporary ownership appear and disappear:
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
class Track;
class Playlist
{
public:
explicit Playlist(std::string_view label) : m_label{ label }
{
std::cout << "playlist " << m_label << " opened" << '\n';
}
~Playlist()
{
std::cout << "playlist " << m_label << " closed" << '\n';
}
void setFeatured(const std::shared_ptr<Track>& featured) { m_featured = featured; }
const std::string& label() const { return m_label; }
private:
std::string m_label{};
std::shared_ptr<Track> m_featured{};
};
class Track
{
public:
explicit Track(std::string_view title) : m_title{ title }
{
std::cout << "track " << m_title << " cached" << '\n';
}
~Track()
{
std::cout << "track " << m_title << " evicted" << '\n';
}
void setOwner(const std::shared_ptr<Playlist>& owner) { m_owner = owner; }
std::shared_ptr<Playlist> owningPlaylist() const { return m_owner.lock(); }
private:
std::string m_title{};
std::weak_ptr<Playlist> m_owner{};
};
int main()
{
auto mix{ std::make_shared<Playlist>("Late Shift") };
auto opener{ std::make_shared<Track>("Cold Harbour") };
mix->setFeatured(opener);
opener->setOwner(mix);
std::cout << "owners of the playlist: " << mix.use_count() << '\n';
{
auto borrowed{ opener->owningPlaylist() }; // lock() hands back a real owner
std::cout << "owners while the lock is held: " << mix.use_count() << '\n';
std::cout << "the track belongs to: " << borrowed->label() << '\n';
} // borrowed is destroyed, the extra ownership goes with it
std::cout << "owners after the lock ends: " << mix.use_count() << '\n';
return 0;
}
playlist Late Shift opened
track Cold Harbour cached
owners of the playlist: 1
owners while the lock is held: 2
the track belongs to: Late Shift
owners after the lock ends: 1
playlist Late Shift closed
track Cold Harbour evicted
The count reads 2 only inside the inner block. A short-lived owner like borrowed cannot recreate the original leak, because it is a local variable and scope exit is guaranteed to take it away again. The danger was never in holding an owning handle; it was in storing one permanently inside an object that the other end owns.
Asking Whether the Object Is Still There
Because a std::weak_ptr refuses to keep its object alive, it has to cope with the object vanishing. It can, because the control block outlives the object and records that the owner count hit zero. expired() reports the answer: true when the object is gone, false while at least one owner remains.
#include <iostream>
#include <memory>
#include <string>
#include <string_view>
class Track
{
public:
explicit Track(std::string_view title) : m_title{ title }
{
std::cout << "track " << m_title << " cached" << '\n';
}
~Track()
{
std::cout << "track " << m_title << " evicted" << '\n';
}
private:
std::string m_title{};
};
int main()
{
std::cout << std::boolalpha;
std::weak_ptr<Track> observer{};
std::cout << "expired before anything is assigned: " << observer.expired() << '\n';
{
auto only{ std::make_shared<Track>("Cold Harbour") };
observer = only;
std::cout << "expired while an owner exists: " << observer.expired() << '\n';
std::cout << "owners counted: " << only.use_count() << '\n';
} // only is destroyed, and the Track goes with it
std::cout << "expired once the owner is gone: " << observer.expired() << '\n';
auto revived{ observer.lock() };
std::cout << "lock() handed back an empty pointer: " << (revived == nullptr) << '\n';
return 0;
}
expired before anything is assigned: true
track Cold Harbour cached
expired while an owner exists: false
owners counted: 1
track Cold Harbour evicted
expired once the owner is gone: true
lock() handed back an empty pointer: true
Two results are worth pinning down. The owner count reads 1 while observer is watching, confirming that observing is not owning. And calling lock() after the object is gone is not an error and does not throw: it returns a std::shared_ptr holding nullptr, which is why a locked handle must be tested before it is dereferenced.
Compare that with a raw pointer to the same object. A raw pointer stores an address and nothing else. Once the object at that address is destroyed the pointer keeps the stale address, still compares unequal to nullptr, and gives no way at all to discover the truth. Dereferencing it is undefined behaviour, and the only defence is knowing by inspection that it cannot have happened. A std::weak_ptr replaces that reasoning with a question you can ask at run time.
Check
expired() before calling lock() on a std::weak_ptr, and treat the pointer that lock() returns as something to test rather than something to trust. Both habits cost one comparison and remove a whole class of dangling-pointer bugs.
Choosing Between the Three Handles
| Question | Reach for |
|---|---|
| Does this reference need to keep the object alive? | std::shared_ptr |
| Is this a back link, a parent pointer, or a cache entry that must not extend a lifetime? | std::weak_ptr |
| Do I need to know at run time whether the object still exists? | std::weak_ptr and expired() |
| Am I looking at an object I neither own nor outlive, inside one function? | A raw pointer or a reference is fine |
Summary
- A
std::shared_ptrcycle is a loop of ownership. Every count in the loop stalls above zero, no destructor runs, and the objects leak with no diagnostic of any kind. - A cycle is any chain where each object refers to the next and the last refers back to the first. Two objects, ten objects, or one object pointing at itself all behave the same way.
- The owner count only records how many
std::shared_ptrhandles exist. It cannot tell that a handle has become unreachable, which is what makes the leak invisible. std::weak_ptr, from<memory>, refers to a shared object without owning it. It is absent fromuse_count(), so replacing one link of a loop with it lets the whole loop unwind.- A
std::weak_ptrhas nooperator->. Calllock()to obtain astd::shared_ptr, use that, and let it go out of scope promptly. expired()answers whether the object is still alive;lock()on an expiredstd::weak_ptrreturns astd::shared_ptrholdingnullptrrather than failing.- A raw pointer cannot answer either question, which is the practical advantage of an observer that shares the control block.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Solving Circular References with std::weak_ptr - Quiz
Test your understanding of the lesson.
Practice Exercises
Fix Circular Reference Memory Leak
Fix a memory leak caused by circular references between Node objects in a doubly-linked list structure. Use std::weak_ptr to break the cycle while maintaining the ability to navigate between nodes.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!