Logo

Blog


Understanding the inner workings of C++ smart pointers - The unique_ptr with custom deleter

Let's continue where I left off last time. You've seen a simple implementation of a unique_ptr in Understanding the inner workings of C++ smart pointers - The unique_ptr. Now, let's improve that model by adding a custom deleter as the Standard Library does.

Applications of a custom deleter

Let's first establish why somebody would want a custom deleter.

One example is that the object was allocated via a local heap, and such must be returned by calling the corresponding deallocation function.

Another example is fopen. This function returns a FILE* object that you are supposed to delete by calling fclose. A classic job for a unique pointer. But you cannot call delete on the FILE pointer.

Here are two examples of using a unique_ptr with a custom deleter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
void MyDeleter(Object* ptr)
{
  delete ptr;
}

unique_ptr<Object> alfred{new Object{}};
static_assert(sizeof(alfred) == sizeof(void*));

unique_ptr<Object, decltype(MyDeleter)> robin{new Object{}, &MyDeleter};
static_assert(sizeof(robin) == sizeof(void*) * 2);

Oh yes, the first object, alfred, doesn't provide a custom deleter. Only robin does. Behind the curtains, both do. Let's look at a modified unique_ptr implementation that handles the custom deleter case.

unique_ptr implementation with custom deleter support

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
template<typename T>
struct DefaultDeleter {
  void operator()(T* ptr) { delete ptr; }
};

template<typename T,
         A 
         typename Deleter = DefaultDeleter<T>>
class unique_ptr {
  compressed_pair<T, Deleter> mPtr{};  B 

public:
  unique_ptr() = default;

  C 
  unique_ptr(T* ptr)
  : mPtr{ptr}
  {}

  D 
  unique_ptr(T* ptr, Deleter* del)
  : mPtr{ptr, del}
  {}

  unique_ptr(const unique_ptr&)           = delete;
  unique_ptr operator=(const unique_ptr&) = delete;

  E 
  ~unique_ptr()
  {
    static_assert(sizeof(T) >= 0, "cannot delete an incomplete type");
    mPtr.second()(mPtr.first());
  }

  T* operator->() { return mPtr.first(); }
};

The major difference to the previous implementation I presented you in Understanding the inner workings of C++ smart pointers - The unique_ptr is that this unique_ptr takes a second defaulted template parameter A.

The default is DefaultDeleter which in its call operator calls delete. If you do not provide a second argument to a unique_ptr, then the STL creates a default deleter for you, typed with the unique_ptrs pointer type.

As a result of the default deleter, the unique_ptr no longer stores the plain pointer. Instead, in B, you can see a new type, compressed_pair.

In C, you can see the constructor from the previous version, which now invokes the constructor of compressed_pair. But there is a second constructor D, which takes a data pointer and a pointer to a deleter. Subsequently, this constructor also invokes the constructor of compressed_pair.

Influenced by this change, the destructor isn't invoked directly any longer. Instead, the destructor calls second() on the compressed pair, passing first() as a parameter. Remember the DefaultDeleter? The implementation assumes that a deleter is a function taking the data pointer as a parameter.

I omitted the move operations deliberately.

unique_ptrs trick: compressed_pair

How does the compressed_pair implementation look like? Here is one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
F 
template<typename T,
         typename Del,
         bool hasEmptyBase =
           std::is_empty_v<Del> and not std::is_final_v<Del>>
struct compressed_pair {
  T*   data{};
  Del* deleter{};

  compressed_pair() = default;

  compressed_pair(T* ptr, Del* del)
  : data{ptr}
  , deleter{del}
  {}

  T*   first() { return data; }
  Del& second() { return *deleter; }
};

G 
template<typename T, typename Del>
struct compressed_pair<T, Del, true> : public Del {  H 
  T* data{};

  compressed_pair() = default;

  compressed_pair(T* ptr)
  : data{ptr}
  {}

  T*   first() { return data; }
  Del& second() { return *this; }
};

compressed_pair is a class template with a specialization for the case when the custom deleter is a class without any data member, an empty class. The primary template F isn't that interesting. You have a class with two data members. The data pointer and a pointer to the deleter. While first() returns the data pointer, second() returns the deleter.

Book an in-house C++ training class

Do you like this content?

I'm available for in-house C++ training classes worldwide, on-site or remote. Here is a sample list of my classes:
  • From C to C++
  • Programming with C++11 to C++17
  • Programming with C++20
All classes can be customized to your team's needs. Training services

More interesting is the specialization G. There is a general optimization in C++ called the empty base class optimization (EBO). That optimization can be applied if a class derives from an empty base class. In that case, the compiler does not need to reserve space for the base class. This is exactly what compressed_pair does. In H, you can see that the specialization derives from the deleter. Consequently, the specialization of compressed_pair only has one data member, the data pointer. If you invoked second(), you get a pointer to this.

With this clever trick, the unique_ptr aims to require the least amount of storage space. The default deleter above uses exactly this trick. At the same time, you pay more in the case of MyDeleter because the compiler stores a pointer to the function.

More to come

Next time, I will show you the internals of a shared_ptr, including the control block and make_shared.

Andreas