Hello! Welcome back to our deep dive into C++ resource management.
In the previous lesson, we analyzed std::shared_ptr from a user's perspective. We dissected its internal two-pointer structure, understood the role of its control block, and appreciated the safety and performance benefits of std::make_shared. You now know what std::shared_ptr does. True to your goal of understanding C++ from first principles, this lesson is about discovering how it works by building our own.
Your learning outcome is to implement a basic reference-counted smart pointer to understand the shared ownership mechanism. This exercise will solidify your mental model of shared ownership, connect directly to the OS-level concepts of memory management you know from C, and give you a profound appreciation for the design of the standard library's version.
The Central Design Challenge: Sharing the Count
The core of a shared pointer is reference counting. When we copy a smart pointer, a shared counter is incremented. When a smart pointer is destroyed, the counter is decremented. When the counter reaches zero, the underlying resource is deleted.
This raises the most important design question: Where do we store this shared counter?
Let's watch a segment from a video that explores this exact problem. The presenter walks through why the most obvious approaches don't work, leading to the necessary design.
Shared Pointers Implementation-MUST know! Advanced C++ Topics for Interviews!
In the video "Shared Pointers Implementation-MUST know!", Keerti Purswani brilliantly explains the fundamental design challenge of a shared pointer. This section is key to understanding why the control block must exist as a separate entity.
Watch from 00:06:04 to 00:12:44. The presenter explains why a simple integer member variable (per-object count) and a static integer (per-class count) are both incorrect. Pay close attention to the diagrams she draws to understand why the counter itself must be a pointer to a dynamically allocated integer.
As the video concludes, the only viable solution is for the reference count to be dynamically allocated on the heap, separate from any single smart pointer object. This leads us to the same two-pointer structure we saw in the last lesson:
- A pointer to the managed object.
- A pointer to the shared control block (which, for our basic version, will just contain the reference counter).

