0

C++ unique/shared ptr custom deleters

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.