C++ Part I · Module 17 · Runtime Polymorphism
Virtual Destructors
Every base class in this module since Chapter 17.2 has carried one line whose purpose was deferred three times, and this is the chapter that pays the debt. The rule itself is a sentence long — if a class has virtual functions, give it a virtual destructor — and it is the kind of rule that gets memorised without being understood, which is why it is still absent from working code. So this chapter measures it: the same program, the same derived object holding a heap buffer, deleted through a base pointer with and without one word, counting allocations and frees. The result is unambiguous and it is the module's first classic failure. What earns the subject a chapter of its own is the next part, which is where people actually get hurt. The modern instinct on meeting a raw delete is to replace it with a smart pointer and stop thinking about lifetimes, and that instinct is usually right — Module 14 spent eight chapters earning it. Here it is half right in a way that is almost impossible to guess. One of the two obvious smart pointers leaks exactly as badly as the raw delete did, the other does not, the difference has nothing to do with ownership semantics and everything to do with which static type you happened to write at the point of construction, and the compiler's warning about the entire situation points at a line inside a standard library header instead of at your code.
Advanced19 min readC++Part IFundamentalsPolymorphism
Part I · Module 17 · Chapter 17.5 · Virtual Destructors
1. The Rule, and Why It Needs a Chapter
If a class has any virtual function, give it a virtual destructor.
That is the whole rule. The reason it needs nineteen minutes rather than one line is that the rule is widely known and widely absent, which usually means people have memorised it without being able to reconstruct why — and a rule you cannot reconstruct is one you will not apply to the case that looks slightly different.
The cases that look slightly different are §5 through §7.
2. The Setup
Two hierarchies, identical except for one word. The derived classes own a heap buffer:
struct BadBase {
~BadBase() { /* NOT virtual */ }
virtual void run() const { /* ... but the class IS polymorphic */ }
};
struct BadDerived : BadBase {
int* buf;
BadDerived() : buf(new int[64]) { ++liveBuffers; }
~BadDerived() { delete[] buf; --liveBuffers; }
};
struct GoodBase { virtual ~GoodBase() { } virtual void run() const { } };
struct GoodDerived : GoodBase { /* same buffer, same destructor */ };The program counts allocations and frees with an instrumented operator new/operator delete, and counts live buffers directly.
3. The Leak
BadBase* p = new BadDerived();
p->run();
delete p;A. delete through a base pointer, NO virtual destructor:
+ BadDerived buffer
BadDerived::run
~BadBase
allocs=2 frees=1 liveBuffers=1Three things in four lines, and all three matter.
BadDerived::run ran. Dispatch worked perfectly — the object's identity was available and used.
Only ~BadBase ran. There is no ~BadDerived line. The derived destructor was never called, so delete[] buf never happened.
allocs=2 frees=1, and one buffer is still live. The 64-int buffer leaked. Note what was freed: the object's own storage. delete p released the right number of bytes for a BadBase-shaped deletion and skipped the derived destructor entirely.
With the virtual destructor:
B. the same, WITH a virtual destructor:
+ GoodDerived buffer
GoodDerived::run
~GoodDerived (freed buffer)
~GoodBase
allocs=2 frees=2 liveBuffers=0Both destructors, in Chapter 16.5 §2's reverse order, and nothing leaked.
4. Why
delete p has to do two things: run the destructor, and release the storage. The destructor call is an ordinary member function call, so it obeys Chapter 17.2's rule — it dispatches if and only if the destructor is virtual.
Without virtual, the compiler resolves ~BadBase statically from the pointer's type, exactly as Chapter 16.6's hiding resolved everything else from static types. With virtual, the destructor is found through the object's table, which means ~BadDerived runs first and then chains to ~BadBase — Chapter 16.5's four-phase teardown, reached from the right end.
So this is not a special rule about destructors. It is Chapter 17.2's dispatch rule applied to the one member function you never call by name. That is why it is easy to forget: every other virtual function is written at a call site where you can see it.
The destructor is the only member function invoked without being named. If it is not virtual, nothing about the call site reminds you.
5. Does a Smart Pointer Rescue You?
The modern reflex on seeing delete p is to replace it with a smart pointer. Module 14 earned that reflex. Here is what it buys.
std::unique_ptr<BadBase> p(new BadDerived());
p->run();C. through unique_ptr<BadBase>:
+ BadDerived buffer
BadDerived::run
~BadBase
allocs=2 frees=1 liveBuffers=1Identical leak. Same counts, same missing ~BadDerived, same live buffer.
std::unique_ptr<BadBase> uses std::default_delete<BadBase>, which does exactly one thing: delete a BadBase*. Chapter 14.4 §7 established that the deleter is part of the unique_ptr's type, and that type was written as BadBase. The smart pointer faithfully performed the same wrong deletion.
6. The One That Does
std::shared_ptr<BadBase> p(new BadDerived());
p->run();D. through shared_ptr<BadBase>:
+ BadDerived buffer
BadDerived::run
~BadDerived (freed buffer)
~BadBase
allocs=3 frees=3 liveBuffers=0No leak. ~BadDerived ran, the buffer was freed, and the counts balance — the third allocation is the control block Chapter 14.5 §2 described.
The mechanism is specific and it is not about ownership semantics. std::shared_ptr's constructor is a template on the argument's type: handed a BadDerived*, it stores a type-erased deleter in the control block that deletes a BadDerived*. The static type at the point of construction is captured and remembered.
Which makes the rescue conditional, and the condition is measurable:
A. shared_ptr<B> p(new D()) -> ~D then ~B
B. B* raw = new D(); shared_ptr<B> p(raw) -> ~B only
C. unique_ptr<B> p(new D()) -> ~B onlyRow B is the trap. The same object, the same shared_ptr<B>, and the derived destructor does not run — because by the time the shared_ptr was constructed, the expression's static type was B*, and that is what the stored deleter deletes.
shared_ptr<Base>(new Derived)is safe.shared_ptr<Base>(someBasePointer)is not. The difference is the static type at the point of construction, which is not where anyone looks for a lifetime bug.
So the honest summary of §5 and §6: smart pointers do not fix a missing virtual destructor. One of them happens to sidestep it under a condition most code satisfies by accident, which is arguably worse than failing consistently — it means the bug is latent until someone refactors a construction site.
7. Where the Compiler's Warning Points
The compiler does warn, which is welcome. Where it warns is the problem.
warning: delete called on non-final 'BadBase' that has virtual functions but
non-virtual destructor [-Wdelete-non-abstract-non-virtual-dtor]It is in -Wall. Measured, for each of the three deletion routes:
RAW (delete p) own-file warnings: 1 library-header warnings: 0
UNIQ (unique_ptr<B>) own-file warnings: 0 library-header warnings: 1
SHARED (shared_ptr<B>) own-file warnings: 0 library-header warnings: 1For the raw delete, the warning points at your line. For both smart-pointer cases it points into <memory> — at unique_ptr.h, inside the library — because that is literally where the delete expression lives.
Two consequences, and the second is the serious one.
It is easy to miss. A warning naming a path inside the standard library reads as noise from somebody else's code.
And it is easy to suppress deliberately. Many projects filter warnings from system headers — that is what -isystem and most IDE configurations do by default — which means the diagnostic for the most common modern spelling of this bug is routinely discarded. The raw-delete version of the bug is the one your build reports, and it is the version least likely to exist in a codebase that already uses smart pointers.
8. The Reflex
Make it mechanical. When you write the first virtual in a class, write the destructor in the same keystroke:
struct Model {
virtual ~Model() = default; // write this first
virtual void step() = 0;
};= default is right in almost every case — Chapter 12.4 established that a defaulted destructor is exactly the compiler-generated one, and making it virtual is the only change needed.
And use Chapter 17.3 §6's trick in derived classes:
struct Real : Model { ~Real() override { /* ... */ } };Writing override on a derived destructor asserts that the base's is virtual — measured there giving only virtual member functions can be marked 'override' when it was not. It is a one-word assertion checked at compile time, in the file most likely to notice.
9. When You Do Not Need One
Two cases, and being precise about them keeps the rule from becoming superstition.
A class with no virtual functions that is not used polymorphically. No dispatch, no base pointers, no deletion through a base — a plain value type needs nothing. Chapter 13.11's Rule of Zero applies.
A base that is never deleted through a base pointer, enforced rather than assumed — a protected destructor makes deletion through the base a compile error for outside code, which is how you say "this is a base, not something you own by base pointer".
What is not on the list: an abstract class. Chapter 17.4 §10 made the point — abstractness says nothing about destruction, and an abstract base is precisely the thing that gets held and deleted by base pointer. It needs a virtual destructor more than a concrete class does.
10. The Cost
Essentially nothing, and worth stating so the rule is not weighed against a phantom.
No extra size. The class already has virtual functions, so Chapter 17.1 §6's table pointer is already there; the destructor adds a table slot, which is per-class and not per-object.
One dispatch per destruction, which is Chapter 17.7's three instructions on an operation that is already freeing memory.
And no loss of a defaulted destructor's properties — virtual ~T() = default is still trivially written and still does the right thing.
The only real consideration is the one from Chapter 17.1 §6: if a type has no virtual functions and you are about to add a virtual destructor, you are adding 8 bytes per object to a type that was not polymorphic. The answer there is not to skip the destructor but to ask why a non-polymorphic type is being used as a base.
11. What to Actually Do
Write virtual ~T() = default whenever you write the first virtual in a class — same keystroke.
Write override on derived destructors — it asserts the base's is virtual, checked at compile time.
Do not rely on a smart pointer to fix it. Measured: unique_ptr<Base> leaks identically.
Do not rely on shared_ptr either — its rescue depends on the static type at construction.
Remember abstract classes need one too — abstractness says nothing about destruction.
Make a non-deletable base's destructor protected if it genuinely must not be deleted through the base.
And check whether your build filters system-header warnings, because that is where the diagnostic lands for the common spellings.
12. Mistakes Worth Making Once
A polymorphic base without a virtual destructor. Measured: allocs=2 frees=1, ~Derived never ran.
Assuming dispatch working means destruction works. Measured: BadDerived::run ran and the destructor did not.
Replacing delete with unique_ptr<Base> and considering it fixed. Measured: identical leak.
Concluding shared_ptr is safe. Measured: shared_ptr<B>(basePtr) runs only ~B.
Believing an abstract class is exempt. It is the most exposed case.
Ignoring a warning that names a standard library header. That is where this one points for smart pointers.
Building with only -Wextra. The warning is in -Wall.
Writing ~Derived() override against a non-virtual base destructor — correctly refused, which is the point of writing it.
Weighing the rule against a cost. Measured: no per-object size change, one dispatch per destruction.
13. Interview Relevance
"Why does a polymorphic base need a virtual destructor?" Because delete through a base pointer resolves the destructor statically unless it is virtual, so the derived destructor never runs. Measured as allocs=2 frees=1.
"What exactly leaks?" Whatever the derived destructor would have released — the object's own storage is freed. That precision distinguishes.
"Does unique_ptr fix it?" No — the deleter is part of the type and the type names the base. Measured identical.
"Does shared_ptr?" Conditionally, and for an unrelated reason: the constructor is a template, so the static type at construction is captured into the control block. shared_ptr<B>(new D) is safe; shared_ptr<B>(basePtr) is not. Very few candidates know this.
"Does the compiler warn?" Yes, in -Wall — but for smart pointers it points inside <memory>, which many builds filter.
"Do abstract classes need one?" Yes, more than concrete ones.
"When can you skip it?" A non-polymorphic value type, or a base whose destructor you make protected so base-pointer deletion cannot compile.
14. What You Should Be Able to Say Now
- If a class has any virtual function, give it a virtual destructor.
- Measured, without one:
BadDerived::rundispatched correctly, only~BadBaseran, andallocs=2 frees=1with one buffer live. - The object's own storage is still freed — what leaks is whatever the derived destructor would have released.
- Measured, with one: both destructors ran in reverse order and
frees=2. - The reason is Chapter 17.2's dispatch rule applied to the one member function you never name.
- Measured:
unique_ptr<BadBase>leaked identically — the deleter is part of the type. - Measured:
shared_ptr<BadBase>(new BadDerived)did not leak; the third allocation is the control block. - Measured:
shared_ptr<B>(basePtr)does leak — the rescue depends on the static type at construction. - So smart pointers do not fix this; one of them sidesteps it conditionally.
- Measured: the warning is in
-Wall, and points at your line for rawdeletebut inside<memory>for both smart pointers. - Builds that filter system-header warnings discard it for the common modern spellings.
- Write the virtual destructor with the first
virtual, andoverrideon derived destructors. - Abstract classes need one too — abstractness is unrelated to destruction.
- A
protecteddestructor makes base-pointer deletion a compile error. - The cost is no per-object size and one dispatch per destruction.
- Detectability got worse as the code got more modern — the same shape as Chapter 14.7 §6's finding.
15. Exercises
Exercise 1 — predict the counts. A base with virtual functions and a non-virtual destructor; a derived class holding a std::string. Give the destructor output and whether anything leaks when deleted through a base pointer.
Exercise 2 — what is freed. Say precisely what delete basePtr does free in that case, and what it does not.
Exercise 3 — the dispatch connection. Explain the leak using only Chapter 17.2's rule, without a special rule about destructors.
Exercise 4 — the smart pointers. For unique_ptr<Base>(new Derived), shared_ptr<Base>(new Derived) and shared_ptr<Base>(basePtr), say which destructors run and why.
Exercise 5 — the warning. Say which flag enables the diagnostic and, for each of the three deletion routes, which file it names.
Exercise 6 — the assertion. Write a derived destructor that fails to compile if the base's is not virtual.
Exercise 7 — the exemptions. Give two classes that do not need a virtual destructor and say how you would enforce the second case.
Exercise 8 — the cost. Answer a colleague who says adding a virtual destructor will grow every object.
Exercise 9 — the general lesson. Name the other place in this curriculum where modernising the ownership type made a failure harder for tools to see.
16. Summary
- Any class with a virtual function needs a virtual destructor, because
deletethrough a base pointer resolves the destructor statically otherwise. - Measured: dispatch worked, only
~BadBaseran,allocs=2 frees=1, one buffer live. Withvirtual: both destructors,frees=2. - The object's storage is freed; the derived destructor's work is not done.
- Measured:
unique_ptr<Base>leaks identically — the deleter is part of the type. - Measured:
shared_ptr<Base>(new Derived)does not leak, because the constructor captures the static type at construction — andshared_ptr<Base>(basePtr)leaks again. - So smart pointers do not fix this, and the one that sidesteps it does so conditionally and invisibly.
- Measured: the
-Wallwarning names your line for rawdeleteand<memory>for both smart pointers — which many builds filter out. - Write it with the first
virtual; useoverrideon derived destructors; abstract classes included. - The cost is nothing per object — the table pointer is already there.
That is the first of this module's three classic failures, and the second is already partly measured. Chapter 16.2 §5 showed that copying a derived object into a base object copies only the base part, and treated it as an unremarkable consequence of layout — eight bytes of a twelve-byte object, with nowhere for the rest to go. Chapter 17.4 §9 then showed that an abstract base makes that copy a compile error. What has not been shown is what the copy does when the base is concrete and the discarded part is behaviourally significant, which is the situation that makes it a bug worth naming. The next chapter measures it across every route by which it happens — a by-value parameter, a copy-initialisation, an assignment, and a container of base objects — and the result is the same every time: the object produced is a valid base object, it answers virtual calls with the base's implementations, and nothing at any warning level mentions it. The sizes do not even reveal it, because in the measured case both types are the same number of bytes.
Standards & specifications
- Governing standard
- ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)
Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C++ Programming curriculum.
