Implementing std::shared_ptr from Scratch
June 15, 2026 · Luciano Muratore
Introduction
std::shared_ptr is one of the most widely used tools in modern C++ for managing dynamically allocated objects with shared ownership. I wanted to go through understanding how they are implemented.
The article shows a build of a minimal version of shared_ptr from scratch MySharedPtr<T>, covering the control block, reference counting, copy semantics, and the self-assignment hazard.
The Core Idea: Shared Ownership via a Control Block
The defining feature of shared_ptr is that multiple instances can share ownership of the same object. The object is destroyed only when the last owning shared_ptr is destroyed or reassigned.
To make this work, a shared_ptr can’t just hold a pointer to the managed object — it also needs a pointer to a shared piece of bookkeeping state: the control block. The control block holds the reference count (and, in the full implementation, a weak count and deleter information).
Every shared_ptr that shares ownership of the same object holds a pointer to the same control block:
A crucial point to make: there is one reference count per control block, not one per shared_ptr. Calling .use_count() on any shared_ptr that shares a control block returns the same number. That means that they’re all reading the same shared piece of state, not independent personal counters.
The Control Block
The control block is responsible for:
- holding a pointer to the managed object,
- holding the strong reference count,
- providing
increment()anddecrement()operations on that count, - deciding when the managed object should be destroyed.
template <typename T>
class ControlBlock {
public:
T* ptr;
int strong_count;
ControlBlock(T* p) : ptr(p), strong_count(1) {}
void increment() {
strong_count++;
}
void decrement() {
strong_count--;
if (strong_count == 0) {
delete ptr;
ptr = nullptr;
}
}
};
Note that the first shared_ptr to be constructed doesn’t “increment” the count, it initializes it to 1. Incrementing only happens when an additional shared_ptr attaches itself to an already-existing control block (via copy construction or copy assignment).
The Shared Pointer Class: Construction and Copying
MySharedPtr<T> itself holds two members: a pointer to the managed object, and a pointer to the control block.
template <typename T>
class MySharedPtr {
private:
T* ptr;
ControlBlock<T>* ctrl;
public:
// Constructor: creates a NEW control block
explicit MySharedPtr(T* p = nullptr)
: ptr(p), ctrl(p ? new ControlBlock<T>(p) : nullptr) {}
// Copy constructor
MySharedPtr(const MySharedPtr& other)
: ptr(other.ptr), ctrl(other.ctrl) {
if (ctrl != nullptr) {
ctrl->increment();
}
}
};
The explicit keyword on the constructor prevents accidental implicit conversions from raw pointers — the same protection std::shared_ptr provides.
The copy constructor is where the “shared” in shared ownership becomes concrete: the new MySharedPtr copies both member pointers from other — meaning it now points to the same control block — and then calls increment() on that control block. The new shared_ptr doesn’t get “sent” anywhere; it reaches out through its ctrl pointer and tells the control block “add one more owner.”
Releasing Ownership: The Destructor
When a MySharedPtr is destroyed, it needs to tell the control block “I’m no longer an owner.” We factor this into a private release() method, since it’s also needed by the assignment operator:
void release() {
if (ctrl != nullptr) {
ctrl->decrement();
if (ctrl->strong_count == 0) {
delete ctrl;
}
ptr = nullptr;
ctrl = nullptr;
}
}
~MySharedPtr() {
release();
}
release() does three things, in order:
- Decrement the control block’s strong count.
- If that count hit zero, the managed object has already been deleted (inside
decrement()) — so now the control block itself can also be deleted, since nothing references it anymore. - Clear this
MySharedPtr’s own pointers tonullptr.
A subtlety worth calling out: a MySharedPtr can be empty (default-constructed, or already released) — its destructor checks ctrl != nullptr and does nothing in that case, since there’s no control block to talk to.
The Copy Assignment Operator and the Self-Assignment Hazard
This is the trickiest piece. Unlike the copy constructor (where the target object doesn’t exist yet), copy assignment overwrites an already-existing MySharedPtr that may currently own a different object.
A naive implementation might:
- Release the old control block.
- Acquire the new one (copy pointers, increment).
This breaks in the self-assignment case (p1 = p1). If other and *this share the same control block, step 1 decrements the count — possibly to zero, deleting the managed object — before step 2 tries to use that same (now-destroyed) control block.
The fix is to reverse the order: acquire the new control block first, then release the old one.
MySharedPtr& operator=(const MySharedPtr& other) {
if (other.ctrl != nullptr) {
other.ctrl->increment();
}
release();
ptr = other.ptr;
ctrl = other.ctrl;
return *this;
}
Why this works:
-
Normal case (
p2 = p1, different control blocks):p1’s control block is incremented first (it now accounts forp2joining as an owner). Thenp2’s old control block is released — if its count drops to zero, that old object is safely destroyed, since the new one is already secured. -
Self-assignment (
p1 = p1, same control block): the increment and the subsequent release both operate on the same control block, and cancel out (e.g., 1 → 2 → 1). The count never momentarily reaches zero, so the object is never destroyed mid-assignment. No explicitif (this == &other)check is needed — the ordering makes self-assignment safe “for free.”
Putting It All Together
template <typename T>
class ControlBlock {
public:
T* ptr;
int strong_count;
ControlBlock(T* p) : ptr(p), strong_count(1) {}
void increment() { strong_count++; }
void decrement() {
strong_count--;
if (strong_count == 0) {
delete ptr;
ptr = nullptr;
}
}
};
template <typename T>
class MySharedPtr {
private:
T* ptr;
ControlBlock<T>* ctrl;
void release() {
if (ctrl != nullptr) {
ctrl->decrement();
if (ctrl->strong_count == 0) {
delete ctrl;
}
ptr = nullptr;
ctrl = nullptr;
}
}
public:
explicit MySharedPtr(T* p = nullptr)
: ptr(p), ctrl(p ? new ControlBlock<T>(p) : nullptr) {}
MySharedPtr(const MySharedPtr& other)
: ptr(other.ptr), ctrl(other.ctrl) {
if (ctrl != nullptr) {
ctrl->increment();
}
}
MySharedPtr& operator=(const MySharedPtr& other) {
if (other.ctrl != nullptr) {
other.ctrl->increment();
}
release();
ptr = other.ptr;
ctrl = other.ctrl;
return *this;
}
~MySharedPtr() {
release();
}
T* get() const { return ptr; }
T& operator*() const { return *ptr; }
T* operator->() const { return ptr; }
int use_count() const {
return ctrl ? ctrl->strong_count : 0;
}
};
The final use_count() uses a ternary expression: ctrl ? ctrl->strong_count : 0 means “if ctrl is non-null, return its strong count; otherwise return 0” — guarding against dereferencing a null control block pointer on an empty MySharedPtr.
A Worked Example
struct Resource {
int id;
Resource(int id_) : id(id_) {
std::cout << "Resource " << id << " created\n";
}
~Resource() {
std::cout << "Resource " << id << " destroyed\n";
}
};
int main() {
MySharedPtr<Resource> p1(new Resource(1));
std::cout << "p1 use_count: " << p1.use_count() << "\n"; // 1
{
MySharedPtr<Resource> p2 = p1;
std::cout << "p1 use_count after copy: " << p1.use_count() << "\n"; // 2
} // p2 destroyed here
std::cout << "p1 use_count after p2 scope ends: " << p1.use_count() << "\n"; // 1
MySharedPtr<Resource> p3(new Resource(2));
p3 = p1; // Resource(2) released, Resource(1) gains an owner
std::cout << "p1 use_count after assign: " << p1.use_count() << "\n"; // 2
p1 = p1; // self-assignment, count stays at 2
std::cout << "p1 use_count after self-assign: " << p1.use_count() << "\n"; // 2
return 0;
}