Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 29

Implementing a Reference-Counted Smart Pointer

Recommended resources

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:

  1. A pointer to the managed object.
  2. A pointer to the shared control block (which, for our basic version, will just contain the reference counter).
Internal Structure of std::shared_ptr and Control Block
This diagram, which you saw before, shows the `shared_ptr` object holding a `rawptr` to the resource and a `cbptr` to the control block. Our implementation will mirror this, with the control block initially just holding our reference count.

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
  1. After p1 is created, it allocates a new reference counter and sets it to 1. The first cout will print 1.
  2. The line MySharedPtr<int> p2 = p1; calls the copy constructor. p2 gets copies of p1's pointers, and the reference count is atomically incremented to 2.
  3. The next two cout statements will both print 2, as both p1 and p2 point to the same counter.
  4. 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.
  5. Then, p1's destructor is called. It decrements the count from 1 to 0. Since the count is now 0, p1's destructor proceeds to delete the managed int and 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?

  1. MySharedPtr temp(other); creates a new smart pointer temp that shares ownership with other. other's ref count is incremented.
  2. swap(temp); swaps the internal pointers of *this and temp. Now *this holds the new resource and temp holds the old resource.
  3. When the function ends, temp is 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_shared equivalent: This performs the single-allocation optimization we discussed. Our version always does two allocations (new T and new 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 a Derived object, 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.