Virtual Destructors, Object Slicing & override / final
In modern C++, we never guess what the compiler does. We look directly at byte offsets, CPU registers, and object ownership lifetimes.
Because in addition to skipping Derived::~Derived(), the compiler may not know the correct memory size or exact base offset to pass to the memory deallocator (operator delete). Under multiple inheritance, Base* might point to an offset within Derived. Without a virtual destructor to perform pointer adjustment back to the start of the allocated block, passing the unadjusted offset to free() crashes the heap manager.
A protected destructor prevents client code from writing delete basePtr; at compile time, eliminating the risk of undefined polymorphic deletion. Because the destructor is non-virtual, the class does not require an 8-byte __vptr or a vtable, keeping the struct standard-layout with zero memory overhead.
Constructors cannot be virtual because an object must exist before virtual dispatch can function. A pure virtual std::unique_ptr<Base> clone() const = 0; allows polymorphic copying: calling ptr->clone() dispatches via the dynamic type's vtable, invoking the derived class's copy constructor and returning a new heap copy without callers knowing the concrete derived type.
Interviewer's lens
Interviewers test virtual destructors and slicing to determine whether you write leak-prone code or memory-safe systems:
- Deletion Safety: Explaining why deleting via a non-virtual base pointer is Undefined Behavior ([expr.delete]/3) that skips derived destructors and corrupts heap allocator sizing.
- The Sutter Destructor Rule: Knowing when a base destructor must be public virtual (polymorphic ownership) versus protected non-virtual (zero-overhead mixins/policies).
- Object Slicing: Tracing how passing polymorphic objects by value chops off derived fields and overwrites
__vptrwith the base vtable. - Contract Enforcement: Using
overrideto catch silent signature drift at compile time andfinalto unlock compiler devirtualization.
Under the hood & memory layout
Destruction and copying have exact physical consequences in polymorphic memory layouts:
- Deleting via Base Pointer (
delete basePtr;):- Without
virtual: The compiler uses static dispatch, jumping directly toBase::~Base(). The derived destructorDerived::~Derived()never runs, leaking derived heap buffers, open sockets, and file handles. This is Undefined Behavior ([expr.delete]/3). - With
virtual: VTable slot 1 holds a deleting destructor thunk. It invokesDerived::~Derived(), unwinds ancestor destructors, and callsoperator deletewith the correct total byte size (sizeof(Derived)).
- Without
- Object Slicing (
Base b = derivedObj;):- Memory Truncation: A
Basestack slot has space only forsizeof(Base)bytes. PassingDerivedby value physically chops off all trailing derived members. - VTable Rewriting: The copy constructor initializes
b's__vptrwithBase's vtable. The sliced object loses its derived identity forever.
- Memory Truncation: A
Under the Itanium C++ ABI, declaring a virtual destructor adds two slots to the vtable: the complete object destructor (~Derived()) and the deleting destructor which runs ~Derived() and then invokes operator delete(sizeof(Derived)).
Copying a 48-byte Derived into a 16-byte Base value truncates the trailing 32 derived bytes. The stack frame of the receiving function allocates only 16 bytes for Base.
In Base b = derivedObj;, b's __vptr is initialized with &Base_vtable, not &Derived_vtable. Subsequent virtual function calls on b invoke Base methods.
A base class with protected ~Base() = default; has no vptr (0 bytes overhead) and causes delete basePtr; to fail at compile time, guaranteeing deletion safety without runtime cost.
Core concepts & syntax traps
Why 'delete basePtr;' Without 'virtual ~Base()' Is Undefined Behavior
Why 'delete basePtr;' Without 'virtual ~Base()' Is Undefined Behavior
Deleting an object through a base pointer without a virtual destructor triggers catastrophic bugs:
- Derived Destructor Is Skipped: The compiler generates a direct, static jump to
Base::~Base(). The derived destructorDerived::~Derived()never executes, instantly leaking derived heap allocations, file handles, and mutex locks. - Heap Memory Corruption: The memory allocator expects
operator deleteto receive the original allocation size. If it receivessizeof(Base)instead ofsizeof(Derived), heap binning metadata becomes corrupted. - Undefined Behavior: The C++ standard explicitly classifies this mismatch as Undefined Behavior ([expr.delete]/3). Any polymorphic base class must declare a virtual destructor.
class LeakyBase {
public:
~LeakyBase() = default; // BUG: Non-virtual destructor!
};
class HeavyDerived : public LeakyBase {
std::vector<int> hugeBuffer_{1'000'000, 42};
public:
~HeavyDerived() { /* never runs if deleted via LeakyBase*! */ }
};
LeakyBase* p = new HeavyDerived();
delete p; // UNDEFINED BEHAVIOR: hugeBuffer_ is leaked, allocator corrupted!Any class with at least one virtual function must declare a public virtual destructor.
The Golden Rule: Base Destructor Must Be Public Virtual OR Protected Non-Virtual
The Golden Rule: Base Destructor Must Be Public Virtual OR Protected Non-Virtual
The C++ Core Guidelines establish a strict design rule for base class destructors:
- Public and Virtual (Polymorphic Bases):
- When to use: Callers manage derived instances through base pointers (
std::unique_ptr<Base>). - Cost: Adds an 8-byte
__vptrto each object, but guarantees safe cleanup through the deleting destructor.
- When to use: Callers manage derived instances through base pointers (
- Protected and Non-Virtual (Policy/Mixin Classes):
- When to use: The base class exists purely to share code or methods, and callers should never delete objects through a
Base*. - Benefit: Writing
delete basePtr;triggers a compile-time error because~Base()is inaccessible. You get 100% deletion safety with 0 bytes of memory overhead.
- When to use: The base class exists purely to share code or methods, and callers should never delete objects through a
// MIXIN BASE: Zero-overhead, deletion-safe
class NonPolymorphicPolicy {
protected:
~NonPolymorphicPolicy() = default; // Protected: cannot be deleted via Base*
public:
void logAction() const { /* ... */ }
};
class Worker : public NonPolymorphicPolicy {};
Worker* w = new Worker();
// NonPolymorphicPolicy* p = w;
// delete p; // COMPILE ERROR: ~NonPolymorphicPolicy is protected!
delete w; // SAFE: Worker destructor is publicMake base destructors public and virtual for polymorphic hierarchies, or protected and non-virtual for non-polymorphic mixins.
Object Slicing in Memory: Why Value Containers (vector<Base>) Fail
Object Slicing in Memory: Why Value Containers (vector<Base>) Fail
Passing or storing polymorphic objects by value triggers Object Slicing:
- Memory Truncation: A function taking
Base ballocates a stack slot of exactlysizeof(Base). If passed a 48-byteDerivedobject, the compiler copies only the first 16 bytes. The trailing 32 bytes of derived data are physically sliced off. - VTable Overwrite: The receiving object
bis constructed as a genuineBase. Its__vptris set to point toBase's vtable, completely erasing any derived virtual function overrides. - The Fix: Never create containers of values (
std::vector<Base>). Always store smart pointers (std::vector<std::unique_ptr<Base>>) or pass references (const Base&) to preserve the full dynamic type.
class Base {
public:
virtual void speak() const { std::println("Base"); }
};
class Derived : public Base {
public:
int extra_{99};
void speak() const override { std::println("Derived extra={}", extra_); }
};
Derived d;
Base sliced = d; // OBJECT SLICING: extra_ is discarded!
sliced.speak(); // Prints 'Base' (vptr points to Base_vtable)Never pass polymorphic objects by value or store them by value in containers; pass const Base& or store std::unique_ptr<Base>.
C++11 override & final: Eliminating Silent Overload Bugs & Devirtualization
C++11 override & final: Eliminating Silent Overload Bugs & Devirtualization
Prior to C++11, typos in virtual function signatures caused silent bugs:
override(Compile-time contract check):- Problem: Missing a
constor writingunsigned intinstead ofintsilently created an entirely new function instead of overriding the base method. - Solution: Adding
overrideinstructs the compiler to halt compilation if the method fails to match an exact base virtual signature.
- Problem: Missing a
final(Optimizer accelerator):- Contract: Prevents further inheritance on a class (
class Leaf final : public Base) or overriding of a method. - Optimization: Tells the compiler that no further derived types can ever exist, allowing it to perform devirtualizationβturning indirect vtable lookups into direct, inlinable calls.
- Contract: Prevents further inheritance on a class (
class BaseDevice {
public:
virtual void calibrate(int level) const = 0;
};
class PressureSensor : public BaseDevice {
public:
// void calibrate(int level) override; // COMPILE ERROR: missing 'const'!
void calibrate(int level) const override final { /* Devirtualized by compiler! */ }
};Always mark virtual overrides with override; mark leaf classes or non-overridable methods final to enable devirtualization.
Name Hiding Across Scopes & Resolution with 'using Base::fn'
Name Hiding Across Scopes & Resolution with 'using Base::fn'
In C++, name lookup happens before overload resolution:
- Scope Search Halts Early: The compiler looks for function names from the derived scope upward. The moment it finds a matching identifier name in
Derived, it stops searchingβcompletely hiding base overloads with the same name, even if their argument types differ. - The Bug: If
Baseprovidescompute(double)andDeriveddeclarescompute(int), callingd.compute(3.14)does not call the base version. It silently truncates3.14to3and callsDerived::compute(int). - The Fix (
using Base::fn;): Declareusing Base::compute;inside the derived class body. This pulls all base overloads into the derived scope so they participate in normal overload resolution.
class BaseCalculator {
public:
void compute(double d); // Overload 1
};
class DerivedCalculator : public BaseCalculator {
public:
using BaseCalculator::compute; // Brings BaseCalculator::compute into derived scope!
void compute(int i); // Overload 2
};
DerivedCalculator calc;
calc.compute(3.14); // Correctly invokes compute(double), not compute(int)!When overloading an inherited function name in a derived class, add 'using Base::fn;' to prevent name hiding.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.