Laying the Foundation: The MySharedPtr Class
Let's start building. Our smart pointer will be a class template, MySharedPtr<T>. Based on our design, it needs two private members: the pointer to the object and a pointer to the reference count.
Given your goal of building a thread pool, thread safety is paramount. The reference count itself will be shared between threads, so incrementing and decrementing it must be an atomic operation. We'll use std::atomic<int> for this.
Here is the basic skeleton of our class:
#include <atomic> // For std::atomic
#include <utility> // For std::swap
template <typename T>
class MySharedPtr {
public:
// Constructors, destructor, operators will go here...
private:
void cleanup(); // A helper to clean up resources
T* m_ptr {nullptr};
std::atomic<int>* m_ref_count {nullptr};
};
Implementing the Lifecycle: Construction and Destruction
The most important methods define the object's lifecycle. Let's implement them one by one.
1. Parameterized Constructor
This constructor is called when we create a MySharedPtr from a raw pointer for the first time. It is responsible for allocating the reference counter.
// Constructor from a raw pointer
explicit MySharedPtr(T* ptr = nullptr) : m_ptr(ptr) {
if (m_ptr) {
// This is the first owner, so we allocate a new counter
// and initialize it to 1.
m_ref_count = new std::atomic<int>(1);
}
}
The explicit keyword here is important. It prevents dangerous implicit conversions from a raw pointer to our smart pointer, which could lead to the "two token jars" problem we discussed in the last lesson.
2. Destructor
The destructor is where the magic of RAII happens. When a MySharedPtr object goes out of scope, it decrements the reference count. If it was the last owner, it cleans up everything.
~MySharedPtr() {
cleanup();
}
// And the private helper:
void MySharedPtr<T>::cleanup() {
if (m_ref_count && --(*m_ref_count) == 0) {
// Atomically decrement the counter. If it becomes 0...
delete m_ptr; // ...delete the managed object...
delete m_ref_count; // ...and the reference counter itself.
}
// Set members to null to be safe
m_ptr = nullptr;
m_ref_count = nullptr;
}
Notice the logic: --(*m_ref_count) is an atomic pre-decrement operation. It decrements the value and returns the new value. If that new value is 0, we are the last owner and must perform the cleanup.
3. Copy Constructor
This is called when we create a new MySharedPtr from an existing one (e.g., MySharedPtr<int> p2 = p1;). It must copy the pointers and increment the shared count.
MySharedPtr(const MySharedPtr& other)
: m_ptr(other.m_ptr), m_ref_count(other.m_ref_count) {
if (m_ref_count) {
// We are a new owner, so atomically increment the counter.
(*m_ref_count)++;
}
}
This is straightforward: the new object now points to the same underlying resource and the same reference counter, and we increment the count to reflect the new ownership.
Test your understanding!
Consider the following code snippet:
{
MySharedPtr<int> p1(new int(42));
std::cout << "p1 count: " << p1.use_count() << std::endl; // Assume use_count() exists
MySharedPtr<int> p2 = p1;
std::cout << "p1 count: " << p1.use_count() << std::endl;
std::cout << "p2 count: " << p2.use_count() << std::endl;
} // p1 and p2 go out of scope here
Trace the value of the reference count at each std::cout statement and describe what happens when the scope ends.
Show answer
- After
p1is created, it allocates a new reference counter and sets it to 1. The firstcoutwill print 1. - The line
MySharedPtr<int> p2 = p1;calls the copy constructor.p2gets copies ofp1's pointers, and the reference count is atomically incremented to 2. - The next two
coutstatements will both print 2, as bothp1andp2point to the same counter. - When the scope ends,
p2's destructor is called first. It decrements the count from 2 to 1. Since the count is not 0, it does nothing else. - Then,
p1's destructor is called. It decrements the count from 1 to 0. Since the count is now 0,p1's destructor proceeds todeletethe managedintand the reference counter itself.
Implementing Assignment
Assignment is more complex than construction because the MySharedPtr being assigned to might already be managing another object. It has to release its old ownership before acquiring new ownership.
4. Copy Assignment Operator
This is called for p2 = p1;. The easiest and safest way to implement this is using the copy-and-swap idiom. It leverages the copy constructor and destructor we've already written to handle all the logic correctly and provide a strong exception safety guarantee.
// Copy assignment operator using copy-and-swap
MySharedPtr& operator=(const MySharedPtr& other) {
// Create a temporary copy using the copy constructor
MySharedPtr temp(other);
// Swap the contents of this object with the temporary copy
swap(temp);
// When temp goes out of scope, its destructor will be called,
// cleaning up the resource that *this object was previously holding*.
return *this;
}
// A private swap member function
void swap(MySharedPtr& other) noexcept {
std::swap(m_ptr, other.m_ptr);
std::swap(m_ref_count, other.m_ref_count);
}
How does this work?
MySharedPtr temp(other);creates a new smart pointertempthat shares ownership withother.other's ref count is incremented.swap(temp);swaps the internal pointers of*thisandtemp. Now*thisholds the new resource andtempholds the old resource.- When the function ends,
tempis destroyed. Its destructor is called, which decrements the ref count of the old resource, cleaning it up if it was the last owner.
This is an elegant pattern that ensures correctness even in complex scenarios like p = p;.
Adding Move Semantics
For modern, efficient C++, we should also support move semantics. This is for when we are transferring ownership, not sharing it.
5. Move Constructor
MySharedPtr<int> p2 = std::move(p1);
The move constructor "steals" the resource from the source object and leaves the source empty. No reference counts are changed.
// Move constructor
MySharedPtr(MySharedPtr&& other) noexcept {
// "Steal" the pointers from the other object
m_ptr = other.m_ptr;
m_ref_count = other.m_ref_count;
// Leave the other object in a valid, empty state
other.m_ptr = nullptr;
other.m_ref_count = nullptr;
}
6. Move Assignment Operator
p3 = std::move(p2);
Similar to the copy assignment, we can use the swap idiom. The logic is analogous: steal the resource from the source, and in the process, ensure the currently held resource is cleaned up properly.
// Move assignment operator
MySharedPtr& operator=(MySharedPtr&& other) noexcept {
// Create an empty temporary, swap our contents into it,
// then swap the other's contents into ourselves.
MySharedPtr temp;
temp.swap(*this);
this->swap(other);
return *this;
}
Alternatively, a more explicit implementation would be:
// Explicit move assignment
MySharedPtr& operator=(MySharedPtr&& other) noexcept {
if (this != &other) {
cleanup(); // Clean up our currently held resource
// Steal from other
m_ptr = other.m_ptr;
m_ref_count = other.m_ref_count;
// Reset other
other.m_ptr = nullptr;
other.m_ref_count = nullptr;
}
return *this;
}
Final Touches: Accessors
Finally, to make our class behave like a pointer, we need the dereference (*) and arrow (->) operators, plus a way to get the count.
// Access the managed object
T* get() const { return m_ptr; }
// Get the number of owners
int use_count() const {
return m_ref_count ? *m_ref_count : 0;
}
// Dereference operator
T& operator*() const { return *m_ptr; }
// Arrow operator
T* operator->() const { return m_ptr; }
What We've Built (and What We Haven't)
Let's review the complete basic implementation. This text provides a similar, complete implementation for you to study.
Crafting a Shared Pointer from the Ground Up:A Deep Dive ...
The article "Crafting a Shared Pointer from the Ground Up" provides a concise, complete implementation very similar to what we've just designed. Review its code to see all the pieces put together.
Read the sections 'Designing the Structure' and 'Implementation Details'. Compare the code there with our implementation. Note that their cleanup logic is directly inside the destructor and assignment operator, but the principle is identical. They also use std::atomic for the reference count.
You have now built a MySharedPtr that correctly manages shared ownership with atomic reference counting! This is a huge step in understanding C++'s memory management at a deep level.
However, our implementation is basic. std::shared_ptr is far more complex. It includes:
- A
make_sharedequivalent: This performs the single-allocation optimization we discussed. Our version always does two allocations (new Tandnew std::atomic<int>). - Custom Deleters: The ability to provide a custom function to destroy the object.
- Type Erasure: The ability for
shared_ptr<Base>to correctly delete aDerivedobject, even with non-virtual destructors (by storing the original deleter in the control block). - Support for
weak_ptr: This requires a second counter in the control block (the weak count).
Conclusion
By building our own reference-counted pointer, we've demystified the core mechanics of std::shared_ptr. You've seen firsthand why it needs a separate, dynamically-allocated control block and how atomic operations are essential for thread-safe ownership transfer.
Key Takeaways:
- Shared ownership requires a shared state—the reference count—which must be allocated on the heap alongside the managed object.
- Constructors, destructors, and assignment operators must carefully manage this atomic counter to ensure the resource is deleted if and only if the last owner is gone.
- The copy-and-swap idiom is a powerful, safe pattern for implementing assignment operators.
- Move semantics provide an efficient way to transfer ownership without affecting the reference count.
What's Next?
Our implementation works, but it's vulnerable to a subtle and dangerous problem: circular references. If two objects hold MySharedPtrs to each other, their reference counts will never drop to zero, and they will leak memory. In the next lesson, we will explore std::weak_ptr, the tool C++ provides to observe a shared object without owning it, which is the key to breaking these cycles.