Logo

Blog


Why you shouldn't provide an empty destructor

Today's post is a written version of C++ Insights episode 38 I published back in May. I decided to write this post to be able to use what I teach you in next month's post. In case you prefer the video here, it is:

The scenario

I see code like the below way too often in code reviews for my taste.

1
2
3
4
class Apple {
public:
  ~Apple() {}  A destructor
};

What do I dislike? Well, the destructor which doesn't do anything, or does it? I get different answers when I point out such a construct during a review. One that frequently comes up is that people prefer seeing that this class has a destructor. When I ask why, not just use =default, things get interesting. There is one group that simply says old habits I forgot. Another group tells me that =default doesn't make a difference to the code presented above. Let's explore what's happening.

Yes, there are scenarios where you might not notice any difference between {} and =default for a destructor. But technically, there is one, and you can observe this difference.

Suppose the class Apple from above is used with a std::optional like this:

1
std::optional<Apple> a{};

Not that bad, right? Well, how about we briefly examine the resulting assembly code? (I know I created C++ Insights to avoid looking at assembly code, but this time there is no way around it :-). Here is the output from Compiler Explorer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
main:                                   # @main
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rdi, [rbp - 8]
        call    std::optional<Apple>::optional() [base object constructor]
        lea     rdi, [rbp - 8]
        call    std::optional<Apple>::~optional() [base object destructor]
        xor     eax, eax
        add     rsp, 16
        pop     rbp
        ret
Apple::~Apple() [base object destructor]:                          # @Apple::~Apple() [base object destructor]
        push    rbp
        mov     rbp, rsp
        mov     qword ptr [rbp - 8], rdi
        pop     rbp
        ret

As you can see, there is a destructor Apple::~Apple present in the assembly code. Further, you can spot two calls to that destructor, which is doing absolutely nothing. At least nothing we need.

User-provided vs. user-declared

All the above boils down to the difference between user-provided and user-declared. In the first example, the destructor of Apple counts as user-defined. While technically, we look at a declaration and definition, the definition part is the one that makes the difference. We tell the compiler that the implementation, which is none here, comes from us. As a result, the class no longer counts as trivially destructible.

For the optional example, the library provides a perfect wrapper around your type. Since Apple is not trivially destructible, the optional creates code to invoke the destructor of Apple. While there might be scenarios when the compiler and optimizer can see through that, we are entering optimization land without any guarantees.

Now, what is user-declared then? We can get user-declared by using =default for the destructor like this

1
2
3
4
5
6
class Apple {
public:
  ~Apple() = default;
};

std::optional<Apple> a{};

That way, the class still counts as trivially destructible, assuming all members are also trivially destructible. The resulting assembly output changes since nothing has to be done for a trivially destructible object. Here is the output again from Compiler Explorer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
main:                                   # @main
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        lea     rdi, [rbp - 8]
        call    std::optional<main::Apple>::optional() [base object constructor]
        xor     eax, eax
        add     rsp, 16
        pop     rbp
        ret

Et voilà, no code or calls for a destructor anymore.

Conclusion

Another reason to be cautious with the destructors is when you want your class to be moveable. See my post A destructor, =default, and the move operations for further insights.

In case there is no good reason, prefer leaving the special members untouched. Especially the destructor. If you really must provide an empty destructor, use =default.

A good reason for providing a destructor is if the destructor needs to be virtual. In the case of ~Apple:

1
virtual ~Apple() = default;

I know one more good reason for =default, but more about next month.

Andreas