May 20, 2026
Destructor trap
Why skipping a virtual destructor silently breaks unique_ptr
Consider this small hierarchy:
#include <iostream>
#include <memory>
using namespace std;
struct A {
A() { cout << "A"; }
~A() { cout << "B"; }
};
struct B : A {
B() { cout << "C"; }
~B() { cout << "D"; }
};
Construction order is always base-first, then derived. Destruction is the reverse: derived-first, then base. If everything goes right, creating and destroying a B object should print AC then DB.
The question is: does it? It depends entirely on how you manage the object.
The raw pointer case
A* obj = new B();
delete obj;
Output: ACB
B is constructed correctly (AC). But delete obj calls ~A directly because obj is of static type A* and ~A is not virtual. ~B is never called. This is undefined behaviour.
Any resource B owns — file handles, memory, locks — leaks silently.
The unique_ptr case
unique_ptr<A> p = make_unique<B>();
Output: ACB
Same result. The default deleter for unique_ptr<A> calls delete on an A*. Without a virtual destructor the outcome is identical to the raw pointer case: ~B is skipped, and the behaviour is undefined.
This surprises people because smart pointers are supposed to handle cleanup for you. They do — but they can only call the destructor that the pointer type resolves to. unique_ptr<A> does not remember that it was constructed from a B*.
The shared_ptr case
shared_ptr<A> p = make_shared<B>();
Output: ACDB
Both destructors run in the correct order. Why does this work without a virtual destructor?
shared_ptr stores a type-erased deleter in its control block at construction time. When you write make_shared<B>(), the control block captures a deleter that knows the concrete type is B* and will call delete (B*)ptr when the reference count reaches zero. The static type of the pointer (A*) is never consulted at destruction time.
This is sometimes called the shared_ptr virtual destructor trick, but it is really just a consequence of how shared_ptr is designed. unique_ptr does not carry a control block and therefore cannot do the same thing by default.
Fixing the root cause: virtual destructor
The correct fix for polymorphic base classes is to declare the destructor virtual:
struct A {
A() { cout << "A"; }
virtual ~A() { cout << "B"; }
};
Now delete obj, unique_ptr<A>, and shared_ptr<A> all produce ACDB. The virtual dispatch resolves to ~B first, which chains up to ~A.
This is the rule stated in the C++ Core Guidelines and in Effective C++ (Item 7):
Make destructors virtual in polymorphic base classes.
Comparing the three cases side by side
| Usage | Output | ~B called? |
Notes |
|---|---|---|---|
A* obj = new B(); delete obj; |
ACB |
No | Undefined behaviour |
unique_ptr<A> p = make_unique<B>(); |
ACB |
No | Undefined behaviour |
shared_ptr<A> p = make_shared<B>(); |
ACDB |
Yes | Works via type-erased deleter |
Any of the above with virtual ~A() |
ACDB |
Yes | Correct in all cases |
Giving unique_ptr the right deleter
If you cannot add a virtual destructor and still want unique_ptr, you can supply a custom deleter:
auto deleter = [](A* p) { delete static_cast<B*>(p); };
unique_ptr<A, decltype(deleter)> p(new B(), deleter);
Output: ACDB
This works, but it couples the owner to the concrete type, which defeats most of the purpose of holding a base pointer. It is rarely the right design.
The practical takeaway
- Any polymorphic base class should have a virtual destructor. This is the simple, universal rule.
shared_ptrhappens to handle the case safely through type erasure, but relying on that instead of declaringvirtual ~Base()is obscure and fragile.unique_ptrdoes not protect you. It behaves exactly like a raw pointer in this regard.- The compiler gives no warning for missing virtual destructors in many default configurations. The bug is silent.
If a class has at least one virtual function, add virtual ~ClassName() = default; and move on. The cost is one pointer per object for the vtable — almost always irrelevant. The cost of forgetting is undefined behaviour and resource leaks that are very hard to diagnose.