Missing Virtual Destructor: Why Deleting Through a Base Pointer Leaks or Worse
Key takeaways
Deleting a derived object through a base pointer without a virtual destructor is undefined behavior, not just a leak. How the destructor call is dispatched, which GCC warnings catch it (and which cases they miss), why std::unique_ptr<Base> is affected, and the three correct designs: public virtual, protected non-virtual, or final.
The bug in one example
struct Base {
virtual void run() {}
~Base() { std::cout << "~Base\n"; } // not virtual
};
struct Derived : Base {
std::vector<int> data = std::vector<int>(1'000'000);
~Derived() { std::cout << "~Derived\n"; }
};
int main() {
Base* p = new Derived;
delete p;
}
Output with GCC 10:
~Base
~Derived never ran, so data was never destroyed and its buffer leaked. That is the usual visible symptom, but it is not the whole story. The C++ standard says deleting an object through a pointer to a base class whose destructor is not virtual is undefined behavior. A leak is simply the most common outcome.
Why delete does not “just know” the real type
delete p does two things: it calls the destructor, then it releases the memory. Both are decided at compile time from the static type of p, unless the destructor is virtual.
For an ordinary member function call, that is familiar: p->run() calls Derived::run only if run is virtual. The destructor follows exactly the same rule. With a non-virtual ~Base, the compiler emits a direct call to Base::~Base and passes sizeof(Base) worth of knowledge to the deallocation. With a virtual destructor, delete p goes through the vtable, reaches Derived’s destructor, which destroys Derived’s members, then calls ~Base automatically, and the memory is released with the correct size and address.
This also explains why the problem is worse than a leak with multiple inheritance. If Derived inherits from A and B, a B* pointing to a Derived usually does not hold the same address as the start of the object. A non-virtual delete through that B* hands the allocator an address it never returned from new, which typically corrupts the heap or aborts. A virtual destructor fixes this too, because the virtual call adjusts to the full object before freeing it.
The fix
struct Base {
virtual void run() {}
virtual ~Base() = default;
};
struct Derived : Base {
~Derived() override { /* ... */ }
};
Now delete p prints ~Derived then ~Base. Note:
virtualis needed only in the base. Every derived destructor is automatically virtual. Writingoverrideon it is optional but documents the intent and fails to compile if someone later removesvirtualfrom the base.= defaultis fine. The destructor does not need a body to be virtual.- Declaring any destructor suppresses the implicitly generated move constructor and move assignment. If the base should be movable, default those explicitly, or accept that derived classes will copy instead of move.
What the compiler warns about, and what it misses
GCC has two relevant warnings:
warning: deleting object of polymorphic class type 'Base' which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]
This one is in -Wall and fires at the delete expression, but only when Base is polymorphic (has at least one virtual function). The second is opt-in:
warning: 'struct Base' has virtual functions and accessible non-virtual destructor [-Wnon-virtual-dtor]
It fires at the class definition, which catches the problem before anyone writes the delete. It is worth enabling in any codebase with class hierarchies.
What neither of them catches:
- A base with no virtual functions. A plain
struct Shape { ~Shape(); };with derived classes is not polymorphic, so GCC says nothing when youdeleteaShape*that points to aCircle. It is the same undefined behavior. std::unique_ptr<Base>.std::unique_ptr<Base> u = std::make_unique<Derived>();compiles without a warning in my tests, because thedeletehappens inside the standard library header where warnings are suppressed. Whenugoes out of scope it prints only~Base. This is the most common way the bug appears in modern code.
For the cases the compiler misses, two tools help. AddressSanitizer (-fsanitize=address) catches the mismatch at run time whenever Derived is larger than Base, because C++14 sized deallocation passes sizeof(Base) to operator delete while the block was allocated with sizeof(Derived):
ERROR: AddressSanitizer: new-delete-type-mismatch on 0x602000000010 in thread T0:
object passed to delete has wrong type:
size of the allocated type: 32 bytes;
size of the deallocated type: 8 bytes.
The report includes the stack of the new and of the delete, which is exactly what is needed to find which hierarchy is affected. It does not fire when the derived class adds no data members, so a clean ASan run is not proof that every base is correct. Static analysis closes that gap: clang-tidy’s cppcoreguidelines-virtual-class-destructor flags any class with virtual functions whose destructor is neither public-virtual nor protected-non-virtual, at the class definition, without needing a delete anywhere.
Why shared_ptr seems to fix it
struct NoVirt { ~NoVirt() { std::cout << "~NoVirt\n"; } };
struct D2 : NoVirt { ~D2() { std::cout << "~D2\n"; } };
std::shared_ptr<NoVirt> s = std::make_shared<D2>();
s.reset(); // prints ~D2 then ~NoVirt
std::shared_ptr captures a deleter for the type it was created with and stores it in the control block. Converting shared_ptr<D2> to shared_ptr<NoVirt> keeps that deleter, so the right destructor runs. unique_ptr has no control block; its deleter is part of its type, std::default_delete<Base>.
Do not rely on this. It breaks the moment someone writes std::shared_ptr<NoVirt>(rawBasePointer), and it tells readers nothing about how the class is supposed to be used.
Three correct designs
Every base class should pick one of these deliberately:
-
Public and virtual. The class is meant to be used polymorphically and deleted through a base pointer. This is the default for interfaces held in
std::unique_ptr<Base>or containers of base pointers. -
Protected and non-virtual. The class is a base for code reuse or a mixin, never meant to be owned through a base pointer:
class Comparable { protected: ~Comparable() = default; };Now
delete comparablePtr;is a compile error ('Comparable::~Comparable()' is protected within this context), whileDerivedobjects destroy themselves normally. There is no vtable cost. -
Not a base at all. Mark the class
final. It cannot be inherited from, so the question never arises. Standard containers such asstd::vectorandstd::stringhave non-virtual destructors for this reason: they are not designed as polymorphic bases, and deriving from them and deleting through a base pointer is the same bug.
Pure virtual destructors
struct Shape {
virtual ~Shape() = 0;
};
Shape::~Shape() = default; // required
This makes Shape abstract when it has no other pure virtual function to mark. Forgetting the definition is a classic mistake, and it fails at link time, not compile time, because every derived destructor calls Shape::~Shape():
undefined reference to `Shape::~Shape()'
Unlike other pure virtual functions, the destructor’s definition is always called, so it must exist.
Where this goes wrong in practice
The version that bites people is almost never a class that already has virtual functions; -Wall flags those. It is a class that started as a simple base with shared fields and no virtual functions at all, later held in a std::vector<std::unique_ptr<Base>>. No warning appears anywhere, the program runs, and the derived members that own memory, files or sockets are silently never destroyed. When I review a hierarchy, I check each base destructor for exactly one of “public virtual”, “protected” or “the class is final”, and treat any other combination as a bug.
The second failure is a leak that only a memory tool reveals, in code that has a shared_ptr somewhere nearby. Someone tests the class through make_shared, sees correct destruction, and concludes the destructor is fine; the unique_ptr path elsewhere in the code leaks. The shared_ptr behavior is real, but it hides the design error rather than fixing it.
FAQ
Q. Does std::shared_ptr<Base> avoid the problem when the destructor is not virtual?
A. Only when it was created from the derived type, as in std::make_shared<Derived>(), because the control block remembers the derived deleter. Built from a raw Base*, or with unique_ptr<Base>, it is undefined behavior. Declare the destructor virtual or protected anyway.
Q. Is it safe to inherit from std::vector or std::string?
A. It is legal if you never delete the derived object through a pointer to the standard container. Because their destructors are not virtual, a std::vector<int>* that points to your derived class must not be deleted. Composition (a member vector) avoids the question.