C++ / MEMORY OWNERSHIP AND SMART POINTERS
weak_ptr and breaking reference cycles
Diagnose a shared_ptr cycle that never frees, break it by making the back-link a weak_ptr, and reach the target safely through lock().
What you will learn
- Spot a shared_ptr cycle by tracing which use_count never reaches zero
- Turn the back-pointer of a two-way link into a weak_ptr so it stops owning
- Call lock() to get a temporary shared_ptr before dereferencing a weak_ptr
- Prune expired weak_ptr entries from observer lists instead of leaking slots
Understanding weak_ptr and breaking reference cycles
Every shared_ptr shares a control block holding two counters. The strong count (what use_count reports) decides when the object's destructor runs; the weak count keeps the control block itself alive so that weak_ptrs can still answer the question "is the object gone?". A weak_ptr increments only the weak count, so it never votes on the object's lifetime, which is exactly what lets it point back into a structure that owns it. The flip side is that a surviving weak_ptr pins the control block, and with make_shared the object's storage sits in that same allocation, so the bytes are held even after the destructor has run.
Reference counting is a purely local rule: each shared_ptr knows only its own counter, and nothing in the library ever computes whether a group of objects is still reachable from your code. If A holds a shared_ptr to B and B holds one back to A, then after your last external handle disappears each object still has a count of 1, supplied by the other. The only thing that would decrement A's count is B's destructor, and B's destructor is exactly what will never run. The pair is unreachable but immortal, and any non-memory resource it holds, such as a socket or a log file, stays held until the process exits.
The fix is a rule about shape: the graph of strong edges must have no cycles, so in any two-way link one direction must be declared non-owning. Pick the direction that does not express ownership, usually a parent pointer, a prev link, or an observer registry, and give it type weak_ptr. Access then has to go through lock(), which atomically checks the strong count and, if it is nonzero, returns a real shared_ptr that pins the object for as long as you keep it. That extra step is the safety: between "it is alive" and "I used it" the last owner can vanish in another thread or inside a callback, and lock() closes that window by taking a genuine reference rather than a promise.
<iostream>
<memory>
<string>
<utility>
struct Child;
struct Parent {
std::string name;
std::shared_ptr<Child> child; // Parent owns Child
explicit Parent(std::string n) : name(std::move(n)) {}
~Parent() { std::cout << "~Parent " << name << "\n"; }
};
struct Child {
std::string name;
std::weak_ptr<Parent> parent; // back link, owns nothing
explicit Child(std::string n) : name(std::move(n)) {}
~Child() { std::cout << "~Child " << name << "\n"; }
void greet() const {
if (std::shared_ptr<Parent> p = parent.lock())
std::cout << name << " sees parent " << p->name
<< " (use_count now " << p.use_count() << ")\n";
else
std::cout << name << " has no parent left\n";
}
};
int main() {
std::shared_ptr<Child> kid = std::make_shared<Child>("kid");
{
std::shared_ptr<Parent> mom = std::make_shared<Parent>("mom");
mom->child = kid; // strong edge down
kid->parent = mom; // weak edge up
std::cout << "mom use_count " << mom.use_count() << "\n";
std::cout << "kid use_count " << kid.use_count() << "\n";
kid->greet();
} // mom's only strong owner dies here
std::cout << "after scope, expired = " << std::boolalpha
<< kid->parent.expired() << "\n";
kid->greet();
std::cout << "dropping kid\n";
}
Reference counting cannot free a group of objects that own each other, so every two-way link needs exactly one non-owning weak_ptr side.
Worked examples
The same pair, leaking and then not
Runs a mutually owning pair and a weak-back-link pair through identical scopes so the missing destructors are visible.
<iostream>
<memory>
struct Node {
int id;
std::shared_ptr<Node> next; // both directions own
explicit Node(int i) : id(i) {}
~Node() { std::cout << "~Node " << id << "\n"; }
};
struct WNode {
int id;
std::shared_ptr<WNode> next;
std::weak_ptr<WNode> prev; // back link does not own
explicit WNode(int i) : id(i) {}
~WNode() { std::cout << "~WNode " << id << "\n"; }
};
int main() {
{
std::shared_ptr<Node> a = std::make_shared<Node>(1);
std::shared_ptr<Node> b = std::make_shared<Node>(2);
a->next = b;
b->next = a;
std::cout << "leaky scope ending, counts " << a.use_count()
<< " and " << b.use_count() << "\n";
}
std::cout << "-- no destructors ran above --\n";
{
std::shared_ptr<WNode> a = std::make_shared<WNode>(1);
std::shared_ptr<WNode> b = std::make_shared<WNode>(2);
a->next = b;
b->prev = a;
std::cout << "weak scope ending, counts " << a.use_count()
<< " and " << b.use_count() << "\n";
}
std::cout << "-- both freed --\n";
}
Example explained
Line 1b->next = a closes the loop, so Node 1 is owned by the local a and by Node 2 and its count is 2.
Line 2Leaving the first scope drops both counts from 2 to 1, so neither destructor fires and both Nodes leak.
Line 3b->prev = a touches only the weak count, which is why a.use_count() reads 1 in the second scope.
Line 4Destroying a takes WNode 1 to zero; its next member then releases WNode 2, so the frees cascade in that order.
An observer list that does not keep listeners alive
Stores weak_ptr in a container so registration never extends a listener's lifetime, and prunes the dead slots afterwards.
<algorithm>
<iostream>
<memory>
<string>
<vector>
struct Listener {
std::string name;
explicit Listener(std::string n) : name(std::move(n)) {}
~Listener() { std::cout << "gone: " << name << "\n"; }
};
int main() {
std::vector<std::weak_ptr<Listener>> subs;
std::shared_ptr<Listener> a = std::make_shared<Listener>("a");
{
std::shared_ptr<Listener> b = std::make_shared<Listener>("b");
subs.push_back(a);
subs.push_back(b);
std::cout << "registered " << subs.size() << "\n";
}
int live = 0;
for (const std::weak_ptr<Listener>& w : subs) {
if (std::shared_ptr<Listener> s = w.lock()) {
std::cout << "notify " << s->name << "\n";
++live;
}
}
std::cout << "live " << live << " of " << subs.size() << "\n";
subs.erase(std::remove_if(subs.begin(), subs.end(),
[](const std::weak_ptr<Listener>& w) { return w.expired(); }),
subs.end());
std::cout << "after prune " << subs.size() << "\n";
}
Example explained
Line 1subs.push_back(b) copies into a weak_ptr, so b still dies at the end of its scope and prints before any notification.
Line 2w.lock() returns an empty shared_ptr for the dead slot, so the if skips it instead of dereferencing freed memory.
Line 3expired() is the right test for pruning because we only decide to erase the slot and never touch the object.
Line 4The surviving weak_ptr to a still owns nothing, so gone: a prints when main's locals are destroyed.
Important notes
weak_ptr has no operator* and no operator->; lock() is the only way in, and it is deliberately an atomic check-and-upgrade, so testing expired() first and dereferencing afterwards buys you nothing in threaded code.
If an object needs to hand out weak references to itself, derive from std::enable_shared_from_this and use weak_from_this(); building a shared_ptr from a raw this creates a second control block and a double delete.
Common mistakes
Storing the result of lock() in a member or a long-lived container: that copy is a full shared_ptr, so the owning edge is back and the pair leaks exactly as before.
Writing w.lock()->field without checking the result: when the target is gone lock() returns an empty shared_ptr and the arrow dereferences null, which is undefined behaviour, usually a crash.
Weakening the wrong direction, so the only remaining handle to an object is a weak_ptr: the object is freed as soon as the enclosing scope ends and every later lock() quietly returns empty.
Try it yourself
Change, predict, then run
Write structs Room and Door that each hold a shared_ptr to the other, link one pair inside a block, and confirm neither destructor prints. Then change Door's pointer to weak_ptr, print both use_counts before the block ends, and check that both destructors now run.
Open the C++ workspaceCheck your understanding
A Parent owns a shared_ptr<Child>, and Child holds a weak_ptr<Parent>. A method does auto p = parent.lock() and assigns p to a new Child member of type shared_ptr<Parent>. What happens?
- Nothing changes, because lock() hands back a non-owning pointer just like the weak_ptr it came from.
- The Parent is destroyed twice, once through the weak_ptr and once through the cached member.
- The owning cycle is restored: Parent's strong count never reaches zero and neither object is freed.
- It fails to compile, because a shared_ptr cannot be constructed from a weak_ptr.
Show answer
lock() returns a genuine shared_ptr that has already incremented the strong count. As a local it decrements again when it goes out of scope, which is why the usual pattern is harmless; stored in a member it lives as long as the Child, so Parent keeps Child alive and Child keeps Parent alive. Option 0 is tempting because lock() normally looks free, but its safety comes from the short lifetime of the temporary, not from the pointer type. A weak_ptr never destroys anything, so there is no double destruction.