Contact
Evil Genius SocietyHomeLearnForumLeaderboardSocialsMessages
← Back to module

Lesson 3 of 9

This is the most dangerous mistake in Module 16, and the fix is one word.

Deleting a derived object through a base pointer without a virtual destructor is undefined behaviour. In practice the derived destructor never runs, so every resource it owns is leaked — silently, with no crash and usually no warning.

The demonstration

#include <iostream>
#include <memory>
#include <string>
#include <vector>

// NO virtual destructor — this is the bug.
class BadBase {
public:
    ~BadBase() { std::cout << "  ~BadBase\n"; }
};

class BadDerived : public BadBase {
    std::vector<int> data;
public:
    BadDerived() : data(1000, 7) { std::cout << "  BadDerived acquired 4000 bytes\n"; }
    ~BadDerived() { std::cout << "  ~BadDerived (releases the vector)\n"; }
};

// WITH a virtual destructor.
class GoodBase {
public:
    virtual ~GoodBase() { std::cout << "  ~GoodBase\n"; }
};

class GoodDerived : public GoodBase {
    std::vector<int> data;
public:
    GoodDerived() : data(1000, 7) { std::cout << "  GoodDerived acquired 4000 bytes\n"; }
    ~GoodDerived() override { std::cout << "  ~GoodDerived (releases the vector)\n"; }
};

int main() {
    std::cout << "deleting a GoodDerived through GoodBase*:\n";
    {
        GoodBase* p = new GoodDerived();
        delete p;
    }

    std::cout << "\ndeleting a BadDerived through BadBase*:\n";
    {
        BadBase* p = new BadDerived();
        delete p;                     // undefined behaviour
    }

    std::cout << "\nunique_ptr<GoodBase> holding a GoodDerived:\n";
    {
        std::unique_ptr<GoodBase> p = std::make_unique<GoodDerived>();
    }
    return 0;
}
deleting a GoodDerived through GoodBase*:
  GoodDerived acquired 4000 bytes
  ~GoodDerived (releases the vector)
  ~GoodBase

deleting a BadDerived through BadBase*:
  BadDerived acquired 4000 bytes
  ~BadBase

unique_ptr<GoodBase> holding a GoodDerived:
  GoodDerived acquired 4000 bytes
  ~GoodDerived (releases the vector)
  ~GoodBase

~BadDerived never printed. The std::vector holding 4000 bytes was never destroyed, so its buffer leaked. The program ran to completion, exited 0, and printed nothing alarming.

~BadBase did run, which is what makes this so easy to miss: something was destroyed, just not the right something.

Built with -fsanitize=address, the situation is named precisely:

==2818==ERROR: AddressSanitizer: new-delete-type-mismatch on 0x503000000070 in thread T0
SUMMARY: AddressSanitizer: new-delete-type-mismatch in operator delete(void*, unsigned long)

new-delete-type-mismatch — the memory was allocated as a BadDerived and is being freed as a BadBase. The sizes differ, so even the deallocation is wrong, not merely the destructor call.

Why the compiler often cannot help

GCC has a warning for this, and it fires only in one of the two cases:

// A base WITH virtual functions but WITHOUT a virtual destructor:
//
//   class Detectable {
//   public:
//       virtual void work();
//       ~Detectable() {}          // not virtual
//   };
//
//   Detectable* p = new Sub();
//   delete p;
//
// warning: deleting object of polymorphic class type 'Detectable' which has
//    non-virtual destructor might cause undefined behavior
//    [-Wdelete-non-virtual-dtor]

That warning is in -Wall, so it costs nothing to have.

But it is silent for BadBase above, which has no virtual functions at all. The compiler has no way to know that a class with no virtual members was intended as a polymorphic base — from its point of view you are deleting exactly what you allocated.

That is the trap: the case the compiler catches is the one you were already half-thinking about (you wrote virtual somewhere), and the case it misses is the one where you never thought about polymorphism at all.

The rule

If a class is ever deleted through a pointer to it, its destructor must be virtual.

In practice: give every base class a virtual destructor.

// class Base {
// public:
//     virtual ~Base() = default;     // this line, every time
// };

= default is the right spelling. It generates the destructor and costs nothing beyond the vtable the class needs anyway.

What it actually costs

Adding a virtual destructor to a class that has no other virtual functions adds a vptr — 8 bytes per object (lesson 16.2), and it stops the type being trivially copyable.

That matters in exactly one situation: a small, non-polymorphic value type that you never delete polymorphically. A struct Point { double x, y; }; should not have a virtual destructor, and nothing in this lesson suggests otherwise.

The decision is simply:

| The class | Destructor | |---|---| | Has any virtual function | virtual | | Is used as a base and deleted through a base pointer | virtual | | Is a plain value type, never inherited from | non-virtual | | Is a base, but only ever deleted as the derived type | non-virtual, but see below |

That last row is real but fragile. If nobody ever writes Base* p = new Derived; delete p; the non-virtual destructor is harmless — until someone does, and nothing warns them. The defensive alternative is to make the base's destructor protected and non-virtual:

// class Policy {
// protected:
//     ~Policy() = default;          // cannot delete through a Policy*
// public:
//     virtual void configure() = 0;
// };

Now delete p; through a Policy* does not compile, which is better than compiling and leaking. This is the standard approach for mixin and policy classes.

std::unique_ptr does not save you

The third case in the output above worked — but only because GoodBase has a virtual destructor.

// std::unique_ptr<BadBase> p = std::make_unique<BadDerived>();
//
// Same bug. unique_ptr<T>'s default deleter calls `delete` on a T*, so it
// needs the virtual destructor exactly as a raw pointer does.

The exception is std::shared_ptr, which captures the deleter at construction:

// std::shared_ptr<BadBase> p = std::make_shared<BadDerived>();
//
// This one DOES work. shared_ptr remembers that it was constructed from a
// BadDerived and calls ~BadDerived. It is the one smart pointer that does
// not need a virtual destructor.

That is a genuine difference between the two (lesson 10.4), and a surprising one. It works because shared_ptr's control block stores a type-erased deleter — the same mechanism that lets it hold a custom deleter. unique_ptr encodes the deleter in its type, so it has nothing to remember.

Do not rely on it. A base class with a virtual destructor works with both; one without works with only one, and the next person will use the wrong one.

Try it

Write a resource-owning hierarchy and demonstrate the leak three ways: with delete, with unique_ptr, and with shared_ptr. Measure the leaked bytes under ASan for each, and explain why one of the three does not leak.

Checkpoint

  • Deleting through a base pointer without a virtual destructor is undefined behaviour.
  • In practice the derived destructor never runs and its resources leak, with no crash.
  • ASan names it precisely: new-delete-type-mismatch, with both sizes reported.
  • Give every base class virtual ~Base() = default; — one line, every time.
  • -Wdelete-non-virtual-dtor (in -Wall) fires only if the base has other virtual functions.
  • The case the compiler misses is the one where you never thought about polymorphism.
  • The cost is a vptr — 8 bytes per object — so plain value types should not have one.
  • For a base never deleted polymorphically, make the destructor protected and non-virtual: then deleting through it does not compile.
  • unique_ptr<Base> has the same bug as a raw pointer; the deleter is part of its type.
  • shared_ptr<Base> does not, because its control block remembers the concrete deleter.
  • Do not rely on that — it hides the missing virtual until someone uses a different pointer.

Questions about this lesson

Log in to ask a question.