A virtual destructor in C++, when?

In today's post, I would like to explain a design rationale used in my post Understanding the inner workings of C++ smart pointers - The shared_ptr.

Keen readers spotted that in my implementation of ctrl_blk_base, I didn't make the destructor virtual. Here is the original code for easy reference:

 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
struct ctrl_blk_base {
  std::atomic_uint64_t shared_ref_count_{1};

  void add_shared() { ++shared_ref_count_; }
  auto dec() { return --shared_ref_count_; }

  virtual void release_shared() = 0;
};

template<typename T>
struct ctrl_blk : ctrl_blk_base {
  T* data_;

  explicit ctrl_blk(T* data)
  : ctrl_blk_base{}
  , data_{data}
  {}

  void release_shared() override
  {
    if(0 == dec()) {
      delete data_;
      delete this;  // self delete
    }
  }
};

template<typename T>
struct ctrl_blk_with_storage : ctrl_blk_base {
  T in_place_;

  template<typename... Args>
  explicit ctrl_blk_with_storage(Args&&... vals)
  : ctrl_blk_base{}
  , in_place_{std::forward<Args>(vals)...}
  {}

  T* get() { return &in_place_; }

  void release_shared() override
  {
    if(0 == dec()) {
      delete this;  // self delete
    }
  }
};

You can see that there are two derived classes, ctrl_blk and ctrl_blk_with_storage, and we often got trained to make a destructor virtual in a base class. For example, by Scott Meyers in Effective C++ Item 14: Make sure base classes have virtual destructors.

I think this is a good rule, but it is not perfect. A better wording is: Make sure base classes have virtual destructors if you want to support polymorphic delete.

A use-case for a polymorphic delete

Let me change the example to the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class IOPort {
public:
  IOPort() { puts("IOPort ctor"); }
  ~IOPort() { puts("IOPort dtor"); }

  virtual void Flush() { puts("IOPort Flush"); }
};

class USBC : public IOPort {
public:
  USBC() { puts("USBC ctor"); }
  ~USBC() { puts("USBC dtor"); }

  void Flush() override { puts("USBC Flush"); }
};

Above, I present a base class, IOPort, and a single derived class, USBC, as one example of a concrete port implementation. Further, IOPort has a virtual member function with Flush. But in this initial example, the destructor in IOPort isn't virtual.

The correct, or better best, decision for or against a virtual destructor is looking at the use-case IOPort should support.

Here is one possible use case:

1
2
3
4
A Note, I'm creating a USBC object and store it as IOPort
std::unique_ptr<IOPort> port{std::make_unique<USBC>()};

port->Flush();

This is a very typical one, where we create a USBC object but only store a pointer of the base class, sometimes a reference. In this case, the unique_ptr will invoke the destructor of IOPort upon destruction. Now, suppose USBC contains data members with destructors. They will never get called. You address this use-case by making the destructor virtual.

A use-case for a non-virtual destructor

Yet, there is one other use case:

1
2
3
4
5
B Note, I'm creating a USBC object and store it as such
std::shared_ptr<USBC> port{std::make_shared<USBC>()};

C Exemplary using functions
Receive(port);

This time, I'm creating a USBC object and storing it as such. Whether you now call functions expecting a USBC or IOPort object doesn't matter. I assume that the shared_ptr deletes the object in the end. And it does so by using the destructor of USBC. In this scenario, both IOPort and USBC can have data members with a destructor, and they will get called.

While my first example uses the powers of polymorphic delete, the second doesn't. The issue now is that all your colleagues have to understand this part and follow this design. Nobody can ever successfully use the destructor of IOPort in the second example.

Well, the exact code I present here would work. Consider this a third use case. If your derived class has no data members with destructors, even with a non-virtual destructor, you're safe.

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

Back to the ctrl_blk_base. Remember, I'm no STL developer. I can't tell you precisely why this decision was made as is. Here is my rationale. Inside the STL, ctrl_blk_base only is used for shared_ptr. This is a very limited use case. In this specific use case, polymorphic destruction is never required. In fact, it would not work. If you now start, just for a possible event, to add a virtual destructor that would impose a second entry in the virtual function table (vtable) for this class. This then comes with some extra costs for each required control block. For a case, we know precisely that it never happens.

The control block example is great for today's topic because it illustrates perfectly a use-case where you never want a virtual destructor. Why? Because multiple shared_ptrs reference a control block. The control block itself handles when it gets deleted. The only way to interact from the outside is release_shared.

Another example of inheritance without a virtual destructor are type traits. If you look at is_pointer, for example. The type trait uses std::true_type or std::false_type as a base class. Yet none of them declares a virtual destructor. First, they aren't expected to be used in a polymorphic way, and second, they don't have data members with destructors.

Summary

Getting the destructor right in C++ isn't easy. You can go the safe and simple way and make the destructor virtual if there is at least one virtual member function.

If you're striving for controlled performance and good modeling of your data type, you should take a closer look at the use cases it should support.

Andreas