Custom Deleter for C++ Smart Pointers

Introduction

We sometimes see custom deleters used for C++ smart pointers. Previously, I have not thought too much about why and when we have to use custom deleters, and what the efficient way to include custom deleters is.

In this blog post, I would like to discuss the efficient usages and motivations of custom deleters for C++ std::unique_ptr and std::shared_ptr.

Custom Deleter

Example

smart_ptr_custom_deleter.cpp
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <cstdio>
#include <functional>
#include <memory>
#include <iostream>

// Helper function for the custom deleter for C read/write files.
// Remember, C does not have constructor and destructor.
// Custom objects will need custom initialization and free functions,
// especially when memory is allocated on heap.
// In modern C++, we read/write files using fstream
// https://en.cppreference.com/w/cpp/header/fstream
// std::fstream has a destructor and there is no need to use custom deleter.
void close_file(std::FILE* fp) { std::fclose(fp); }

// Helper functor for the custom deleter for C read/write files.
struct FileCloser
{
void operator()(std::FILE* fp) const { std::fclose(fp); }
};

// A C++ class.
struct Foo
{
Foo() {}
~Foo() {}
};

// The customer deleter is often not meaningful for C++ objects
// as C++ objects has their own destructors.
struct FooDeleter
{
void operator()(Foo* p) const { delete p; }
};

int main()
{

FileCloser fcloser;
std::FILE* ptr_file = std::fopen("file.txt", "w");

// std::unique_ptr requires deleter as part of the type.
// std::unique_ptr can be a zero-overhead abstraction with some optimization
// compared with raw pointer, provided that the deleter is stateless.
// Templated class.
// template<class T, class Deleter> std::unique_ptr
// // Function pointer
std::unique_ptr<std::FILE, decltype(&close_file)> unique_ptr_file(
ptr_file, &close_file);
// // Functor
// std::unique_ptr<std::FILE, FileCloser> unique_ptr_file(ptr_file,
// fcloser);
// // Lambda expression.
// std::unique_ptr<std::FILE, std::function<void(std::FILE*)>>
// unique_ptr_file(ptr_file, [](std::FILE* fp) { std::fclose(fp); });

if (unique_ptr_file)
{
std::fputs("fopen example", unique_ptr_file.get());
}

// No need to call std::fclose.

// Templated constructor.
// template<class Y, class Deleter> shared_ptr(Y *ptr, Deleter d)
// The use of customer deleter for Foo is not meaningful here,
// as Foo already has a destructor.
std::shared_ptr<Foo> shared_ptr_foo_1(new Foo(), FooDeleter());
// Constructor does not allow new deleter for an existing shared_ptr.
// Can't have two deleters for the same object.
// std::shared_ptr<Foo> shared_ptr_foo_2(shared_ptr_foo_1, FooDeleter());
std::shared_ptr<Foo> shared_ptr_foo_2(shared_ptr_foo_1);

std::cout << sizeof(std::FILE*) << std::endl; // 8 Bytes
std::cout << sizeof(std::unique_ptr<std::FILE>) << std::endl; // 8 Bytes
std::cout << sizeof(std::unique_ptr<std::FILE, FileCloser>) << std::endl; // 8 Bytes
std::cout << sizeof(std::unique_ptr<std::FILE, decltype(&close_file)>) << std::endl; // 16 Bytes
std::cout << sizeof(std::unique_ptr<std::FILE, std::function<void(std::FILE*)>>) << std::endl; // 40 Bytes
std::cout << sizeof(std::shared_ptr<Foo>) << std::endl; // 16 Bytes

return 0;
}

Execution and Memory Leak Check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ g++ smart_ptr_custom_deleter.cpp -o smart_ptr_custom_deleter
$ valgrind --leak-check=full ./smart_ptr_custom_deleter
==35880== Memcheck, a memory error detector
==35880== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==35880== Using Valgrind-3.15.0 and LibVEX; rerun with -h for copyright info
==35880== Command: ./smart_ptr_custom_deleter
==35880==
8
8
8
16
40
16
==35880==
==35880== HEAP SUMMARY:
==35880== in use at exit: 0 bytes in 0 blocks
==35880== total heap usage: 6 allocs, 6 frees, 78,321 bytes allocated
==35880==
==35880== All heap blocks were freed -- no leaks are possible
==35880==
==35880== For lists of detected and suppressed errors, rerun with: -s
==35880== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Discussions

When do we need custom deleters for smart pointers?

Usually it is when we need to use the objects from some C libraries for our application. Objects from C library do not have destructor and usually requires specific resource free functions to free the memory allocated on heap. If all the objects are from C++ libraries and have well-defined destructor, we can just create std::unique_ptr and std::shared_ptr without deleters.

Why don’t std::make_unique and std::make_shared accept custom deleters?

std::make_unique and std::make_shared do not accept custom deleters. Their purposes are mostly for replacing the new/delete behaviors for C++ objects. If we ever have to use custom deleters, we use std::unique_ptr and std::shared_ptr instead.

Why std::unique_ptr carries deleter type as its part of type whereas std::shared_ptr does not?

Most likely it is because of the performance. std::shared_ptr always carries control block to track the object sharing status and is thus less efficient. Creating a copy of an additional custom deleter will not likely affect its size and performance significantly. std::unique_ptr, however, is designed for more efficient purposes and tries to be as efficient as the raw pointer. With a stateless deleter and possibly compile-time empty base optimization, the deleter code could be inlined and there is no additional memory required to store the deleter, therefore maintaining the performance of std::unique_ptr. For example, std::FILE*, std::unique_ptr<std::FILE>, and std::unique_ptr<std::FILE, FileCloser> are all 8 Bytes. So using stateless functor deleter for std::unique_ptr is as efficient as the raw pointer. However, if the optimization fails to be applied, the size of std::unique_ptr could go arbitrarily large as the deleter could have state and go arbitrarily large. std::shared_ptr data structure only has the raw pointer and a pointer to the control block, so its size is always fixed and usually twice as large as std::unique_ptr.

What is the control block for std::shared_ptr?

The control block is a dynamically-allocated object that holds:

  • either a pointer to the managed object or the managed object itself;
  • the deleter (type-erased);
  • the allocator (type-erased);
  • the number of std::shared_ptrs that own the managed object;
  • the number of std::weak_ptrs that refer to the managed object.

The reference counts update uses atomic instructions which brings some overhead when std::shared_ptr gets copied. However, the dereferencing cost are exactly the same as the raw pointer.

References

Author

Lei Mao

Posted on

07-27-2021

Updated on

02-27-2022

Licensed under


Comments