You don’t always need a virtual desctructor

Let’s talk inheritance

Lets start off by doing some exercises with inheritance. I’ve written up 3 classes - a base, a derived, and a super. They inherit each other in the listed order. Lets take a look at them, shall we?

#include <iostream>
#include <memory>

struct Base
{
    Base() {
        std::cout << "--------------------" << std::endl;
        std::cout << "ctor base" << std::endl;
    }
    ~Base() {
        std::cout << "~dtor base" << std::endl;
    }
};

struct Derived : Base
{
    Derived() { std::cout << "\t ctor derived" << std::endl; }
    ~Derived() { std::cout << "\t ~dtor derived" << std::endl; }
};

struct Super : Derived
{
    Super() { std::cout << "\t\t ctor super" << std::endl; }
    ~Super() { std::cout << "\t\t ~dtor super" << std::endl; }
};

All i’ve done so far is create the 3 classes and add some logging so that we can tell when we enter the constructor and when we enter the destructor. Before we go any further, lets make sure we are on the same page in terms of how C++ constructors and destructors objects when there is multiple levels of inheritance available:

{
    Super super{};
}
--------------------
ctor base
	 ctor derived
		 ctor super
		 ~dtor super
	 ~dtor derived
~dtor base

As you can see, we construct the lowest level first (Base) and continue until we reach the highest level Super and then when we destruct, we do it in the opposite order. Part of the reason this happens this way is so that we can construct and allocate the objects in the right dependency order, but we can discuss that at a later time.

Now that that’s out of the way, lets try an example that’s not so simple:

{
    // Lets start by creating an instance of super, but capturing it as a base pointer
    Base* base = new Super{};
    delete base;
}
--------------------
ctor base
	 ctor derived
		 ctor super
~dtor base

You should immediately notice that the output doesn’t look like the same as what we saw earlier. What happened to:

		 ~dtor super
	 ~dtor derived

We didn’t clean up any of the derivations! This happens to be the unfortunate case because the type Base doesn’t really know how to clean up the upper levels. How does Base know if it of type Derived or of type Super or god forbid, something else?

So we have a problem. But what’s the normal solution? Well, it turns out that C++ provides us something called a virtual destructor.

###saying Virtual Destructors It looks just like a regular destructor, but happens to include the virtual keyword. It looks something like:

struct SomeClass
{
    // ... stuff ...
    virtual ~SomeClass();
};

If you try googling “When to use virtual desctructor in C++”, you’ll get a lot of similar answers. Most likely the first you click will take you to this answer: https://stackoverflow.com/a/461224

Here is a quick blurb of the relevant bits:

alt text

Cool, so its basically saying we can fix our problem by changing our Base class from:

struct Base
{
    Base();
    ~Base();
};

to something more like:

struct Base
{
    Base();
    virtual ~Base();
};

So lets quickly sanity check that:

struct SafeBase
{
    SafeBase() {
        std::cout << "--------------------" << std::endl;
        std::cout << "ctor safe base" << std::endl;
    }
    virtual ~SafeBase() {
        std::cout << "~dtor safe base" << std::endl;
    }
};

struct SafeDerived : SafeBase
{
    SafeDerived() { std::cout << "\t ctor safe derived" << std::endl; }
    ~SafeDerived() { std::cout << "\t ~dtor safe derived" << std::endl; }
};

struct SafeSuper : SafeDerived
{
    SafeSuper() { std::cout << "\t\t ctor safe super" << std::endl; }
    ~SafeSuper() { std::cout << "\t\t ~dtor safe super" << std::endl; }
};

{
    SafeSuper super{};
}

{
    SafeBase* base = new SafeSuper();
    delete base;
}
--------------------
ctor safe base
	 ctor safe derived
		 ctor safe super
		 ~dtor safe super
	 ~dtor safe derived
~dtor safe base
--------------------
ctor safe base
	 ctor safe derived
		 ctor safe super
		 ~dtor safe super
	 ~dtor safe derived
~dtor safe base

Ay! Would you look at that. Just like the stackoverflow foretold. The prophacy holds! virtual ~Base does the trick!

Lets make sure it works with our smart pointers too:

{
    std::unique_ptr<Base> base = std::unique_ptr<Base>(new Super{});
}

{
    std::unique_ptr<SafeBase> base = std::unique_ptr<SafeBase>(new SafeSuper{});
}
--------------------
ctor base
	 ctor derived
		 ctor super
~dtor base
--------------------
ctor safe base
	 ctor safe derived
		 ctor safe super
		 ~dtor safe super
	 ~dtor safe derived
~dtor safe base

Cool. That also checks out when we use std::unique_ptr.

But this would be a pretty lame section if we just stopped there. I am here to tell you that you don’t actually always need it!

But you said we needed a virtual destructor!

Nah. Lets try another example:

{
    std::shared_ptr<Base> base = std::make_shared<Super>();
}
--------------------
ctor base
	 ctor derived
		 ctor super
		 ~dtor super
	 ~dtor derived
~dtor base

Woah, wait what?!

We are using the old one. The one without the virtual destructor. We just saw that it doesn’t work. But why does it work if we use a shared_ptr?

It turns out that one of the lesser known benefits of std::shared_ptr is that it can remember your destructors. Not only that, it even lets you specify your own!

Your own, you say?

// Yup, we can actually just write a function that will be our custom destructor
void MyCustomDestructor(Super* s)
{
    std::cout << "Hijacking the destructor without the class knowing!" << std::endl;
    delete s;
}

If you didn’t know you could do this, well, now you do. This can be helpful when you want to take over certain bits before you clean up, maybe do some logging, some diagnostics, etc.

Once we have our custom destructor, we can just use it by doing:

{
    std::shared_ptr<Base> base = std::shared_ptr<Base>(new Super{}, MyCustomDestructor);
}
--------------------
ctor base
	 ctor derived
		 ctor super
Hijacking the destructor without the class knowing!
		 ~dtor super
	 ~dtor derived
~dtor base

Isn’t that neat? So turns out we don’t need a virtual destructor if we just use a std::shared_ptr instead. With that said, std::shared_ptrs can be overkill in most cases, so I generally try to avoid them as much as possible.

You might be wondering at this point: “What else can a shared pointer do? Why does this work? What’s it look like under the sheets?”

Well, head over to the next section to find out more!