Skip to content
C++
Library
since C++11
Intermediate

weak_ptr Patterns

C++ weak_ptr usage patterns — breaking cycles, observers, caches, enable_shared_from_this, and thread-safe access via lock().

std::weak_ptr<T>since C++11

A non-owning smart pointer that observes a shared_ptr-managed object without participating in its reference count, providing safe access via lock() that returns either a valid shared_ptr or nullptr if the object has already been destroyed.

Overview

weak_ptr addresses a fundamental gap in shared_ptr's ownership model: sometimes you need to reference an object without keeping it alive. Every shared_ptr-managed object has a control block containing two counters — a strong count (number of shared_ptrs) and a weak count (number of weak_ptrs plus one for the strong count being nonzero). When the strong count reaches zero, the managed object is destroyed. When the weak count also reaches zero, the control block itself is freed. A weak_ptr holds a pointer to the control block only, never to the object directly.

This two-phase destruction is what makes weak_ptr safe: it can always ask the control block whether the object is still alive, even after the object has been freed.

Three canonical use cases:

  • Breaking ownership cycles — parent/child graphs where back-references from child to parent must not extend parent lifetime.
  • Observer lists — event subscribers that may be destroyed independently of the subject.
  • Soft caches — entries that survive only as long as external owners hold them alive.

Syntax

cpp
#include <memory>

// Construction — only from shared_ptr or another weak_ptr
std::shared_ptr<T> sp = std::make_shared<T>(...);
std::weak_ptr<T> wp = sp;          // does not increment use_count()
std::weak_ptr<T> wp2 = wp;         // copy from another weak_ptr

// Core interface
std::shared_ptr<T> locked = wp.lock();   // returns shared_ptr or nullptr (noexcept)
bool dead = wp.expired();                // true if strong count == 0 (noexcept)
long n = wp.use_count();                 // strong count, 0 if expired (noexcept)
wp.reset();                              // releases the weak reference

// owner_before() — consistent ordering for use in std::set/map (C++11)
bool before = wp.owner_before(other_wp);

Examples

Breaking Ownership Cycles

The classic leak: two classes hold shared_ptr to each other — the strong counts never reach zero.

cpp
// BROKEN: reference cycle, both objects leak
struct NodeBad {
    std::shared_ptr<NodeBad> next;
    std::shared_ptr<NodeBad> prev;   // cycle!
};

// FIXED: back-references use weak_ptr
struct Node {
    std::string value;
    std::shared_ptr<Node> next;      // owns forward
    std::weak_ptr<Node>   prev;      // observes backward — no ownership

    explicit Node(std::string v) : value{std::move(v)} {}
};

auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");

a->next = b;
b->prev = a;   // weak — a's lifetime is not extended by b

// When a goes out of scope: a's strong count → 0 → destroyed.
// b->prev is now expired. b is then also freed.
// No leak.

Observer Pattern with Automatic Cleanup

Observers hold a shared_ptr to their callback state; the subject holds weak_ptr. When an observer is destroyed, the subject silently skips it on the next notification pass.

cpp
class EventSource {
    using Callback = std::function<void(int)>;
    std::vector<std::weak_ptr<Callback>> subscribers_;

public:
    // Caller owns the returned token — destroying it unsubscribes.
    [[nodiscard]] std::shared_ptr<Callback>
    subscribe(Callback cb) {
        auto token = std::make_shared<Callback>(std::move(cb));
        subscribers_.push_back(token);
        return token;  // caller must keep this alive
    }

    void emit(int value) {
        // Partition: notify live, discard dead — single pass
        std::erase_if(subscribers_, [&](const std::weak_ptr<Callback>& wp) {
            if (auto cb = wp.lock()) {
                (*cb)(value);
                return false;   // keep
            }
            return true;        // expired, remove
        });
    }
};

EventSource src;

auto tok = src.subscribe([](int v) { std::println("got {}", v); });
src.emit(1);    // prints "got 1"

tok.reset();    // unsubscribe by destroying the token
src.emit(2);    // no output; expired entry cleaned up

Weak-Reference Cache

The cache holds entries weakly: if all external shared_ptrs to an entry are released, the entry can be destroyed and will be reloaded on next access. No explicit eviction policy needed.

cpp
class ShaderCache {
    std::unordered_map<std::string, std::weak_ptr<Shader>> cache_;

public:
    std::shared_ptr<Shader> acquire(const std::string& name) {
        auto& slot = cache_[name];
        if (auto existing = slot.lock())
            return existing;               // still live — return it

        auto shader = compile_shader(name);  // expensive
        slot = shader;                       // store weak ref
        return shader;
    }

