What Is a Virtual Destructor?

A virtual destructor is a base class destructor declared with the virtual keyword. It makes delete on a base class pointer run the destructor that belongs to the object actually sitting in memory, rather than the one that belongs to the pointer's declared type.

One keyword separates a clean teardown from undefined behavior. This lesson starts with the destruction machinery, shows exactly where a non-virtual destructor breaks it, then answers the three questions that follow: which classes deserve a virtual destructor, how to stop a class being inherited from at all, and how to deliberately call one specific version of a virtual function.

Destruction already runs as a chain

Destroying a derived object is never a single destructor call. The compiler walks the inheritance chain from the most derived class upwards, running each destructor body in turn, which is the exact reverse of the order construction used.

#include <iostream>

class Cue
{
public:
    Cue() { std::cout << "Cue armed" << '\n'; }
    ~Cue() { std::cout << "Cue cleared" << '\n'; }
};

class ShellCue : public Cue
{
public:
    ShellCue() { std::cout << "ShellCue armed" << '\n'; }
    ~ShellCue() { std::cout << "ShellCue cleared" << '\n'; }
};

int main()
{
    ShellCue burst{};

    return 0;
}

Output:

Cue armed
ShellCue armed
ShellCue cleared
Cue cleared

Nothing here is virtual, and the chain still ran correctly in both directions. That is worth holding on to, because it tells you the chain is not what breaks later. Once the compiler has entered ~ShellCue, the rest follows automatically. The whole problem is about which destructor the chain starts at.

Who picks the starting destructor

When burst goes out of scope above, the compiler knows its complete type from the declaration, so it starts at ~ShellCue. Writing delete on a pointer is a different situation, because two types are now in play:

What it is Known when
Static type of the pointer The type you wrote in the declaration, such as Cue* Compile time
Dynamic type of the object The type the object was actually created as, such as ShellCue Run time

delete slot has to choose a destructor from one of those two. A non-virtual destructor is an ordinary early bound member function, so the choice comes from the static type: Cue* selects ~Cue, and the chain starts one level too high. ~ShellCue never runs.

The standard does not describe that as a partial cleanup. It describes it as undefined behavior: if the static type of the operand of delete differs from the dynamic type of the object, the static type must have a virtual destructor, or the program has no defined meaning at all.

Danger
Deleting a derived object through a base class pointer whose destructor is not virtual is undefined behavior, not merely a leak. The compiler is free to assume it never happens, so reasoning about "which destructor got skipped" describes only what one implementation happened to do on one day.

The following program is broken. Do not use it as a model.

#include <iostream>
#include <string_view>

class Cue
{
public:
    virtual std::string_view routing() const { return "house feed"; }

    ~Cue() // not virtual
    {
        std::cout << "Cue cleared" << '\n';
    }
};

class ShellCue : public Cue
{
private:
    int* m_delays{};

public:
    explicit ShellCue(int fuseCount)
        : m_delays{ new int[fuseCount]{} }
    {
    }

    std::string_view routing() const override { return "mortar bank"; }

    ~ShellCue()
    {
        std::cout << "ShellCue cleared" << '\n';
        delete[] m_delays;
    }
};

int main()
{
    Cue* slot{ new ShellCue{ 6 } };
    std::cout << slot->routing() << '\n';

    delete slot;

    return 0;
}

No output is shown for that program on purpose, because it has none that can be trusted. What can be reported is the diagnostic, since the platform's compiler recognises the shape of the mistake:

s.cpp: In function 'int main()':
s.cpp:40:5: warning: deleting object of polymorphic class type 'Cue' which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]
   40 |     delete slot;
      |     ^~~~~~~~~~~

In practice, implementations behave the obvious way and call only ~Cue, so ~ShellCue never runs and the array behind m_delays leaks. Treat that as a description of the damage, not as a guarantee.

Warning
That warning appears only because Cue has a virtual function, which is what makes it a polymorphic class. Strip routing() out and the same delete compiles silently with -Wall -Wextra, still undefined. A clean build is not evidence that your destructors are safe.

Turning the destructor into a virtual call

The repair is one keyword on the base class destructor. delete then resolves the destructor the same way any other virtual call resolves, from the dynamic type, so the chain starts at the bottom where it belongs.

