shared_ptr, weak_ptr & Ownership Graphs
Understand reference-counted shared ownership with shared_ptr, break cycles with weak_ptr, learn about control blocks and aliasing, and know when shared_ptr is the wrong choice.
shared_ptr: Shared Ownership
std::shared_ptr enables shared ownership — multiple shared_ptr instances can own the same object. The object is destroyed when the last owning shared_ptr is destroyed or reset. Rather like the last person to leave the office switching off the lights.
Internally, shared_ptr maintains a control block containing:
- A strong reference count (number of shared_ptr owners)
- A weak reference count (number of weak_ptr observers)
- The deleter and allocator
shared_ptr is larger than a raw pointer (typically 2 pointers: one to the object, one to the control block) and reference counting has atomic overhead on every copy/destruction. Use it only when ownership is genuinely shared.
make_shared and Reference Counting
std::make_shared allocates the object AND control block in a single allocation (instead of two). This is more cache-friendly and avoids the exception-safety issue of raw new.
#include <iostream>
#include <memory>
#include <string>
struct Logger {
std::string name;
Logger(std::string n) : name(std::move(n)) {
std::cout << name << " created\n";
}
~Logger() { std::cout << name << " destroyed\n"; }
void log(const std::string& msg) {
std::cout << "[" << name << "] " << msg << '\n';
}
};
int main() {
auto logger = std::make_shared<Logger>("AppLogger");
std::cout << "use_count: " << logger.use_count() << '\n'; // 1
{
auto copy = logger; // shared ownership
std::cout << "use_count: " << logger.use_count() << '\n'; // 2
copy->log("inside scope");
} // copy destroyed, count drops to 1
std::cout << "use_count: " << logger.use_count() << '\n'; // 1
logger->log("still alive");
} // logger destroyed — count drops to 0, Logger deletedweak_ptr: Breaking Cycles
std::weak_ptr observes a shared_ptr-managed object without extending its lifetime. To access the object, call lock(), which returns a shared_ptr — or nullptr if the object was already destroyed. weak_ptr is essential for breaking circular references that would otherwise leak.
#include <iostream>
#include <memory>
#include <string>
struct Employee;
struct Team {
std::string name;
std::vector<std::shared_ptr<Employee>> members;
Team(std::string n) : name(std::move(n)) {}
~Team() { std::cout << "Team " << name << " destroyed\n"; }
};
struct Employee {
std::string name;
std::weak_ptr<Team> team; // weak — does NOT own the team
Employee(std::string n) : name(std::move(n)) {}
~Employee() { std::cout << "Employee " << name << " destroyed\n"; }
void print_team() {
if (auto t = team.lock()) { // try to get shared_ptr
std::cout << name << " belongs to " << t->name << '\n';
} else {
std::cout << name << " has no team\n";
}
}
};
int main() {
auto team = std::make_shared<Team>("Engineering");
auto alice = std::make_shared<Employee>("Alice");
auto bob = std::make_shared<Employee>("Bob");
team->members.push_back(alice);
team->members.push_back(bob);
alice->team = team; // weak_ptr — no circular ownership
bob->team = team;
alice->print_team(); // "Alice belongs to Engineering"
team.reset(); // destroy team
alice->print_team(); // "Alice has no team" — weak_ptr expired
}If two objects hold shared_ptr to each other, neither can ever reach reference count zero — they keep each other alive forever. This is a memory leak, even though you are using smart pointers. Two people each insisting the other goes through the door first, in other words.
struct Node {
std::shared_ptr<Node> next; // if next points back, LEAK!
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // circular — neither a nor b will ever be destroyedThe fix: make one direction a weak_ptr. In tree structures, children hold weak_ptr to parents (or raw observer pointers). In doubly-linked lists, use weak_ptr for the prev pointer.
The Aliasing Constructor & enable_shared_from_this
The aliasing constructor creates a shared_ptr that shares ownership with an existing shared_ptr but points to a different object (typically a member). The aliased object stays alive as long as any aliased shared_ptr exists:
struct Pair { int first; int second; };
auto p = std::make_shared<Pair>(Pair{1, 2});
std::shared_ptr<int> sp_first(p, &p->first); // shares p's control block
// p can go out of scope — Pair stays alive because sp_first still references itenable_shared_from_this is a CRTP base class that allows an object managed by shared_ptr to obtain a shared_ptr to itself via shared_from_this(). Without it, constructing a new shared_ptr from this creates a second control block, leading to double-free.
shared_ptr is overused. Reach for it only when ownership is genuinely shared across independent lifetimes. Do NOT use shared_ptr as a lazy substitute for thinking about ownership.
Avoid shared_ptr when:
- A single owner exists — use unique_ptr instead (zero overhead).
- The object lives on the stack — just use the object directly.
- You just need to pass a reference — pass T& or T*.
- You are using it "because it's safe" — shared_ptr has runtime cost (atomic refcount, extra allocation) and hides ownership semantics.
- In performance-critical paths — atomic refcount operations are surprisingly expensive under contention.
Legitimate shared ownership scenarios: caches (multiple lookups return same object), pub/sub systems, shared configuration objects, and graph data structures with genuinely shared nodes. Sharing is caring, but only when it is actually necessary.
Control Block Internals
Understanding the control block helps explain shared_ptr costs. make_shared combines the object and control block into one allocation. Constructing from raw new requires two separate allocations.
#include <memory>
#include <iostream>
struct Widget {
int data[4];
};
int main() {
// Two allocations: one for Widget, one for control block
std::shared_ptr<Widget> p1(new Widget{});
// Single allocation: Widget + control block together
auto p2 = std::make_shared<Widget>();
// Sizes (typical 64-bit platform)
std::cout << "sizeof(Widget*): " << sizeof(Widget*) << '\n'; // 8
std::cout << "sizeof(unique_ptr<Widget>): " << sizeof(std::unique_ptr<Widget>) << '\n'; // 8
std::cout << "sizeof(shared_ptr<Widget>): " << sizeof(std::shared_ptr<Widget>) << '\n'; // 16
// shared_ptr is 2 pointers: one to object, one to control block
// Control block contains: strong count, weak count, deleter, allocator
std::cout << "p2 use_count: " << p2.use_count() << '\n'; // 1
std::weak_ptr<Widget> w = p2;
std::cout << "p2 use_count: " << p2.use_count() << '\n'; // still 1
// weak_ptr does NOT increase strong count
}shared_ptruses reference counting — object dies when last owner is gonemake_sharedallocates object + control block in one allocation (prefer it always)weak_ptrobserves without owning — uselock()to safely access, breaks circular referencesshared_ptris 2 pointers wide and has atomic refcount overhead — not free- Default to
unique_ptr; only useshared_ptrwhen ownership is genuinely shared - Never construct two
shared_ptrfrom the same raw pointer — useenable_shared_from_thisfor self-references
Quiz — Test Your Knowledge
(15 XP)1. What happens when two objects hold `shared_ptr` to each other?
2. Why is `std::make_shared` preferred over `shared_ptr<T>(new T(...))`?
3. How do you safely access an object through a `weak_ptr`?