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.

Key Concept
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.

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

Key Concept
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.

Best Practice
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.

Best Practice
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_ptr cycle 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_ptr handles 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 from use_count(), so replacing one link of a loop with it lets the whole loop unwind.
  • A std::weak_ptr has no operator->. Call lock() to obtain a std::shared_ptr, use that, and let it go out of scope promptly.
  • expired() answers whether the object is still alive; lock() on an expired std::weak_ptr returns a std::shared_ptr holding nullptr rather than failing.
  • A raw pointer cannot answer either question, which is the practical advantage of an observer that shares the control block.