#include <iostream>
#include <string_view>

class Cue
{
public:
    virtual std::string_view routing() const { return "house feed"; }

    virtual ~Cue()
    {
        std::cout << "Cue cleared" << '\n';
    }
};

class ShellCue : public Cue
{
private:
    int* m_delays{};

public:
    explicit ShellCue(int fuseCount)
        : m_delays{ new int[fuseCount]{} }
    {
    }

    std::string_view routing() const override { return "mortar bank"; }

    ~ShellCue() override
    {
        std::cout << "ShellCue cleared" << '\n';
        delete[] m_delays;
    }
};

int main()
{
    Cue* slot{ new ShellCue{ 6 } };
    std::cout << slot->routing() << '\n';

    delete slot;

    return 0;
}

Output:

mortar bank
ShellCue cleared
Cue cleared

The program is now well defined, the diagnostic is gone, and m_delays is released. Note that ShellCue did not change its policy at all; only the base class did. Safety here is a property of the base class, decided by whoever writes it, and no amount of care in a derived class can supply it afterwards.

Best Practice
Whenever a class participates in inheritance and declares a destructor of its own, make that destructor virtual in the base class. A base class destructor should be public and virtual.

Virtualness travels down the hierarchy

Destructors follow the same inheritance rule as every other virtual member function: once a base class declares one virtual, every override in every descendant is virtual too, whether or not the keyword is repeated. That applies to destructors even though their names differ at each level.

The program below declares virtual exactly once, at the top, and then deletes a three-level object through a pointer to the middle class.

#include <iostream>

class Cue
{
public:
    virtual ~Cue() { std::cout << "Cue cleared" << '\n'; }
};

class ShellCue : public Cue
{
public:
    ~ShellCue() { std::cout << "ShellCue cleared" << '\n'; } // no virtual keyword
};

class SaluteCue : public ShellCue
{
public:
    ~SaluteCue() { std::cout << "SaluteCue cleared" << '\n'; } // no virtual keyword
};

int main()
{
    ShellCue* slot{ new SaluteCue{} };

    delete slot;

    return 0;
}

Output:

SaluteCue cleared
ShellCue cleared
Cue cleared

~SaluteCue ran even though the pointer's static type was ShellCue*, which proves ~ShellCue was virtual despite never being written that way. Two consequences follow. You never need to add an empty destructor to a derived class purely to mark it virtual, and a class that inherits from a properly written base is safe to delete polymorphically without doing anything.

Tip
A base class often needs a virtual destructor without needing a destructor body. Ask the compiler for one rather than writing empty braces:

virtual ~Cue() = default;

The two keyword omissions above are for demonstration only. In your own code write override on a derived destructor, exactly as you would on any other override, so the compiler checks that a virtual destructor really exists above it.

What the keyword costs

Virtual dispatch is not free. Making any member function virtual, destructor included, gives every object of that class a hidden pointer to its class's dispatch table. The table itself is shared, but the per-object pointer is not, and it is charged to every single instance you ever create.

#include <iostream>

struct PlainTiming
{
    int m_offsetMs{};
};

struct BoundTiming
{
    int m_offsetMs{};

    virtual ~BoundTiming() = default;
};

int main()
{
    std::cout << "PlainTiming: " << sizeof(PlainTiming) << " bytes" << '\n';
    std::cout << "BoundTiming: " << sizeof(BoundTiming) << " bytes" << '\n';

    return 0;
}

Output, from a 64-bit build:

PlainTiming: 4 bytes
BoundTiming: 16 bytes

One int grew to four times its size because of a destructor that does nothing. On a small type held in a container of millions, that is a real cost, so "make everything virtual just in case" is the wrong default. Let intent decide:

The class is Destructor Reason
Designed as a base class, or has any virtual function Public and virtual Somebody will eventually delete it through a base pointer
A concrete value type, never inherited from Non-virtual, and no other virtual members Keeps the object at its natural size
Uncertain, but you want reuse Non-virtual, use composition instead of inheritance Containment gives reuse without a polymorphic teardown

The middle row leaves a gap. If a class is not designed for inheritance, nothing so far actually stops someone deriving from it and then deleting through your pointer type.

Sealing a class instead of guarding its destructor

