Have you implemented a shared pointer class in C++? If you have, did you do it
in an interview setting? You might be familiar with the STL shared_ptr
and the
fact that many implementations of shared_ptr
use reference counting to manage
the lifetime of a dynamically allocated object. That said, if you’ve never
thought about or tried to actually implement the concept itself, doing so in an
interview is a tall order. This article walks you through the implementation of
an interview grade SharedPtr
class.
How to Reference Count
When you think about implementing a shared pointer, what comes to mind? Wrapping
the user’s pointer and counting how many SharedPtr
objects point to the same
location seems like a reasonable strategy. Here’s a first attempt at setting up
this bookkeeping:
template <typename T>
class SharedPtr {
public:
...
private:
T* data_;
std::size_t ref_count_;
};
This declaration is mostly correct. The T* data_
member is right. You need a
way of sharing and accessing the data. What better way than a pointer to the
data. After all, a shared pointer is a lightweight wrapper around a raw pointer.
The std::size_t ref_count_
variable seems like a good idea, however, it
doesn’t work for ref counting. Why? What happens when you copy, assign,
destroy, or call Reset()
on a SharedPtr
? In those instances, you need to
decrement/increment the ref_count_
. You can certainly update the ref_count_
in the object performing the operation. However, there’s no clear way to
communicate the increment/decrement to all other SharedPtr
instances wrapping
the same data_
pointer.
What’s the trick? Change the declaration of ref_count_
to std::size_t* ref_count_
. The ref count itself is a pointer that’s shared by all SharedPtr
instances wrapping the same data_
pointer. The first SharedPtr
to wrap
data_
is responsible for allocating ref_count_
. When ref_count_
hits 0,
ref_count_
deallocates along with data_
.
Lets work an example. Consider the code below:
void Nonsense() {
SharedPtr<int> p1(new int(42));
SharedPtr<int> p2 = p1;
}
How do p1
and p2
evolve from when you first enter the Nonsense()
function’s scope until right before destruction? Try going line-by-line starting
with the instantiation of p1
:
+------------+ +-----------------+
| P1 | | Main Memory |
+------------+ +-----------------+
SharedPtr<int> p1(new int(42)); | data_ +----->| 42 |
+------------+ +-----------------+
| ref_count_ +----->| 1 |
+------------+ +-----------------+
Nothing too crazy here. You wrap a pointer to the value 42
. Your ref count
points to a value of 1
. What happens when you assign p1
to p2
?
+------------+ +-----------------+
| P1 | | Main Memory |
+------------+ +-----------------+
| data_ +----->| 42 |<-+
+------------+ +-----------------+ |
| ref_count_ +----->| 2 |<-+-+
+------------+ +-----------------+ | |
SharedPtr<int> p2 = p1; | |
+------------+ | |
| P2 | | |
+------------+ | |
| data_ +---------------------------+ |
+------------+ |
| ref_count_ +-----------------------------+
+------------+
Here the SharedPtr
works its magic. Both p1
and p2
point to the same data
in memory via a copy of the data_
pointer. You bookkeep ref_count_
during
the assignment in p2
. Specifically, *ref_count_
gets incremented from 1
to
2
. The key thing to note is that even though the increment to ref_count_
came from the p2
object, p1
sees the change. Why? Because p1
and p2
point to the same area in memory containing the ref_count_
value.
The API
The SharedPtr
API is similar in spirit to the STL’s shared_ptr
:
template <typename T>
class SharedPtr {
public:
SharedPtr();
explicit SharedPtr(T* data);
~SharedPtr();
SharedPtr(const SharedPtr& sp);
SharedPtr& operator=(SharedPtr rhs);
SharedPtr(SharedPtr&& sp);
SharedPtr& operator=(SharedPtr&& rhs);
const T& operator*() const;
T& operator*();
bool Empty() const;
std::size_t RefCount() const;
void Reset(T* data);
template <typename U>
friend void Swap(SharedPtr<U>& a, SharedPtr<U>& b);
};
Here are the key features starting from the top:
SharedPtr
is a template class that wraps a pointer to any typeT
.- You can default construct
SharedPtr
. - Included is a constructor that takes ownership of a raw pointer.
- You can copy/move construct and assign
SharedPtr
objects. - The dereference operator gets overloaded.
- One can verify whether the pointer is empty or NULL.
- One can access the reference count.
- You can wrap another dynamically allocated object without leaking memory to
the originally wrapped object via a
Reset()
call.
You’ll notice a friend Swap()
method towards the end of the declaration.
Swap()
implements the copy-and-swap idiom. Swap()
simplifies the
implementation of copy assignment and move construction/assignment. More on that
later.
The Basics
Construction, dereferencing, ref counting, and empty/NULL checks have a straightforward implementation:
template <typename T>
SharedPtr<T>::SharedPtr() : data_(nullptr), ref_count_(nullptr) {}
template <typename T>
SharedPtr<T>::SharedPtr(T* data)
: data_(data), ref_count_(new std::size_t(1)) {}
template <typename T>
bool SharedPtr<T>::Empty() const { return (!data_ && !ref_count_); }
template <typename T>
std::size_t SharedPtr<T>::RefCount() const {
if (Empty()) {
throw std::runtime_error("cannot return ref count of NULL SharedPtr");
}
return *ref_count_;
}
template <typename T>
const T& SharedPtr<T>::operator*() const {
if (Empty()) {
throw std::runtime_error("cannot dereference NULL SharedPtr");
}
return *data_;
}
template <typename T>
T& SharedPtr<T>::operator*() {
if (Empty()) {
throw std::runtime_error("cannot dereference NULL SharedPtr");
}
return *data_;
}
This implementation throws std::runtime_error
when a user attempts to access
the reference count or data of an uninitialized SharedPtr
. This was a decision
made to make the class more test friendly and avoid any undefined behavior. It’s
also worth mentioning that the call to new
in the nondefault constructor has
the potential to throw std::bad_alloc
along with introducing the overhead of
an allocation. Since you’re already using exceptions for error handling and
wrapping dynamically allocated objects, the latter “issues” are probably
negligible in most codebases opting to use SharedPtr
.
Reference Counter Bookkeeping
The core of the SharedPtr
implementation is how the ref_count_
member gets
updated. That is, you need to manage ref_count_
increment/decrement and
guarantee the wrapped resource gets released when ref_count_
reaches 0. To do
this right, you can enumerate all the places ref_count_
gets updated.
ref_count_
gets incremented:
- On nondefault construction.
- On copy construction.
ref_count_
gets decremented:
- On destruction.
- On copy or move assignment.
- After a call to
Reset()
.
Decrement happens more often and has the added overhead of checking whether the
ref_count_
reached 0. In the interest of not duplicating the decrement and ref
count check code, I implemented a utility method: DecrementRefCount()
:
template <typename T>
void SharedPtr<T>::DecrementRefCount() {
if (Empty()) {
return;
}
*ref_count_ -= 1;
if (0 == *ref_count_) {
delete data_;
delete ref_count_;
data_ = nullptr;
ref_count_ = nullptr;
}
}
DecrementRefCount()
makes the implementation of the remaining API methods
relatively straightforward:
template <typename T>
SharedPtr<T>::~SharedPtr() {
DecrementRefCount();
data_ = nullptr;
ref_count_ = nullptr;
}
template <typename T>
SharedPtr<T>::SharedPtr(const SharedPtr<T>& sp)
: data_(sp.data_), ref_count_(sp.ref_count_) {
*ref_count_ += 1;
}
template <typename T>
SharedPtr<T>& SharedPtr<T>::operator=(SharedPtr rhs) {
DecrementRefCount();
Swap(*this, rhs);
return *this;
}
template <typename T>
SharedPtr<T>::SharedPtr(SharedPtr&& sp) : SharedPtr<T>() {
Swap(*this, sp);
}
template <typename T>
SharedPtr<T>& SharedPtr<T>::operator=(SharedPtr&& rhs) {
DecrementRefCount();
Swap(*this, rhs);
return *this;
}
template <typename T>
void SharedPtr<T>::Reset(T* data) {
DecrementRefCount();
data_ = data;
ref_count_ = new std::size_t(1);
}
The copy-and-swap idiom helps implement copy/move assignment and the move
constructor. Critical to the use of this idiom is the implementation of a
Swap()
function that can swap the state of two SharedPtr
objects:
template <typename U>
friend void Swap(SharedPtr<U>& a, SharedPtr<U>& b) {
using std::swap;
swap(a.data_, b.data_);
swap(a.ref_count_, b.ref_count_);
}
The post linked at the end of this article explains the rationale behind the idiom and dives into the gritty details.
Conclusion
Creating a SharedPtr
class is an interesting problem with some fun edge cases
and quirks. It’s not too hard to understand why someone would want to ask a
question like this. Getting a proper implementation requires some diagramming
and careful bookkeeping. Questions around error handling and memory management
also come up. Now whether it’s a good question for a 30min interview is another
story.
The complete project source with build instructions, usage, etc. is available on GitHub under shared_ptr.