C++ Shared and Weak Pointers: Reference Counting and Cycles
A C++ shared pointer counts its owners and deletes the object when the last one goes; a weak pointer observes without owning and breaks reference cycles.
- Course: C++ study plan
- Module: Memory, ownership and RAII
- Kind: Lesson
- Reading time: 15 min
- Runtime: Clang 18 · C++20
How does shared_ptr work in C++?
std::shared_ptr<T> shares ownership of a heap object through a reference count kept in a control block. Each copy is one more owner and increments the count; each destroyed copy decrements it; when the count reaches zero the object is deleted at once. std::make_shared allocates the object and the control block together, and use_count() reports the current number of owners.
Lesson
Sometimes an object has no single owner: a cache entry used by several requests, a node in a graph reached from two parents, a callback that must outlive the code that registered it. std::shared_ptr<T> handles that with a reference count: every copy of the pointer is one owner, the count rises and falls as copies are made and destroyed, and the object is deleted when the last owner goes. It is the smart pointer beginners reach for first and experts reach for last, because counting has a cost, back-pointers need an escape from the count, and most objects that seem shared turn out to have an owner after all. This lesson covers the machinery, the cycle that leaks, std::weak_ptr, and when shared ownership is actually the right answer.
The control block
#include <memory>
auto a = std::make_shared<std::string>("shared"); // count 1
{
auto b = a; // count 2: a copy is a new owner
std::cout << a.use_count() << '\n'; // 2
} // b destroyed: count 1
std::cout << a.use_count() << '\n'; // 1
a.reset(); // count 0: the string is deleted now
A shared_ptr is two pointers wide: one to the object, one to a control block that holds the strong count, the weak count and the deleter. std::make_shared<T>(args…) allocates the object and the control block in a single allocation, which is faster and keeps them adjacent; std::shared_ptr<T>(new T) makes two allocations and, like its unique_ptr cousin, should not appear in modern code. Copying increments the strong count, destroying a copy decrements it, and when it reaches zero the object is destroyed — at that moment, deterministically, inside whichever shared_ptr's destructor or reset() made the final decrement.
use_count() reports the strong count. In a single-threaded program it is exact, which is why the exercises print it; in a multithreaded one it is a snapshot. The increments themselves are atomic (Module 17), so copying a shared_ptr costs a synchronised read-modify-write — many times an ordinary increment — and passing one by value through ten function calls pays that ten times for no reason.
Passing a shared_ptr
The rule from lesson 4 carries over. A function that merely uses the object takes const T& or T*; only a function that will keep an owner — store it in a member, a container, a callback — takes std::shared_ptr<T> by value, and the caller pays one increment for the copy that is then kept. Passing const std::shared_ptr<T>& is a half-measure: it avoids the increment but still demands that the caller own a shared_ptr.
The cycle that leaks
struct Node {
std::shared_ptr<Node> other;
~Node() { std::cout << "destroyed\n"; }
};
int main() {
auto x = std::make_shared<Node>();
auto y = std::make_shared<Node>();
x->other = y; // y's count: 2
y->other = x; // x's count: 2
} // x and y go out of scope: both counts drop to 1. Nothing prints.
Each node is kept alive by the other. After main's two variables die, each object still has one owner — the other object — and neither count reaches zero. Reference counting cannot collect cycles; a tracing garbage collector can, and this is the one place C++'s model is weaker. Every parent-and-children, doubly linked, observer-and-subject structure built with shared_ptr in both directions has this leak.
weak_ptr: a pointer that does not own
struct Album;
struct Track {
std::string title;
std::weak_ptr<Album> album; // back-pointer: does not keep the album alive
};
struct Album {
std::string title;
std::vector<std::shared_ptr<Track>> tracks; // the album owns its tracks
};
auto album = std::make_shared<Album>();
auto t = std::make_shared<Track>();
t->album = album; // weak: album.use_count() stays 1
album->tracks.push_back(t);
if (auto a = t->album.lock()) { // lock(): a shared_ptr, or empty if the album is gone
std::cout << a->title << ' ' << a.use_count() << '\n'; // 2 while a exists
}
album.reset(); // the album and its tracks vector die; t still holds one track
std::cout << t->album.expired() << '\n'; // 1 (true)
A std::weak_ptr<T> observes an object owned by shared_ptrs without counting as an owner: it holds the control block (bumping the weak count, which keeps only the block alive) and can tell whether the object still exists. expired() answers yes or no; lock() returns a shared_ptr that owns the object for as long as you hold it, or an empty one if the object is gone — and lock() is the one to use, because between an expired() check and the access that follows the object could die. Ownership points one way (album to tracks), observation points back (track to album), and the cycle is broken.
The same shape handles caches (the cache holds weak_ptrs and hands out shared_ptrs; entries die when unused) and observer lists (the subject holds weak_ptrs to listeners that may have been destroyed — the second exercise).
enable_shared_from_this, in brief
A member function that needs a shared_ptr to its own object must not write std::shared_ptr<T>(this) — that creates a second control block for the same object, and the object is deleted twice. Deriving from std::enable_shared_from_this<T> gives the class a shared_from_this() that returns a shared_ptr sharing the existing control block. It works only on an object already owned by a shared_ptr, so never inside a constructor. Module 10 covers the inheritance it relies on.
When shared ownership is right
Rarely, and specifically:
- The object genuinely has several owners of independent lifetimes — a cached resource, a graph node with several parents, an immutable configuration snapshot handed to many threads.
- A callback or an asynchronous task must keep its target alive until it runs.
- The lifetime cannot be tied to any one scope.
"I am not sure who should own it" is not on the list; it is the smell that shared_ptr is being used to avoid a design decision. The default is a value; a unique_ptr when the object must live on the heap; a shared_ptr when one of the reasons above applies; a raw pointer or reference for every non-owning use in between. C++20 added std::shared_ptr<T[]> for shared arrays; a std::vector inside a shared_ptr is usually clearer.
Pitfalls
| Mistake | What happens |
|---|---|
std::shared_ptr<T> a(raw); std::shared_ptr<T> b(raw); | Two control blocks: double delete. |
Cycles of shared_ptr | Leak; use weak_ptr for the back edge. |
Dereferencing lock() without testing it | It may be empty; test the result. |
shared_ptr by value everywhere | Atomic churn; pass const T& to code that merely uses the object. |
std::shared_ptr<T>(this) inside a member | A second control block; use enable_shared_from_this. |
| Assuming the count makes the object thread-safe | Only the count is atomic; the object needs its own lock. |
Key takeaways
shared_ptrowns through a control block: copies increment, destruction decrements, zero deletes;make_sharedallocates both at once.use_count()is exact in one thread; copying costs an atomic increment — passconst T&to functions that merely use the object.- Two
shared_ptrs pointing at each other never reach zero: a cycle leaks. weak_ptrobserves without owning;lock()gives a temporaryshared_ptror an empty one;expired()asks whether the object is gone.- Prefer values and
unique_ptr; useshared_ptronly when several owners with independent lifetimes truly exist.
Common questions
What is the difference between shared_ptr and weak_ptr?
A shared_ptr owns the object and keeps it alive; a weak_ptr only observes an object owned by shared_ptrs and does not count as an owner. To use the object, call lock() on the weak_ptr, which returns a shared_ptr that is empty if the object has already been destroyed.
How do shared_ptr cycles cause memory leaks?
If two objects hold shared_ptrs to each other, each keeps the other's count at one or more, so neither count ever reaches zero and neither object is deleted, even after every outside pointer is gone. Reference counting cannot collect cycles; make the back-pointer a weak_ptr to break it.
Why use make_shared instead of shared_ptr(new T)?
std::make_shared<T>(args) allocates the object and its control block in a single allocation, which is faster and keeps them adjacent; std::shared_ptr<T>(new T) makes two. It also never exposes a raw pointer that could accidentally be handed to a second control block.
Is shared_ptr thread-safe?
Only its reference count: the increments and decrements are atomic. The object it points to is not protected and needs its own lock. The atomic updates also make each copy of a shared_ptr cost many times an ordinary increment, so pass const T& to code that merely uses the object.
Exercises
An album and its tracks
Build the ownership graph from the lesson and print the counts it defines. An Album owns its tracks through std::vector<std::shared_ptr<Track>>; each Track points back with a std::weak_ptr<Album>. The album's destructor prints album <title> released.
Read the album title (a whole line), then n, then n track titles (whole lines). Create the album with std::make_shared, then each track with std::make_shared, set its back-pointer and push it into the album. Then print:
album <title>: <n> tracks, owners=<album.use_count()>;- for each track, iterating by
const auto&:lock()the back-pointer and print<track> -> <album title> (track owners=<track.use_count()>, album owners=<locked.use_count()>); - if there are no tracks, print
no tracks, reset the album and stop; - otherwise copy the first track into a
std::shared_ptr<Track> firstand printfirst track owners=<first.use_count()>; - reset the album pointer in
main(the destructor line prints here); - print
first track owners=<first.use_count()>again, thenalbum expired=<first->album.expired()>withstd::boolalpha.
Every number is fixed by the rules: a weak_ptr is not an owner, a const auto& loop makes no copies, and lock() is an owner for as long as it lives.
Input: the album title line, n, then n title lines. Output: as above.
Abbey Road
2
Come Together
Something
prints
album Abbey Road: 2 tracks, owners=1
Come Together -> Abbey Road (track owners=1, album owners=2)
Something -> Abbey Road (track owners=1, album owners=2)
first track owners=2
album Abbey Road released
first track owners=1
album expired=trueObservers that may vanish
A Subject keeps a list of listeners it does not own — std::vector<std::weak_ptr<Listener>> — so that a listener that has been destroyed elsewhere simply stops receiving. Owners live in a std::map<std::string, std::shared_ptr<Listener>> in main.
Commands, one per line:
add <name>— if the name exists printexists <name>; otherwise create the listener withstd::make_shared, store it in the map, subscribe it to the subject and printadded <name> (owners=<use_count>)— which must be 1, because the subject holds only aweak_ptr;drop <name>— erase it from the map (printdropped <name>), orunknown <name>if absent;notify <word>— for each weak pointer in subscription order,lock()it; a live listener prints<name> received <word>, a dead one is counted; then remove the expired entries withstd::erase_ifand printdelivered=<d> pruned=<p>.
Input: commands. Output: one line per add/drop, and the notification lines.
add ann
add bob
notify hello
drop ann
notify again
add ann
notify third
prints
added ann (owners=1)
added bob (owners=1)
ann received hello
bob received hello
delivered=2 pruned=0
dropped ann
bob received again
delivered=1 pruned=1
added ann (owners=1)
bob received third
ann received third
delivered=2 pruned=0In this module: Memory, ownership and RAII
- The memory model — where objects live and when they die
- new and delete — dynamic memory and the ownership contract
- RAII — a resource's lifetime is an object's lifetime
- std::unique_ptr — exclusive ownership, enforced by the type
- std::shared_ptr and std::weak_ptr — counted ownership and its cycles (this lesson)
- Memory errors and the tools that find them
- Checkpoint — Memory, ownership and RAII
← std::unique_ptr — exclusive ownership, enforced by the type · Memory errors and the tools that find them →