    // Optional: sweep dead entries to prevent the map growing unbounded
    void purge_expired() {
        std::erase_if(cache_, [](const auto& kv) {
            return kv.second.expired();
        });
    }
};

The map slot holds a weak_ptr — it does not prevent deallocation. The caller holds the shared_ptr that determines lifetime.

enable_shared_from_this and weak_from_this

When a class needs to produce a shared_ptr to itself — typically to hand to async callbacks — it inherits from std::enable_shared_from_this<T> (C++11). The base class stores an internal weak_ptr that is populated by the first shared_ptr constructor.

cpp
class Connection : public std::enable_shared_from_this<Connection> {
    asio::tcp::socket socket_;
    std::string id_;

public:
    Connection(asio::tcp::socket s, std::string id)
        : socket_{std::move(s)}, id_{std::move(id)} {}

    void start() {
        // shared_from_this() returns a shared_ptr using the existing control block.
        // Constructing shared_ptr<Connection>(this) here would create a SECOND
        // independent control block — double-free on destruction.
        auto self = shared_from_this();

        async_read(socket_, buffer_, [self](std::error_code ec, std::size_t n) {
            // 'self' keeps the Connection alive for the duration of the callback
            // even if the last external owner has been released.
            if (!ec) self->handle_data(n);
        });
    }

    // C++17: weak_from_this() — for subscriptions that must not extend lifetime
    void register_with_monitor(Monitor& mon) {
        auto weak_self = weak_from_this();   // C++17
        mon.on_tick([weak_self] {
            if (auto self = weak_self.lock())
                self->report_stats();
            // else: Connection gone, silently skip
        });
    }
};

// Object MUST be managed by shared_ptr; shared_from_this() in a constructor
// throws std::bad_weak_ptr because no shared_ptr exists yet.
auto conn = std::make_shared<Connection>(std::move(sock), "conn-42");
conn->start();

Best Practices

Always use lock() for access, never dereference directly. weak_ptr has no operator* or operator->; it forces the lock() ceremony, which is the point.

Prefer lock() over expired() + lock(). The state can change between two calls. lock() atomically checks and increments the strong count — it is the correct single operation.

Return the lock() result to a named variable. The locked shared_ptr must remain alive for the duration of use; a temporary wp.lock()->method() is correct only if the method completes before the temporary is destroyed (end of full expression), but storing it explicitly is safer and clearer.

Purge observer lists periodically. std::erase_if on expired entries in the emit loop (as above) prevents unbounded growth without a separate cleanup thread.

Use weak_from_this() (C++17) for non-owning self-references in callbacks. Capturing shared_from_this() in an event subscription extends the object's lifetime indefinitely — typically a bug.


Common Pitfalls

Calling shared_from_this() in a constructor. The control block's internal weak_ptr is populated only after the shared_ptr constructor completes. Calling it in the derived constructor throws std::bad_weak_ptr. Defer async operations to a factory-style start() method called after construction.

Constructing shared_ptr<T>(this) inside a class that inherits enable_shared_from_this. This creates an independent control block. The object ends up with two separate reference counts; whichever hits zero first destroys the object while the other still thinks it is alive — undefined behaviour.

Using use_count() == 0 as an alive check. use_count() is advisory and unsynchronized with destruction. Two threads can observe use_count() > 0 and race to destroy the object. Use lock() — it is the only atomic check-and-promote.

Storing a weak_ptr to a stack object. weak_ptr requires a shared_ptr and its control block. Wrapping a stack variable in shared_ptr with a no-op deleter to get a weak_ptr is possible but dangerous and almost always wrong. If you need an observer of a stack object, raw pointers or references are appropriate (with clear lifetime documentation).

Forgetting that weak_ptr does not call the deleter when it expires. The deleter runs when the strong count reaches zero — weak_ptr has no role in that. If you register cleanup with a shared_ptr deleter expecting the deleter to signal your weak_ptr observers, that is fine, but the ordering guarantee is: object destroyed first, then control block freed when the last weak_ptr releases.


Thread-Safe Observer Notification

The emit() loop shown earlier calls std::erase_if while iterating — safe for single-threaded use, but undefined behaviour if subscribe() or emit() are called concurrently from different threads. Two patterns address this without sacrificing the automatic-cleanup property.

Pattern 1: snapshot before notify. Copy the subscriber list under a lock, release the lock, then iterate and notify from the local copy. This prevents holding the lock during callbacks, which eliminates a whole class of deadlocks where a callback tries to re-subscribe.

