I’ve recently found the need for a custom deleter function attached to smart pointers in C++ and I found there are a number of different ways to implement this.
I’ll touch on the advantages/disadvantages of the different ways to use custom deleters and the difference between unique and shared ptr.
std::unique_ptr:
The type of the deleter is defined as part of the unique_ptr and you define it as the second template parameter, then when constructing the type, you must pass through the type specified as a parameter.
Instead of std::unique_ptr calling ‘delete’ and invoking the destructor on the internally stored pointer, it will instead pass the pointer to the function provided.
The deleter must by a type which implements a operator() which takes a T* as a parameter.
So in the example below, we specify that the deleter is std::function<void(MyClass*)>. Then on construction, we pass through the function we want to call.
class MyClass
{
public:
~MyClass() { }
};
using DefaultPtr = std::unique_ptr<MyClass>;
DefaultPtr pDefault; //Will call MyClass::~MyClass()
using CustomDeletePtr = std::unique_ptr<MyClass, std::function<void(MyClass*)>>;
CustomDeletePtr pCustom; //Will call function passed through on construction
static void CustomDeleter(MyClass* pMyClass)
{
//This will be called instead of delete and invoking the constructor.
}
void InitPointers()
{
pDefault = DefaultPtr(new MyClass());
pCustom = CustomDeletePtr(new MyClass(), &CustomDeleter);
}
std::unique_ptr – cheapest implementations:
The custom deleter type you specify will affect the size of your std::unique_ptr. The sizes below are from C++ VS2019 64bit.
- Default: std::unique_ptr<MyClass> (no custom deleter) – 8 bytes.
- 1: std::unique_ptr<MyClass, CustomDeleteClass> – 8 bytes.
- 2 std::unique_ptr<MyClass, decltype(lambda)> – 8 bytes.
- 2: std::unique_ptr<MyClass, void(*)(MyClass*)> – 16 bytes.
- 3: std::unique_ptr<MyClass, std::function<void(MyClass*)> – 72 bytes.
Lets talk about the sizes above. std::unique_ptr by default is a really simple wrapper for a pointer and it only has to store the 8 byte pointer.
Adding custom deleters can increase the size of std::unique_ptr because it has to store the type specified. std::function really bloats out the size of the std::unique_ptr because of the flexibility and features it provides.
Providing a C Style function as the deleter void(*)(MyClass*) increases the size to 16 bytes. This is because it needs to store a pointer to the function specified. Its increase the size of the function but offers good flexibility because you can provide any function you want when constructing.
Using a custom deleter class with an operator() or a lambda incurs no additional storage overhead because the custom deleter function is always the same for all objects created and the DeleterClass contains no member variables.
class DeleterClass
{
public:
void operator()(MyClass* pMyClass)
{
}
};
using CustomDeleteOperatorPtr = std::unique_ptr<MyClass, DeleterClass>;
CustomDeleteOperatorPtr MyCustomPtr(new MyClass());
auto lambda = [](MyClass* p) { delete p; };
std::unique_ptr<MyClass, decltype(lambda)> PointerWithLambda(new MyClass(), lambda);
One bonus when using the operator() over a lambda is that you don’t even have to specify it when constructing new instances of the object as you can see above, assuming the class can be default constructed.
std::shared_ptr
std::shared_ptr requires some different syntax compared to std::unique_ptr. Behind the scenes, std::shared_ptr allocates a ‘control block’ containing the atomic reference counter and other data such as the custom deleter. The image below from Oreilly.com shows it nicely:
When using a custom deleter with shared_ptr we don’t pass any additional template arguments, our custom deleter is passed when creating the object and it exists within the control block.
class MyClass
{
public:
~MyClass() { }
};
static void CustomDeleter(MyClass* pMyClass)
{
delete pMyClass;
}
int main()
{
//1 - Function
std::shared_ptr<MyClass> s1(new MyClass(), CustomDeleter);
//2 - Lambda
std::shared_ptr<MyClass> s2(new MyClass(), [](MyClass* pMyClass) { delete pMyClass; });
//3 - Custom Class
class DeleterClass
{
public:
DeleterClass()
{
}
void operator()(MyClass* pMyClass)
{
delete pMyClass;
}
};
DeleterClass d; //Must be CopyConstructible.
std::shared_ptr<MyClass> s3(new MyClass(), d);
}
The size of the shared_ptr remains 16 bytes on a 64 bit compiler. One pointer to the object and another pointer to the control block. Depending on the implementation, the size of the control block could change if a custom deleter is or isn’t allocated.
On MSVC 2019, using std::shared_ptr by default, the control block size is 24 bytes. The size of the control block can increase depending on the type of deleter used. Using a lambda or class with an operator() incurs no additonal storage overhead. Using a function pointer will increment the size by the cost of a pointer and using more complex types such as std::function will increase storage further, as it does with std::unique_ptr.
Note: Unfortunately you cannot use a custom deleter with std::make_shared (nor with std::make_unique). Something else interesting to know about std::shared_ptr is that it’s more optimal to use std::make_shared() rather than std::shared_ptr<T>(new T()) because internally the implementation needs to allocate a control block so if you provide your own pointer, it has to call ‘new’ to allocate its own control block. If you called make_shared(), it will allocate the memory for your data and the control block in the same allocation!
Closing thoughts
When wanting to use a custom deleter with a std::unique_ptr and you want it to be the same function for all objects, use a custom class with operator() as this incurs the least overhead, can be inlined at runtime and doesn’t increase the size of unique_ptr.
If you’re wanting to call a different function for each of your unique_ptrs, opt for a straight up function pointer/ lambda. The only additional overhead is the cost of storing the function pointer.
For std::shared_ptr, the storage overhead of the deleter is stored in the control block only once instead of for every object like with std::unique_ptr, so it’s cheaper to use a complex delete type with larger storage requirements.