28 / 163 · C++14 · 9 min
Base destructor: public virtual or protected nonvirtual
If a derived object may be owned and deleted through a base pointer, the base should provide a public virtual destructor. If the base is only an interface view that cannot be destroyed independently, a protected nonvirtual destructor can block that deletion. The choice follows the destruction contract, not a blanket rule of adding virtual whenever inheritance appears.
In this lesson
First ask who owns and deletes the object
When ordinary single-object delete destroys a derived object of a different dynamic type through a base pointer, the base needs a virtual destructor so the entire derived object is destroyed correctly. This discussion covers ordinary deletion and does not use the C++20-specific destroying delete mechanism. Whether other members are virtual is independent of whether the destructor is virtual; having virtual functions does not automatically make the destructor virtual.
A public virtual destructor means callers can complete destruction of an owned object through that interface. The example's unique_ptr<Owned> is exactly that contract: it stores a base pointer, and when it leaves scope it should run the Impl destructor first, then the Owned destructor. The default smart-pointer deleter will not automatically supply dynamic deletion for a non-virtual base.
If base deletion is not allowed, forbid it explicitly
If the base is only for borrowing and behavioral access, and every object is always managed as its concrete derived type, make the base destructor protected and non-virtual. External delete Base* fails due to access. Derived destructors can still call the base destructor, and concrete objects can still live on the stack or be managed by unique_ptr<Derived>.
This is clearer than a public non-virtual destructor: the dangerous use is rejected at compile time, rather than relying on callers to remember a hidden convention. A protected virtual destructor can appear in special frameworks, but it is not the default for the two common contracts here. The key is to design access and the dynamic destruction path together.
The destruction chain does not depend on the derived layer remaining forever
Once the base destructor is virtual, derived destructors automatically become virtual as well, and you can add override to check intent. The actual order is the derived destructor body, derived members, then the base destructor. If a virtual function is called again from the base destructor, it will not return to a derived-layer implementation that has already finished.
The example records destruction order in a fixed array, then shows a borrowed interface with a protected non-virtual destructor. Neither choice assumes object size. If the interface crosses a dynamic-library boundary, you still need compatible allocation and deallocation and a live implementation module long enough; virtual only solves the correct dynamic destructor entry, not every deployment-level lifetime problem.
Pitfalls
- After converting unique_ptr<Derived> to unique_ptr<Base>, the default deleter usually deletes through Base*; a public non-virtual destructor on Base is therefore still dangerous.
- Do not delete[] a derived array through Base*; a virtual destructor cannot make a base pointer correctly represent a derived array layout.
Run an example
Minimum C++14 · complete program · Download .cpp
#include <array>
#include <cassert>
#include <cstddef>
#include <memory>
#include <type_traits>
struct Log {
std::array<int, 2> entries{};
std::size_t used = 0;
void add(int n) noexcept { entries[used++] = n; }
};
struct Owned {
Log& log;
explicit Owned(Log& l) : log(l) {}
virtual ~Owned() { log.add(2); }
};
struct Impl final : Owned {
explicit Impl(Log& l) : Owned(l) {}
~Impl() override { log.add(1); }
};
class Borrowed {
public:
virtual int value() const = 0;
protected:
~Borrowed() = default;
};
struct Concrete final : Borrowed {
int value() const override { return 7; }
};
int main() {
Log log;
{ std::unique_ptr<Owned> object = std::make_unique<Impl>(log); }
const std::array<int, 2> expected{{1, 2}};
assert(log.entries == expected && log.used == 2);
static_assert(!std::is_destructible<Borrowed>::value, "external deletion forbidden");
Concrete concrete;
const Borrowed& view = concrete;
assert(view.value() == 7);
}
Compile locally
g++ -std=c++14 -Wall -Wextra -Wpedantic -pthread objects-virtual-destructor.cpp -o example && ./exampleExpected result
Expected: exit 0, no output; every assert holds.
CHECK YOUR UNDERSTANDING
Close the answer. Explain it.
If you change the Borrowed destructor to public but keep it non-virtual, does the local Concrete in the example immediately go wrong? Why is it still not recommended?
Show a reference answer
The local Concrete is still destroyed as its concrete type and will not go wrong because of that. But the new interface lets outsiders write delete Borrowed*, and when the pointer actually points to Concrete that enters an unsafe ordinary deletion path. The value of the protected non-virtual design is to exclude an unsupported destruction path from the compilable interface, not to fix this particular local object.
Check the sources
Drafts and official chapters change. The version mark is only the example’s minimum.