The traditional way to close that gap was to write a protected non-virtual destructor. That does block deletion through a base pointer, because delete needs access to the destructor and outside code has none. The cure is worse than the disease: the base class can no longer be created as a local variable either, since ordinary scope exit also needs access to the destructor, and it cannot be deleted through its own pointer type. Making derived classes safe by rendering the base class unusable on its own is a poor trade.

The final specifier solves it from the other direction. Instead of restricting how a class may be destroyed, it restricts who may inherit from it, and imposes nothing else at all.

class Countdown final
{
public:
    int m_seconds{};
};

class Rehearsal : public Countdown
{
};

int main()
{
    return 0;
}

That program is rejected before it ever runs:

s.cpp:7:7: error: cannot derive from 'final' base 'Countdown' in derived type 'Rehearsal'

Countdown stays a perfectly normal class. It can be built on the stack, held by value, copied, and destroyed, with no hidden pointer added to it, and the risky hierarchy simply cannot be formed.

Best Practice
Pick one of two clear positions for every class you write. If it is meant to be inherited from, give it a public virtual destructor. If it is not, mark the class final. Reach for a protected non-virtual destructor only when you have a specific reason the two standard positions cannot cover.

Calling a named version on purpose

Virtual dispatch is the behaviour you normally want, but it can be switched off for a single call. Qualifying the function name with a class name using the scope resolution operator turns that call into an ordinary early bound call to the version you named.

#include <iostream>
#include <string_view>

class Cue
{
public:
    virtual std::string_view routing() const { return "house feed"; }

    virtual ~Cue() = default;
};

class ShellCue : public Cue
{
public:
    std::string_view routing() const override { return "mortar bank"; }
};

int main()
{
    ShellCue burst{};
    const Cue& slot{ burst };

    std::cout << slot.routing() << '\n';
    std::cout << slot.Cue::routing() << '\n';

    return 0;
}

Output:

mortar bank
house feed

Both calls run on the same object through the same reference. The unqualified call dispatches to the override, and slot.Cue::routing() names a specific function, so there is nothing left for runtime to decide. You will meet this most often inside a derived override that wants to extend the base version rather than replace it, and only rarely from outside the class.

Leave assignment alone

Assignment operators can be declared virtual. Doing so is legal, and it is still a bad idea for now.

The trouble is that assignment has an argument on the right whose type also varies, so an override has to cope with being handed a base object, a sibling type, or its own type, and decide what each of those means. Nothing in the language resolves that for you, and the workarounds involve techniques well beyond this chapter.

Best Practice
Keep assignment operators non-virtual. Virtualising the destructor is always the right call in a hierarchy; virtualising assignment is not, and the simple choice is the correct one here.

Summary

Destruction is a chain: destroying a derived object runs each destructor in the hierarchy from the most derived class upwards, in the reverse of construction order. The chain itself always works. The only question is which destructor it starts at.

Non-virtual destructors start too high: delete on a pointer with a non-virtual destructor picks the destructor from the pointer's static type. If that differs from the object's dynamic type, the standard says the program has undefined behavior. Implementations typically run only the base class destructor, so the derived destructor never executes and anything it owned is leaked.

The fix belongs to the base class: mark the base class destructor virtual and delete resolves it from the dynamic type instead, starting the chain at the correct level. Whenever you are dealing with inheritance, the base class destructor should be public and virtual.

Derived destructors are automatically virtual: if the base class destructor is virtual, every derived destructor is virtual too, with or without the keyword. There is no reason to add an empty destructor to a derived class merely to mark it virtual. For a base class that needs virtual destruction but no destructor body, write virtual ~ClassName() = default;.

Virtual is not free: any virtual member, including a destructor, adds a hidden per-object pointer, which can dominate the size of a small class. Give a virtual destructor to classes designed as base classes or already holding virtual functions, and leave concrete value types alone.

Blocking inheritance: a protected non-virtual destructor prevents deletion through a base pointer, but also prevents ordinary use of the base class by itself. The modern answer for a class that should not be inherited from is to mark the class final, which blocks the hierarchy without restricting the class in any other way.

Bypassing dispatch: qualify the call with a class name, as in object.BaseClass::function(), to call one named version and skip virtual resolution entirely.

Virtual assignment: possible, but not recommended. Leave assignment operators non-virtual.