cpp
// C++17
#include <shared_mutex>
#include <mutex>
#include <vector>
#include <memory>
#include <functional>
#include <algorithm>

class ThreadSafeEventSource {
    using Callback = std::function<void(int)>;
    std::vector<std::weak_ptr<Callback>> subscribers_;
    mutable std::mutex mu_;

public:
    [[nodiscard]] std::shared_ptr<Callback>
    subscribe(Callback cb) {
        auto token = std::make_shared<Callback>(std::move(cb));
        std::lock_guard lock{mu_};
        subscribers_.push_back(token);
        return token;
    }

    void emit(int value) {
        // Step 1: snapshot under lock
        std::vector<std::weak_ptr<Callback>> snapshot;
        {
            std::lock_guard lock{mu_};
            snapshot = subscribers_;
        }

        // Step 2: notify from local copy — lock not held during callbacks
        for (auto& wp : snapshot) {
            if (auto cb = wp.lock())
                (*cb)(value);
        }

        // Step 3: purge dead entries under write lock
        {
            std::lock_guard lock{mu_};
            std::erase_if(subscribers_, [](const std::weak_ptr<Callback>& wp) {
                return wp.expired();
            });
        }
    }
};

Pattern 2: std::shared_mutex for concurrent notifications. Multiple threads can call emit() simultaneously (read lock), while subscribe() takes an exclusive write lock. The snapshot step is still recommended to avoid lock-during-callback, but concurrent emit() calls no longer block each other.

cpp
// C++17
class SharedMutexEventSource {
    using Callback = std::function<void(int)>;
    std::vector<std::weak_ptr<Callback>> subscribers_;
    mutable std::shared_mutex mu_;

public:
    [[nodiscard]] std::shared_ptr<Callback>
    subscribe(Callback cb) {
        auto token = std::make_shared<Callback>(std::move(cb));
        std::unique_lock lock{mu_};   // exclusive — modifies subscribers_
        subscribers_.push_back(token);
        return token;
    }

    void emit(int value) {
        std::vector<std::weak_ptr<Callback>> snapshot;
        {
            std::shared_lock lock{mu_};   // shared — concurrent emit() calls OK
            snapshot = subscribers_;
        }
        for (auto& wp : snapshot) {
            if (auto cb = wp.lock())
                (*cb)(value);
        }
        // Purge dead entries needs exclusive lock
        {
            std::unique_lock lock{mu_};
            std::erase_if(subscribers_, [](const std::weak_ptr<Callback>& wp) {
                return wp.expired();
            });
        }
    }
};

Always purge expired entries under a write lock. std::erase_if modifies the container; running it concurrently with reads or other modifications is a data race. Batch the purge after each emit() or on a periodic schedule.


weak_ptr as a Map/Set Key with owner_before

std::weak_ptr is not equality-comparable by default, but it provides owner_before() — a strict weak ordering based on the address of the shared control block. This makes it usable as a key in ordered associative containers via std::owner_less.

cpp
// C++11
#include <map>
#include <memory>
#include <string>

struct Widget { std::string name; };

// std::owner_less<std::weak_ptr<Widget>> provides operator() using owner_before()
std::map<std::weak_ptr<Widget>,
         std::string,
         std::owner_less<std::weak_ptr<Widget>>> registry;

auto w1 = std::make_shared<Widget>("button");
auto w2 = std::make_shared<Widget>("label");

registry[w1] = "primary";
registry[w2] = "secondary";

// Look up by shared_ptr — owner_less supports mixed weak/shared comparisons in C++17
// C++11: must convert to weak_ptr first
std::weak_ptr<Widget> key = w1;
auto it = registry.find(key);
if (it != registry.end())
    std::println("found: {}", it->second);  // "primary"

C++17 introduced std::owner_less<void>, a transparent comparator that handles mixed weak_ptr/shared_ptr comparisons directly — no manual conversion needed.

cpp
// C++17: owner_less<void> allows find() with a shared_ptr key directly
std::map<std::weak_ptr<Widget>,
         std::string,
         std::owner_less<void>> registry17;

registry17[w1] = "primary";

// find() with shared_ptr key — no weak_ptr conversion required
auto it = registry17.find(w1);

