Counted Pointer
A smart pointer idiom that tracks shared ownership via an atomic reference count, destroying the managed object when the last owner relinquishes it.
Counted Pointersince C++11A smart pointer that maintains a shared reference count across all co-owners of a heap object, deleting the object exactly once when the count reaches zero.
Overview
The counted pointer idiom solves the shared-ownership problem: when multiple parts of a program need to keep an object alive without any single one acting as the definitive owner, each holds a counted pointer. The count increments on copy and decrements on destruction or reassignment. The managed object is deleted precisely when the last counted pointer is destroyed or repointed.
C++11 standardized this idiom as std::shared_ptr<T> in <memory>. Before that, boost::shared_ptr had been the de-facto standard for over a decade. The core idea predates C++ itselfβCOM, Objective-C's ARC, and Python's garbage collector all rely on reference counting.
Two structural variants exist:
Non-intrusive (external counter) β the reference count lives in a separately allocated control block, decoupled from the managed object. The object itself needs no modification. std::shared_ptr follows this model.
Intrusive (embedded counter) β the reference count is a data member of the managed object. This eliminates the control block allocation and one pointer indirection per dereference, at the cost of requiring that the class cooperate with the pointer. boost::intrusive_ptr targets this pattern.
The Control Block
In a non-intrusive implementation, the smart pointer holds two raw pointers: one to the managed object and one to a control block β a heap-allocated structure containing the strong reference count, a weak reference count, and the deleter callable.
// Simplified internal layout of std::shared_ptr<T> // C++11
struct control_block {
std::atomic<long> strong_count;
std::atomic<long> weak_count;
// deleter + allocator follow (type-erased)
};
// Each shared_ptr<T> instance holds:
// T* stored_ptr; returned by get(), operator*, operator->
// control_block* ctrl; shared among all co-ownersstd::make_shared<T>(args...) (C++11) fuses the object and control block into a single allocation, reducing allocator overhead and improving cache locality. Constructing from a raw pointer splits them:
// Two allocations: Widget + control block (separate)
auto p1 = std::shared_ptr<Widget>(new Widget{42}); // C++11
// One allocation: Widget and control block co-located
auto p2 = std::make_shared<Widget>(42); // C++11 β prefer thisThe aliasing constructor (C++11) lets the stored pointer and the owned pointer diverge, which is essential for pointing at sub-objects while keeping the enclosing object alive:
struct Config {
std::string name;
std::vector<int> values;
};
auto cfg = std::make_shared<Config>(); // C++11
std::shared_ptr<std::vector<int>> vals(cfg, &cfg->values); // aliasing ctor
// vals.get() == &cfg->values; Config stays alive until vals is goneHand-Rolled Non-Intrusive Counted Pointer
Understanding std::shared_ptr requires internalizing the ownership transitions. The following thread-unsafe implementation exposes the mechanics directly:
#include <utility>
template<typename T>
class counted_ptr {
T* ptr_ = nullptr;
int* count_ = nullptr;
void release() noexcept {
if (count_ && --(*count_) == 0) {
delete ptr_;
delete count_;
}
ptr_ = nullptr;
count_ = nullptr;
}
public:
counted_ptr() = default;
explicit counted_ptr(T* p)
: ptr_(p), count_(p ? new int(1) : nullptr) {}
counted_ptr(const counted_ptr& o)
: ptr_(o.ptr_), count_(o.count_) {
if (count_) ++(*count_);
}
counted_ptr(counted_ptr&& o) noexcept // C++11 move
: ptr_(o.ptr_), count_(o.count_) {
o.ptr_ = nullptr;
o.count_ = nullptr;
}
counted_ptr& operator=(counted_ptr o) noexcept { // copy-and-swap; handles self
std::swap(ptr_, o.ptr_);
std::swap(count_, o.count_);
return *this;
}
~counted_ptr() { release(); }
T* get() const noexcept { return ptr_; }
T& operator*() const noexcept { return *ptr_; }
T* operator->()const noexcept { return ptr_; }
int use_count() const noexcept { return count_ ? *count_ : 0; }
explicit operator bool() const noexcept { return ptr_ != nullptr; } // C++11
};The copy-and-swap assignment correctly handles self-assignment without an explicit check. In production, the counter must be std::atomic<int> for thread safety.
Intrusive Reference Counting
When you control the managed class and can afford the coupling, embedding the counter eliminates the control block allocation entirely:
#include <atomic> // C++11
class ref_counted_base {
mutable std::atomic<int> ref_count_{0};
public:
void add_ref() const noexcept {
ref_count_.fetch_add(1, std::memory_order_relaxed);
}
bool release() const noexcept {
// acq_rel ensures the destructor sees all prior writes
return ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1;
}
int use_count() const noexcept {
return ref_count_.load(std::memory_order_relaxed);
}
protected:
~ref_counted_base() = default; // prevent raw delete bypassing the counter
};
template<typename T>
class intrusive_ptr {
T* ptr_ = nullptr;
public:
explicit intrusive_ptr(T* p = nullptr, bool add_ref = true) : ptr_(p) {
if (ptr_ && add_ref) ptr_->add_ref();
}
intrusive_ptr(const intrusive_ptr& o) : intrusive_ptr(o.ptr_) {}
intrusive_ptr(intrusive_ptr&& o) noexcept : ptr_(o.ptr_) { o.ptr_ = nullptr; }
~intrusive_ptr() {
if (ptr_ && ptr_->release()) delete ptr_;
}
intrusive_ptr& operator=(intrusive_ptr o) noexcept {
std::swap(ptr_, o.ptr_);
return *this;
}
T* get() const noexcept { return ptr_; }
T& operator*() const noexcept { return *ptr_; }
T* operator->() const noexcept { return ptr_; }
};
struct Texture : ref_counted_base {
explicit Texture(std::string_view path); // std::string_view: C++17
};
intrusive_ptr<Texture> load(std::string_view path) {
return intrusive_ptr<Texture>(new Texture(path));
}The memory_order_relaxed increment is safe because the caller already holds a reference. The acq_rel decrement synchronises the destructor with all preceding mutations through acquire semantics on the final thread.
Weak References and Cycles
Counted pointers do not handle ownership cycles: if A holds a shared_ptr to B and B holds one back to A, neither count ever reaches zero. std::weak_ptr<T> (C++11) breaks cycles by observing an object without contributing to its lifetime:
#include <memory>
struct Node {
int value;
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // C++11 β does not keep 'prev' alive
};
auto a = std::make_shared<Node>(1);
auto b = std::make_shared<Node>(2);
a->next = b;
b->prev = a; // weak β does not prevent 'a' from being destroyed
if (auto locked = b->prev.lock()) { // C++11 β returns empty if expired
std::cout << locked->value << '\n';
}Best Practices
- Prefer
std::make_sharedovershared_ptr(new T{})to halve allocator calls and to avoid potential leaks if argument evaluation throws betweennew Tand theshared_ptrconstructor. - Pass
shared_ptrbyconst&within a call stack and by value only when transferring or extending ownership β every copy of ashared_ptrtouches an atomic counter. - Use
std::weak_ptrfor observer, cache, and back-edge relationships to prevent cycles. - Protect the destructor of an intrusive base class so callers cannot
deletethe raw pointer and bypass the counter. - Prefer returning
shared_ptr<const T>from factories when callers do not need to mutate the object; it signals intent and permits safe sharing without defensive copying.
Common Pitfalls
Double-delete from raw pointer aliasing. Two independent shared_ptrs constructed from the same raw pointer create two separate control blocks, each of which will delete the object:
Widget* raw = new Widget;
std::shared_ptr<Widget> p1(raw);
std::shared_ptr<Widget> p2(raw); // undefined behavior β double deletethis escapes into an independent shared_ptr. Creating a shared_ptr to this inside a member function triggers the same aliasing bug. Inherit from std::enable_shared_from_this<T> (C++11) and call shared_from_this() instead:
struct Job : std::enable_shared_from_this<Job> { // C++11
void schedule() {
// shared_from_this() reuses the existing control block
thread_pool::post(shared_from_this());
}
};Thread safety scope. The reference count manipulations are atomic; concurrent operations on different shared_ptr instances pointing to the same object are safe. Concurrent mutation of the same shared_ptr variable β one thread resetting while another copies β is a data race. Use std::atomic<std::shared_ptr<T>> (C++20) or external synchronisation for a shared shared_ptr variable.
Control block outlives the object with make_shared. Because make_shared fuses the object storage with the control block, that storage is not reclaimed until all weak_ptrs referencing it are gone β even if all shared_ptrs were destroyed long ago. For large objects with long-lived weak observers, constructing via shared_ptr(new T) lets the object memory be freed independently of the control block, despite the extra allocation.
Thread-Safe Reference Count with std::atomic
Claim. A plain int counter is not sufficient in multithreaded code β even a single increment is a read-modify-write that can race with a concurrent decrement on another thread.
std::atomic<int> with explicit memory orders lets you pay only for the synchronisation you need. Increments can use memory_order_relaxed because the caller already holds a live reference, which prevents the count from reaching zero concurrently. The final decrement must use memory_order_acq_rel: the release half publishes all prior writes to the object; any thread that then reaches zero will observe a final count of one and apply acquire semantics, ensuring the destructor sees a fully consistent object.
#include <atomic>
#include <utility>
template<typename T>
class AtomicCountedPtr { // C++11
struct ControlBlock {
T* ptr;
std::atomic<int> count{1};
explicit ControlBlock(T* p) : ptr(p) {}
};
ControlBlock* ctrl_ = nullptr;
void acquire() noexcept {
if (ctrl_)
ctrl_->count.fetch_add(1, std::memory_order_relaxed);
}
void release() noexcept {
if (!ctrl_) return;
// acq_rel: release publishes our writes; acquire synchronises the dtor
if (ctrl_->count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
delete ctrl_->ptr;
delete ctrl_;
}
ctrl_ = nullptr;
}
public:
AtomicCountedPtr() = default;
explicit AtomicCountedPtr(T* p)
: ctrl_(p ? new ControlBlock(p) : nullptr) {}
AtomicCountedPtr(const AtomicCountedPtr& o) : ctrl_(o.ctrl_) { acquire(); }
AtomicCountedPtr(AtomicCountedPtr&& o) noexcept
: ctrl_(o.ctrl_) { o.ctrl_ = nullptr; }
AtomicCountedPtr& operator=(AtomicCountedPtr o) noexcept {
std::swap(ctrl_, o.ctrl_);
return *this;
}
~AtomicCountedPtr() { release(); }
T* get() const noexcept { return ctrl_ ? ctrl_->ptr : nullptr; }
T& operator*() const noexcept { return *ctrl_->ptr; }
T* operator->() const noexcept { return ctrl_->ptr; }
int use_count() const noexcept {
return ctrl_ ? ctrl_->count.load(std::memory_order_relaxed) : 0;
}
explicit operator bool() const noexcept { return ctrl_ != nullptr; }
};The memory_order_seq_cst default costs a full memory fence on x86 and a stlr/ldar pair on ARM; the targeted orders above are measurably cheaper under high contention.
Custom Deleter Support
Claim. Storing the object as a bare pointer prevents the counted pointer from being used with anything other than heap-allocated, delete-able objects.
Adding a std::function<void(T*)> deleter to the control block lifts that restriction. Arrays, file handles, and pool-allocated objects all become first-class citizens:
#include <functional>
#include <cstdio>
template<typename T>
class counted_ptr_ex { // C++11
struct CB {
T* ptr;
std::atomic<int> count{1};
std::function<void(T*)> deleter;
CB(T* p, std::function<void(T*)> d)
: ptr(p), deleter(std::move(d)) {}
};
CB* cb_ = nullptr;
void drop() noexcept {
if (!cb_) return;
if (cb_->count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
cb_->deleter(cb_->ptr);
delete cb_;
}
cb_ = nullptr;
}
public:
counted_ptr_ex() = default;
counted_ptr_ex(T* p, std::function<void(T*)> d)
: cb_(p ? new CB(p, std::move(d)) : nullptr) {}
// Default heap delete
explicit counted_ptr_ex(T* p)
: counted_ptr_ex(p, [](T* x){ delete x; }) {}
counted_ptr_ex(const counted_ptr_ex& o) : cb_(o.cb_) {
if (cb_) cb_->count.fetch_add(1, std::memory_order_relaxed);
}
counted_ptr_ex(counted_ptr_ex&& o) noexcept : cb_(o.cb_) { o.cb_ = nullptr; }
counted_ptr_ex& operator=(counted_ptr_ex o) noexcept {
std::swap(cb_, o.cb_); return *this;
}
~counted_ptr_ex() { drop(); }
T* get() const noexcept { return cb_ ? cb_->ptr : nullptr; }
T& operator*() const noexcept { return *cb_->ptr; }
T* operator->() const noexcept { return cb_->ptr; }
};Example usage with an array and a C file handle:
// Array delete // C++11
auto buf = counted_ptr_ex<int>(new int[256], [](int* p){ delete[] p; });
// RAII file handle
auto fh = counted_ptr_ex<FILE>(
std::fopen("data.bin", "rb"),
[](FILE* f){ if (f) std::fclose(f); }
);
// Pool deallocation
auto pooled = counted_ptr_ex<Widget>(
pool.allocate<Widget>(),
[&pool](Widget* w){ w->~Widget(); pool.deallocate(w); }
);The deleter is type-erased via std::function, which carries a small heap allocation and virtual-dispatch overhead. std::unique_ptr<T, D> bakes the deleter type into the pointer type and pays zero overhead, but cannot be shared. For shared ownership with a custom deleter, this trade-off is unavoidable unless the deleter type is threaded through as a second template parameter.
make_counted: Exception-Safe Construction
Claim. Writing counted_ptr(new T(...)) has a subtle exception-safety hole: if the control block allocation throws after new T succeeds, the just-constructed T leaks.
The sequence new T(args...) runs first, then new ControlBlock(ptr) is attempted. If the second allocation throws, the raw pointer to T is lost and the object is never destroyed. The fix mirrors std::make_shared: allocate T and the control block together in one call so no partial state can exist:
#include <memory> // std::allocator
#include <new> // placement new
template<typename T>
struct alignas(T) FusedBlock { // C++11
struct CB {
std::atomic<int> count{1};
bool alive{true};
} cb;
alignas(T) unsigned char storage[sizeof(T)];
};
template<typename T>
class make_counted_ptr {
FusedBlock<T>* block_ = nullptr;
T* obj() const noexcept {
return reinterpret_cast<T*>(block_->storage);
}
void drop() noexcept {
if (!block_) return;
if (block_->cb.count.fetch_sub(1, std::memory_order_acq_rel) == 1) {
if (block_->cb.alive) { obj()->~T(); block_->cb.alive = false; }
delete block_;
}
block_ = nullptr;
}
public:
make_counted_ptr() = default;
explicit make_counted_ptr(FusedBlock<T>* b) : block_(b) {}
make_counted_ptr(const make_counted_ptr& o) : block_(o.block_) {
if (block_) block_->cb.count.fetch_add(1, std::memory_order_relaxed);
}
make_counted_ptr(make_counted_ptr&& o) noexcept
: block_(o.block_) { o.block_ = nullptr; }
make_counted_ptr& operator=(make_counted_ptr o) noexcept {
std::swap(block_, o.block_); return *this;
}
~make_counted_ptr() { drop(); }
T* get() const noexcept { return block_ ? obj() : nullptr; }
T& operator*() const noexcept { return *obj(); }
T* operator->() const noexcept { return obj(); }
};
template<typename T, typename... Args>
make_counted_ptr<T> make_counted(Args&&... args) { // C++11
// Single allocation β if this throws, nothing was constructed
auto* block = new FusedBlock<T>;
// Placement-new T into the storage β if this throws, block is on the stack
// but has not yet been handed to make_counted_ptr, so it leaks.
// Wrap in try/catch to handle T's constructor throwing:
try {
new (block->storage) T(std::forward<Args>(args)...);
} catch (...) {
delete block;
throw;
}
return make_counted_ptr<T>(block);
}With a single allocation, the only way the construction can fail is if new FusedBlock<T> throws (nothing has been constructed yet, no leak) or if T's constructor throws (caught above, block is freed). Either way, no resource escapes. This is the same guarantee std::make_shared provides.
Enabling shared_from_this Equivalent
Claim. Any time a member function needs to extend the object's lifetime β posting itself to a thread pool, storing a callback β using this raw in a lambda is dangerous because nothing prevents the object from being destroyed before the callback fires.
std::enable_shared_from_this<T> solves this for shared_ptr by storing a weak_ptr inside the object. The same pattern is straightforward to replicate for a hand-rolled counted pointer using a weak variant of the control block:
#include <memory>
// Using std::shared_ptr / enable_shared_from_this as the production pattern:
struct TaskBase : std::enable_shared_from_this<TaskBase> { // C++11
virtual void run() = 0;
virtual ~TaskBase() = default;
// Safe: shared_from_this() re-uses the existing control block
void schedule_on(std::function<void(std::function<void()>)> pool) {
auto self = shared_from_this(); // bumps refcount
pool([self]{ self->run(); }); // lambda owns a counted ref
}
};
struct MyTask : TaskBase {
void run() override { /* ... */ }
};
// Correct usage:
auto task = std::make_shared<MyTask>(); // C++11
task->schedule_on(thread_pool_post);
// task local ref may go out of scope; the lambda's 'self' keeps it aliveThe common mistake is bypassing shared_from_this() by capturing this directly:
struct BadTask : std::enable_shared_from_this<BadTask> { // C++11
void unsafe_schedule(std::function<void(std::function<void()>)> pool) {
// WRONG: lambda captures raw `this`; object may be destroyed
// before the pool executes the callback
pool([this]{ run(); });
}
void run() {}
};Calling shared_from_this() before any shared_ptr owns the object throws std::bad_weak_ptr. Always construct the object through make_shared or assign it to a shared_ptr before calling member functions that use shared_from_this.
For a hand-rolled EnableCountedFromThis<T> the same principle applies: store a weak-flavored observer in the base class, populate it in the smart pointer constructor, and expose a counted_from_this() method that promotes the weak reference to a strong one.
Counted Pointers in Data Structures
Claim. Concurrent data structures that hand nodes between threads need a discipline for keeping nodes alive long enough for safe access without holding a lock.
A lock-free stack illustrates the problem. Each pop must read head_, dereference it, and swing the head to next β but between reading head_ and dereferencing it, another thread might pop and delete the node:
#include <atomic>
#include <memory>
template<typename T>
class LockFreeStack { // C++11
struct Node {
T value;
std::shared_ptr<Node> next;
explicit Node(T v) : value(std::move(v)) {}
};
std::atomic<std::shared_ptr<Node>> head_{}; // C++20
public:
void push(T v) {
auto node = std::make_shared<Node>(std::move(v));
node->next = head_.load(std::memory_order_relaxed);
while (!head_.compare_exchange_weak(
node->next, node,
std::memory_order_release,
std::memory_order_relaxed)) {}
}
std::shared_ptr<T> pop() {
auto old = head_.load(std::memory_order_acquire);
while (old && !head_.compare_exchange_weak(
old, old->next,
std::memory_order_release,
std::memory_order_acquire)) {}
return old ? std::make_shared<T>(std::move(old->value)) : nullptr;
}
};Using std::shared_ptr as the node pointer means a successful pop keeps the node alive through its own copy until the caller is done with it β no separate reference count bookkeeping in the push/pop paths. Raw pointers require either hazard pointers or epoch-based reclamation to prevent use-after-free.
The ABA problem is not eliminated by reference counting alone. ABA occurs when the same address is reused for a new object between a read and a CAS. With shared_ptr nodes the address is the shared_ptr value, not a raw pointer, so recycled addresses do not collide unless the allocator happens to return the same address for a new node. For production lock-free code, consider tagged pointers or a dedicated reclamation scheme in addition to reference counting.
Performance Comparison
The four ownership strategies occupy different points in the cost/flexibility space:
| Metric | shared_ptr<T> | intrusive_ptr<T> | counted_ptr<T> (hand-rolled) | unique_ptr<T> |
|---|---|---|---|---|
| Allocation overhead | 2 allocs (new T path) or 1 fused (make_shared) | 1 alloc (no control block) | 2 allocs (new T + new CB) | 1 alloc |
| Copy cost | Atomic RMW on control block | Atomic RMW on embedded counter | Atomic RMW on control block | Not copyable |
| Dereference cost | 1 pointer load | 0 extra indirection | 1 pointer load | 0 extra indirection |
| Weak reference support | Yes (weak_ptr) | No (requires manual implementation) | Requires separate weak control block | No |
| Custom deleter | Yes (type-erased, zero overhead with make_shared) | No (always delete) | Yes (with std::function, adds overhead) | Yes (zero overhead, baked into type) |
enable_shared_from_this | Yes (stdlib) | Manual | Manual | N/A |
Cache locality. std::make_shared fuses the T and control block into one allocation; the first dereference through a freshly minted pointer touches the same cache line as the count. Intrusive pointers go further: the counter lives inside the object itself, so add_ref and object access always share the same cache line. Separate-allocation shared_ptr(new T) and the hand-rolled counted_ptr pay an extra cache miss on first access to the control block when the two allocations land on different cache lines.
Atomic overhead. On modern x86-64, fetch_add(relaxed) compiles to a lock xadd instruction (3β10 cycles under low contention). On ARMv8, memory_order_relaxed avoids barrier instructions. The acq_rel decrement costs a dmb ish fence on ARM. For paths that copy a shared_ptr in a tight loop, passing by const& rather than by value eliminates these atomic operations entirely.
See Also
std::shared_ptrβ<memory>, C++11std::weak_ptrβ<memory>, C++11std::enable_shared_from_thisβ<memory>, C++11std::make_sharedβ<memory>, C++11std::atomic<std::shared_ptr<T>>β<memory>, C++20