So, we have already seen how unique pointers work. They wrap a raw pointer, they cannot be copied and they delete their internal pointer when the they go out of scope. But sometimes we want to use RAII with our pointers, but we need more than a single copy of them. That’s where shared pointer comes in. A shared pointer is just like a unique pointer, except it can be copied! So we can have multiple shared pointers pointing to the same underlying data.
The really clever thing is that a shared pointer keeps track of how many copies of itself there are. It does this by maintaining a counter of the number of copies, called the reference count. When all of the copies of a given shared pointer have gone out scope, the reference count will be 0 and delete
is called on the underlying raw pointer. So even though there are multiple copies of the same shared pointer, we can still be sure that the underlying raw pointer will get deleted. You can (sort of) think of a unique pointer as a shared pointer, with a maximum reference count of 1.
Ok then, let’s look at some code!
template<typename T>
class shared_ptr
{
private:
T* internal_pointer;
uint* reference_count;
public:
shared_ptr(T* internal_pointer) : internal_pointer(internal_pointer)
{
reference_count = new uint(1);
}
shared_ptr(const shared_ptr& source) : internal_pointer(source.internal_pointer), reference_count(source.reference_count)
{
(*reference_count)++;
}
shared_ptr<T>& operator=(const shared_ptr& source)
{
on_reference_deletion();
internal_pointer = source.internal_pointer;
reference_count = source.reference_count;
(*reference_count)++;
return *this;
}
shared_ptr(shared_ptr&& source) : internal_pointer(source.internal_pointer)
, reference_count(source.reference_count)
{
source.reference_count = nullptr;
source.internal_pointer = nullptr;
}
shared_ptr<T>& operator=(shared_ptr&& source)
{
on_reference_deletion();
internal_pointer = source.internal_pointer;
reference_count = source.reference_count;
source.internal_pointer = nullptr;
source.reference_count = nullptr;
return *this;
}
T& operator*()
{
return *internal_pointer;
}
T* operator->()
{
return internal_pointer;
}
~shared_ptr()
{
if(reference_count)
{
on_reference_deletion();
}
}
private:
void on_reference_deletion()
{
(*reference_count)--;
if(*reference_count == 0)
{
delete internal_pointer;
}
}
};
This is of course very similar to our implementation of unique_ptr
. However, now we have an extra member, a pointer to an unsigned integer called reference_count
. This is the counter that keeps track of how many copies of this shared_ptr
there are. It is a pointer so that all the distinct copies can share the same counter. In the constructor,
shared_ptr(T* internal_pointer) : internal_pointer(internal_pointer)
{
reference_count = new uint(1);
}
we are creating the first instance of this shared_ptr
so we set the reference counter to 1.
We also have a copy constructor now:
shared_ptr(const shared_ptr& source) : internal_pointer(source.internal_pointer), reference_count(source.reference_count)
{
(*reference_count)++;
}
In this constructor, we copy the internal_pointer
and the reference_count
from source
. Then, as there is a new copy of this shared_ptr
, we increment the the reference counter.
The move constructor is similar to the one in unique_ptr
:
shared_ptr(shared_ptr&& source) : internal_pointer(source.internal_pointer), reference_count(source.reference_count)
{
source.reference_count = nullptr;
source.internal_pointer = nullptr;
}
First we copy across the internal_pointer
and the reference_count
. We have moved out of source
, so it should no longer have any reference to the underlying data. So we set the two pointers inside of source
to nullptr
. This means that source
can now go out of scope, without effecting the internal_pointer
or reference_count
. Moving does not increase the number of references, so we don’t need to increment the reference counter here.
The copy constructor and move assignment are a little bit more complicated. An object that is being copy or move assigned, will already have been initialised. That means that it already has a pointer to some data and a reference count. So, before the assignment can happen, it needs to decrement the reference count, and if necessary delete the data pointed to by internal_pointer
. This operation is handled by the function on_reference_deletion
:
void on_reference_deletion()
{
(*reference_count)--;
if(*reference_count == 0)
{
delete internal_pointer;
}
}
So our copy assignment looks like this:
shared_ptr<T>& operator=(const shared_ptr& source)
{
on_reference_deletion();
internal_pointer = source.internal_pointer;
reference_count = source.reference_count;
(*reference_count)++;
return *this;
}
First we call on_reference_deletion
. Then we copy over the data from the source shared_ptr
, both the internal_pointer
and the reference_count
. Then, as we have made a new copy of the source shared_ptr
, we increment the reference count.
Then there is the the move assignment operator:
shared_ptr<T>& operator=(shared_ptr&& source)
{
on_reference_deletion();
internal_pointer = source.internal_pointer;
reference_count = source.reference_count;
source.internal_pointer = nullptr;
source.reference_count = nullptr;
return *this;
}
As before, first we call on_reference_deletion
and then we copy across the two internal pointers. Just like in the move constructor, we do not want source
to have pointer to our data, so we set both it’s internal pointers to nullptr
. Again, as moving does not increase the number of references, we do not have to increment the reference count.
An important thing to note, is that, when we move out of a shared_ptr
it is not left in a good state. It’s pointers are both set to nullptr
. If we try to access them we will get undefined behaviour. This is what we would expect, when we move, we are transferring ownership, so we don’t expect the source to be usable anymore.
Finally, let’s look at our destructor:
~shared_ptr()
{
if(reference_count)
{
on_reference_deletion();
}
}
Here, we hare destroying a shared_ptr
, so we call on_reference_deletion
. However, it is possible that this a shared_ptr
that was moved. So first we check to see that the reference_count
is a valid pointer. If it is not, we don’t need to do anything.
The great thing about shared_ptr
is that we can keep multiple pointers to the same underlying data, and pass them around without having to worry about freeing that memory. It’s almost like a using a language with a garbage collector, like C# or Java. Indeed, shared pointers are a little bit better than garbage collection. In C# or Java, we know only that the garbage collector will reclaim unused memory at some point after the last reference goes out of scope. However, with shared pointers, the memory is reclaimed exactly when the last reference goes out of scope! Which is much more efficient. The one downside compared to garbage collection is that shared_ptr
cannot handle circular references. But, we can just be very careful and not create any of them.