Expired entries accumulate as keys because the control block (and thus the key's ordering) remains valid until the last weak_ptr is released. Purge them explicitly when needed.

cpp
// C++20
void purge_registry(auto& registry) {
    std::erase_if(registry, [](const auto& kv) {
        return kv.first.expired();
    });
}

owner_before compares control blocks, not pointed-to objects. Two separate make_shared<T>() calls with equal values produce different keys. Aliased shared_ptrs that share a control block compare equal even if they point to different sub-objects.


Performance: weak_ptr vs Raw Pointer Overhead

weak_ptr::lock() is not free. It performs an atomic compare-and-exchange on the strong reference count — necessary to guarantee that the object is not destroyed between the "is alive" check and the "increment count" step.

Access methodSafety guaranteeApproximate cost per access
weak_ptr::lock()Safe even if concurrently destroyed~5–10 ns (atomic CAS)
Raw pointerUnsafe — UB if object is gone<1 ns
Raw referenceSafe by contract (outlives caller)<1 ns

On a modern x86-64 core, 10 million lock() calls on a live weak_ptr take roughly 50–100 ms. This matters in tight inner loops (e.g., particle systems notifying observers per frame at 60 Hz). For most code it is irrelevant.

Use weak_ptr when lifetime is uncertain; use a raw pointer or reference when lifetime is structurally guaranteed. If an object is documented to outlive all its observers by construction — for example, a subsystem that owns all workers and joins them in its destructor — a raw pointer avoids the atomic overhead with no safety cost.

cpp
// C++11
// Raw pointer appropriate: Manager owns all Workers and destroys them first.
class Manager {
    std::vector<std::unique_ptr<Worker>> workers_;
public:
    ~Manager() { workers_.clear(); }  // workers gone before Manager
};

class Worker {
    Manager* owner_;   // raw — lifetime guaranteed by construction
public:
    explicit Worker(Manager* m) : owner_{m} {}
};

Control block memory persists for the lifetime of the last weak_ptr. A 16-byte control block (strong count + weak count) survives object destruction as long as any weak_ptr holds a reference to it. In a large registry of weak_ptrs to short-lived objects, control-block heap pressure can be non-trivial — use make_shared (which allocates the object and control block together) to improve cache locality, and purge expired entries to allow control blocks to be freed.


weak_ptr<T[]> Array Support (C++17)

C++17 extended both shared_ptr<T[]> and weak_ptr<T[]> to support array types with correct element-wise destruction semantics. Before C++17, array types required manual deleters or a wrapper container.

cpp
// C++17
#include <memory>

// Allocate a managed array of 10 ints
auto sp = std::make_shared<int[]>(10);
for (int i = 0; i < 10; ++i) sp[i] = i * i;

// Observe via weak_ptr — no ownership
std::weak_ptr<int[]> wp = sp;

if (auto locked = wp.lock()) {
    // locked is shared_ptr<int[]>; operator[] is available
    for (int i = 0; i < 10; ++i)
        std::println("{}", locked[i]);   // 0 1 4 9 16 ...
}

shared_ptr<T[]> destroys elements with delete[], so the array is correctly cleaned up. The weak_ptr<T[]> follows the same two-phase destruction as for single objects.

The key difference from shared_ptr<std::vector<int>> is layout: shared_ptr<int[]> (with make_shared) stores the array and control block in a single allocation — contiguous and cache-friendly. A vector adds an extra indirection and a separate heap allocation for its buffer.

cpp
// C++14 and earlier: wrap in vector to get weak observation
// C++11
auto sp_vec = std::make_shared<std::vector<int>>(10, 0);
std::weak_ptr<std::vector<int>> wp_vec = sp_vec;

if (auto v = wp_vec.lock()) {
    (*v)[3] = 42;
}

Prefer weak_ptr<T[]> over weak_ptr<std::vector<T>> for fixed-size arrays in C++17. The single-allocation layout from make_shared<T[]> avoids the vector's internal pointer indirection and reduces allocator pressure.


Bounded Weak Cache with Eviction Policy

The basic ShaderCache shown earlier has unbounded map growth: once an entry is inserted its key persists even after the weak_ptr expires. With many short-lived objects this accumulates dead keys indefinitely.

Expired entries accumulate as map keys; purge them explicitly. The simplest strategy is a periodic in-line purge triggered by access count — zero external threads, zero background work.

cpp
// C++17
#include <unordered_map>
#include <memory>
#include <string>

class BoundedShaderCache {
    std::unordered_map<std::string, std::weak_ptr<Shader>> cache_;
    int access_count_ = 0;
    static constexpr int kPurgeInterval = 1000;

    void purge_expired() {
        std::erase_if(cache_, [](const auto& kv) {
            return kv.second.expired();
        });
    }

public:
    std::shared_ptr<Shader> acquire(const std::string& name) {
        if (++access_count_ % kPurgeInterval == 0)
            purge_expired();

        auto& slot = cache_[name];
        if (auto existing = slot.lock())
            return existing;

        auto shader = compile_shader(name);
        slot = shader;
        return shader;
    }

    std::size_t size() const { return cache_.size(); }
};

For workloads where strong locality matters, an LRU structure pairs a std::list (insertion-order tracking) with an std::unordered_map (O(1) lookup). Each map value holds both the weak_ptr and an iterator into the list; on cache miss, the oldest entry is evicted to keep the map bounded regardless of object lifetimes.

cpp
// C++17: LRU weak cache skeleton
template<typename K, typename V>
class LruWeakCache {
    using List  = std::list<K>;
    using Entry = std::pair<std::weak_ptr<V>, typename List::iterator>;

    std::unordered_map<K, Entry> map_;
    List                         lru_;
    std::size_t                  capacity_;

public:
    explicit LruWeakCache(std::size_t cap) : capacity_{cap} {}

    std::shared_ptr<V> get(const K& key) {
        auto it = map_.find(key);
        if (it != map_.end()) {
            if (auto sp = it->second.first.lock()) {
                // Move to front (most recently used)
                lru_.splice(lru_.begin(), lru_, it->second.second);
                return sp;
            }
            // Expired — remove
            lru_.erase(it->second.second);
            map_.erase(it);
        }
        return nullptr;   // caller must load and insert()
    }

    void insert(const K& key, std::shared_ptr<V> value) {
        if (map_.size() >= capacity_) {
            // Evict LRU entry
            auto oldest = lru_.back();
            map_.erase(oldest);
            lru_.pop_back();
        }
        lru_.push_front(key);
        map_[key] = {std::weak_ptr<V>{value}, lru_.begin()};
    }
};

Choose purge strategy based on access pattern. The interval-based purge is simplest and works well for steady-state workloads. The LRU skeleton is appropriate when the working set is larger than the cache and hot entries must not be displaced by cold ones.


Async Lifetime Extension: weak_ptr in Callbacks vs shared_ptr

Capturing shared_from_this() in a long-lived callback — a timer, a network completion handler, a deferred task — keeps the object alive until that callback fires, regardless of whether the application logic intends for the object to persist. This is a common source of "phantom" objects that should have been destroyed but weren't.

Capture shared_from_this() only when the callback must extend the object's lifetime. For all other cases, capture weak_from_this() (C++17) and test the lock inside the callback body.

cpp
// C++17
class HealthReporter : public std::enable_shared_from_this<HealthReporter> {
    Timer& timer_;
    std::string node_id_;

public:
    HealthReporter(Timer& t, std::string id)
        : timer_{t}, node_id_{std::move(id)} {}

    void start() {
        // BAD: shared_ptr capture — HealthReporter cannot be destroyed
        // until the timer fires, even if all callers release their handles.
        timer_.schedule(10s, [self = shared_from_this()] {
            self->send_heartbeat();
        });

        // GOOD: weak_ptr capture — object lifetime is independent of the timer.
        timer_.schedule(10s, [wp = weak_from_this()] {    // C++17
            if (auto self = wp.lock())
                self->send_heartbeat();
            // else: reporter already gone; skip silently
        });
    }

    void send_heartbeat() {
        std::println("heartbeat from {}", node_id_);
    }
};

auto reporter = std::make_shared<HealthReporter>(timer, "node-7");
reporter->start();

reporter.reset();  // with shared_ptr capture: object still alive until timer fires
                   // with weak_ptr capture:   object is destroyed immediately here

The distinction matters for shutdown sequences: if a service holds many objects that register timers, draining all timers before destroying objects is complex. With weak_ptr captures the objects can be destroyed first — the timer callbacks simply find wp.lock() == nullptr and return.

cpp
// C++11: without weak_from_this, store the weak_ptr explicitly
class LegacyService : public std::enable_shared_from_this<LegacyService> {
public:
    void start(Timer& timer) {
        std::weak_ptr<LegacyService> wp = shared_from_this();  // C++11
        timer.schedule(5s, [wp] {
            if (auto self = wp.lock())
                self->tick();
        });
    }
    void tick() { /* ... */ }
};

Use weak_from_this() for subscriptions and shared_from_this() for mandatory continuations. A network read completion handler typically must keep the connection alive to process the data — shared_from_this() is correct there. A periodic health-check timer that merely reports state if the object is still alive — weak_from_this() is correct.


See Also