Skip to content

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(args...) 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.

shared_ptr_basics.cpp
#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 deleted

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

weak_ptr_example.cpp
#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
}
Pitfall

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 destroyed

The 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 it

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

Best Practice

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.

control_block.cpp
#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
}
Key Takeaways
  • shared_ptr uses reference counting — object dies when last owner is gone
  • make_shared allocates object + control block in one allocation (prefer it always)
  • weak_ptr observes without owning — use lock() to safely access, breaks circular references
  • shared_ptr is 2 pointers wide and has atomic refcount overhead — not free
  • Default to unique_ptr; only use shared_ptr when ownership is genuinely shared
  • Never construct two shared_ptr from the same raw pointer — use enable_shared_from_this for 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`?