So in the previous article, I covered a basic unique
pointer where the smart pointer retained sole ownership of the pointer. The shared
pointer (SP) is the other common smart pointer we encounter. In this case, the ownership of the pointer is shared across multiple instances of SP, and the pointer is only released (deleted) when all SP instances have been destroyed.
So, not only do we have to store the pointer, but we also need a mechanism to keep track of all the SP instances that share ownership of the pointer. When the last SP instance is destroyed, it also deletes the pointer (The last owner cleans up., A similar principle to the last one to leave the room turns out the lights).
Shared Pointer contextual destructor
namespace ThorsAnvil
{
template<typename T>
class SP
{
T* data;
public:
~SP()
{
if (amITheLastOwner())
{
delete data;
}
}
};
}
There are two major techniques for tracking the shared owners of a pointer:
- Keep a count:
- When the count is 1 you are the last owner.
- This is a straightforward and logical technique. You have a shared counter that is incremented/decremented as SP instances take/release ownership of the pointer. The disadvantages are that you need dynamically allocated memory that must be managed, and in a threaded environment, you need to serialize accesses to the counter.
- Use a linked list of the owners:
- When you are the only list member, you are the last owner.
- When an SP instance takes/releases ownership of the pointer, they are added/removed to/from the linked list. This is slightly more complex as you must maintain a circular linked list (for O(1)). The advantage is that you do not need to manage any separate memory for the count (A SP instance points at the next SP instance in the chain) and in a threaded environment adding/removing a shared pointer need not always be serialized (though you will still need to lock your neighbors to enforce integrity).
Shared Count
The list version is easier to implement correctly. There are no real gotchas (that I have seen), though people do struggle with inserting and removing a link from a circular list. I have another article planned for that at some point so that I will cover it then.
The Shared Count is the technique used by the std::shared_ptr
, though the standard version stores slightly more than the count to try to improve efficiency (see std::make_shared
).
The main mistake I see from beginners is not using a dynamically allocated counter (i.e., they keep the counter in the SP object). You must dynamically allocate memory for the counter so that it can be shared by all SP instances (you can not tell how many there will be or the order in which they will be deleted).
You must also serialize access to this counter to ensure the count is correctly maintained in a threaded environment. In the first version, I will only consider single-threaded environments for simplicity, so synchronization is unnecessary.
First Try
namespace ThorsAnvil
{
template<typename T>
class SP
{
T* data;
int* count;
public:
explicit SP(T* data)
: data(data)
, count(new int(1))
{}
~SP()
{
--(*count);
if (*count == 0)
{
delete data;
delete count;
}
}
SP(SP const& copy)
: data(copy.data)
, count(copy.count)
{
++(*count);
}
SP& operator=(SP const& rhs)
{
T* oldData = data;
int* oldCount = count;
data = rhs.data;
count = rhs.count;
++(*count);
--(*oldCount);
if (*oldCount == 0)
{
delete oldData;
delete oldCount;
}
}
T* operator->() const {return data;}
T& operator*() const {return *data;}
T* get() const {return data;}
explicit operator bool() const {return data;}
};
}
Problem 1: Potential Constructor Failure
When a developer (attempts) to create an SP, they are handing over ownership of the pointer to the SP instance. Once the constructor starts, there is an expectation by the developer that no further checks are needed. But there is a problem with the code as written.
In C++, memory allocation through new does not fail (unlike C where malloc()
can return a Null on failure). In C++, a failure to allocate memory via the standard new generates a std::bad_alloc
exception. Additionally, if we throw an exception out of a constructor, the destructor will never be called (the destructor is only called on fully formed objects when the instance's lifespan ends).
So, if an exception is thrown during construction (and thus the destructor will not be called), we must assume responsibility for ensuring that the pointer is deleted before the exception escapes the constructor. Otherwise, the data pointer will be leaked.
Constructor takes responsibility for pointer
namespace ThorsAnvil
{
.....
explicit SP(T* data)
: data(data)
, count(new (std::nothorw) int(1))
{
if (count == nullptr)
{
delete data;
throw std::bad_alloc();
}
}
.....
explicit SP(T* data)
try
: data(data)
, count(new int(1))
{}
catch(...)
{
delete data;
throw;
}
}
Problem 2: DRY up the Assignment
Currently, the assignment operator is exception-safe and conforms to the strong exception guarantee, so there is no real problem here. But there seems to be a lot of duplicated code in the class.
Closer look at assignment
namespace ThorsAnvil
{
.....
SP& operator=(SP const& rhs)
{
T* oldData = data;
int* oldCount = count;
data = rhs.data;
count = rhs.count;
++(*count);
--(*oldCount);
if (*oldCount == 0)
{
delete oldData;
delete oldCount;
}
}
}
Two portions of this look like other pieces of code that have already been written:
data = rhs.data;
count = rhs.count;
++(*count);
--(*oldCount);
if (*oldCount == 0)
{
delete oldData;
delete oldCount;
}
This observation is commonly referred to as the Copy and Swap Idiom. I will not go through all the details of the transformation here. But we can re-write the assignment operator as:
Copy and Swap Idiom
SP& operator=(SP const& rhs)
{
SP tmp(rhs);
std::swap(data, tmp.data);
std::swap(count, tmp.count);
return *this;
}
SP& operator=(SP rhs)
{
rhs.swap(*this);
return *this;
}
Fixed First Try
So, given the problems described above, we can update our implementation to compensate for these issues:
Fixed First Try
namespace ThorsAnvil
{
template<typename T>
class SP
{
T* data;
int* count;
public:
explicit SP(T* data)
try
: data(data)
, count(new int(1))
{}
catch(...)
{
delete data;
throw;
}
~SP()
{
--(*count);
if (*count == 0)
{
delete data;
delete count;
}
}
SP(SP const& copy)
: data(copy.data)
, count(copy.count)
{
++(*count);
}
SP& operator=(SP rhs)
{
rhs.swap(*this);
return *this;
}
SP& operator=(T* newData)
{
SP tmp(newData);
tmp.swap(*this);
return *this;
}
void swap(SP& other) noexcept
{
std::swap(data, other.data);
std::swap(count, other.count);
}
T* operator->() const {return data;}
T& operator*() const {return *data;}
T* get() const {return data;}
explicit operator bool() const {return data;}
};
}
Summary
So, in this second post, we have looked at SP and mentioned the two main implementation techniques commonly used. We specifically looked in detail at some common problems usually overlooked in the counted implementation of SP. In the following article, I want to examine a couple of other issues common to both types of smart pointers.