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
protectedand 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
virtualuntil someone uses a different pointer.
Questions about this lesson
Log in to ask a question.