C++ Inheritance Design: Access Specifiers, Virtual Destructors, Hiding and Diamonds
Key takeaways
Use public inheritance only for substitutable is-a relationships, give polymorphic bases a virtual or protected destructor, mark every override, pull hidden overloads back with using, pass polymorphic objects by reference, and know who constructs a virtual base.
What inheritance is for
Inheritance in C++ does two separate jobs that are easy to conflate:
- Interface inheritance: a
Derivedcan be used wherever aBase&is expected, and virtual functions pick the derived behavior at runtime. This is what polymorphism means. - Implementation inheritance:
DerivedgetsBase’s data members and member functions for free.
Public inheritance gives you both. That is exactly why it is so easy to misuse: people reach for it to reuse code (job 2) and accidentally promise substitutability (job 1). This article is about the design decisions and failure modes around hierarchies. The mechanics of dynamic dispatch, vtables and pure virtual functions are covered separately in C++ virtual functions.
All diagnostics below come from g++ 10.3 with -std=c++17 -Wall -Wextra unless another flag is named.
is-a vs has-a: when public inheritance is wrong
Public inheritance should mean “every Derived is a Base and can be used anywhere a Base is used without surprising the caller” (the Liskov substitution principle). The classic counterexample is Square : public Rectangle: a caller that sets width and height independently on a Rectangle& breaks the square’s invariant. Nothing in the language stops you; the code compiles and the bug lives in behavior.
A useful test: would you be comfortable if every function that takes a const Base& received a Derived? If the honest answer is “only some of them,” you want composition:
// Reuses std::vector's storage but should NOT be usable as a vector
class Stack {
std::vector<int> items_; // has-a
public:
void push(int v) { items_.push_back(v); }
int pop() { int v = items_.back(); items_.pop_back(); return v; }
std::size_t size() const { return items_.size(); }
};
Composition keeps Stack’s interface exactly as large as you write it. With class Stack : public std::vector<int>, every caller could insert into the middle, and a std::vector<int>* could point at a Stack and delete it through std::vector’s non-virtual destructor.
Access specifiers: public, protected, private inheritance
The inheritance access level caps what the base’s members become in the derived class and, more importantly, who can convert Derived* to Base*:
| Inheritance | Base public members become | Base protected become | Who can convert Derived* → Base* |
|---|---|---|---|
public | public | protected | Everyone |
protected | protected | protected | Derived, its friends, and classes derived from it |
private | private | private | Only Derived and its friends |
Private members of the base are never accessible to the derived class under any mode; they are still part of the object, just not nameable.
struct Stack : private std::vector<int> {
using std::vector<int>::push_back; // re-export selected members
using std::vector<int>::size;
};
struct Base { virtual ~Base() = default; };
struct Impl : protected Base {};
int main() {
Stack s; s.push_back(1); // OK: re-exported
std::vector<int>* v = &s; // error
Impl i; Base* b = &i; // error
s.clear(); // error
}
error: 'std::vector<int>' is an inaccessible base of 'Stack'
error: 'Base' is an inaccessible base of 'Impl'
error: 'void std::vector<_Tp, _Alloc>::clear() [with _Tp = int; _Alloc = std::allocator<int>]' is inaccessible within this context
Private inheritance is “implemented in terms of” and blocks the dangerous conversion. It is still usually worse than a member, because it pulls the base’s names into your scope and couples you to its protected interface. Reach for it only when you need something a member cannot give you: overriding one of the base’s virtual functions, accessing its protected members, or the empty base optimization for stateless policy classes (C++20 [[no_unique_address]] now covers that last case for members too).
Virtual destructors: deleting through a base pointer
If a class will be deleted through a pointer to its base, the base destructor must be virtual. Otherwise the behavior is undefined, and in practice only the base destructor runs:
struct Base {
virtual void run() {}
~Base() { std::puts("~Base"); } // not virtual
};
struct Derived : Base {
std::vector<int> buf = std::vector<int>(1000);
~Derived() { std::puts("~Derived"); }
};
int main() {
Base* p = new Derived;
delete p; // warns
std::unique_ptr<Base> u = std::make_unique<Derived>(); // same bug, no warning
}
warning: deleting object of polymorphic class type 'Base' which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor]
Output:
~Base
~Base
~Derived never ran, so buf’s 1000 ints leaked twice. Notice that the smart-pointer version produced no warning: the delete happens inside std::default_delete in a system header, where g++ suppresses the diagnostic. That is the version you actually meet in modern code. -Wnon-virtual-dtor (not in -Wall) flags the class definition instead of the delete expression, which does catch it:
warning: 'struct Base' has virtual functions and accessible non-virtual destructor [-Wnon-virtual-dtor]
The rule has two correct forms:
- Public and virtual (
virtual ~Base() = default;) for bases that are owned and deleted polymorphically. - Protected and non-virtual for bases that are only used as a mixin or interface and never deleted through a base pointer. The compiler then rejects
delete base_ptroutright.
A derived destructor is automatically virtual when the base’s is; you do not need to repeat virtual, and writing ~Derived() override is allowed but uncommon. More detail, including what “undefined” looks like with multiple inheritance, is in missing virtual destructor.
Virtual calls in constructors and destructors do not dispatch down
During Base’s constructor, the Derived members have not been constructed yet, so C++ treats the object as a Base for the duration. Virtual calls resolve to Base’s version. The same happens in reverse in the destructor.
struct Widget {
Widget() { std::cout << "init as " << name() << '\n'; }
virtual ~Widget() { std::cout << "destroy as " << name() << '\n'; }
virtual std::string name() const { return "Widget"; }
};
struct Button : Widget {
std::string label = "OK";
std::string name() const override { return "Button(" + label + ")"; }
};
int main() { Button b; std::cout << "in main: " << b.name() << '\n'; }
init as Widget
in main: Button(OK)
destroy as Widget
This is a design protection, not an inconvenience: if the call did dispatch to Button::name, it would read label before it was constructed. With a pure virtual function the base version does not exist, and the call is undefined behavior. g++ only catches the direct case:
warning: pure virtual 'virtual void Task::run()' called from constructor
If the constructor calls a non-virtual helper that then calls run(), there is no warning, and with GCC the program aborts at runtime with “pure virtual method called” followed by “terminate called without an active exception”. The pattern I have seen most often in real code is a framework base class whose constructor calls init() or registerSelf(), expecting subclasses to customize it. It looks fine until the first subclass overrides init() and discovers its override never runs from there. The fix is a two-phase setup: a factory function that constructs the object fully, then calls the virtual initialization, or passes the configuration as constructor arguments instead of asking the subclass for it.
Name hiding: an override that removes the other overloads
Any declaration of a name in the derived class hides all base members with that name, regardless of signature:
struct Shape {
virtual void draw(int) { std::cout << "Shape::draw(int)\n"; }
virtual void draw(double) { std::cout << "Shape::draw(double)\n"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
void draw(int) override { std::cout << "Circle::draw(int)\n"; }
};
int main() {
Circle c;
c.draw(2.5); // which one?
Shape& s = c;
s.draw(2.5);
}
Circle::draw(int)
Shape::draw(double)
Through a Circle, only Circle::draw(int) is visible, so 2.5 is silently converted to 2. Through a Shape&, the double overload is found. Same object, same argument, different function, and -Wall -Wextra says nothing. -Woverloaded-virtual (not in -Wall for GCC 10) catches it:
warning: 'virtual void Shape::draw(double)' was hidden [-Woverloaded-virtual]
note: by 'virtual void Circle::draw(int)'
The fix is one line in the derived class:
struct Circle : Shape {
using Shape::draw; // bring every Shape::draw back into scope
void draw(int) override;
};
override and final: make the compiler check your intent
Without override, a signature mismatch creates a brand-new virtual function and the “override” is never called. With it, mismatches become errors:
struct Base { virtual void update(float dt) const; virtual ~Base() = default; };
struct A : Base { void update(float dt) override; }; // forgot const
struct B final : Base { void update(float dt) const override; };
struct C : B {}; // B is final
struct D : Base { void update(float) const final; };
struct E : D { void update(float) const override; }; // D::update is final
error: 'void A::update(float)' marked 'override', but does not override
error: cannot derive from 'final' base 'B' in derived type 'C'
error: virtual function 'virtual void E::update(float) const' overriding final function
Missing const, float vs double, a changed reference qualifier, or a base signature changed in a refactor are the usual culprits. Put override on every override; -Wsuggest-override will point out the ones you missed. Use final on leaf classes when you want to close the hierarchy; besides the API guarantee, it lets the compiler devirtualize calls through that type. See override and final for more.
Slicing: polymorphism needs references or pointers
Copying a derived object into a base-class value copies only the base part:
struct Event { virtual ~Event() = default;
virtual std::string describe() const { return "event"; } };
struct Click : Event { int x = 3, y = 4;
std::string describe() const override {
return "click at " + std::to_string(x) + "," + std::to_string(y); } };
void log_event(Event e) { std::cout << e.describe() << '\n'; } // by value
int main() {
Click c;
log_event(c); // prints "event"
std::vector<Event> events;
events.push_back(c);
std::cout << events[0].describe() << '\n'; // prints "event"
}
No warning at any common level. Take polymorphic parameters as const Event&, and store polymorphic collections as std::vector<std::unique_ptr<Event>>. A cheap safeguard for hierarchy roots is to make copy operations protected or deleted in the base, which turns accidental slicing into a compile error. Object slicing covers the partial-assignment variant, which is even harder to spot.
Multiple and virtual inheritance
Inheriting several interfaces (classes with only pure virtual functions and a virtual destructor) is common and unproblematic. Inheriting several implementations that share a common base is where it gets hard:
struct Device { int id = 0;
Device() { std::cout << "Device()\n"; }
Device(int i) : id(i) { std::cout << "Device(" << i << ")\n"; } };
struct Input : Device { Input() : Device(1) {} };
struct Output : Device { Output() : Device(2) {} };
struct Terminal : Input, Output {};
Terminal t; // Device(1), Device(2): two Device subobjects
t.id; // error: request for member 'id' is ambiguous
virtual inheritance merges the shared base into a single subobject, and it comes with a rule that surprises almost everyone: the most-derived class constructs the virtual base, and the intermediate classes’ initializers for it are ignored.
struct VInput : virtual Device { VInput() : Device(1) {} };
struct VOutput : virtual Device { VOutput() : Device(2) {} };
struct VTerminal : VInput, VOutput {};
struct VTerminal2 : VInput, VOutput { VTerminal2() : Device(42) {} };
Device()
id=0
Device(42)
id=42
VTerminal never mentioned Device, so the default constructor ran and both Device(1) and Device(2) were skipped. If Device had no default constructor, VTerminal would fail to compile, which is at least visible. When I have had to debug virtual bases, this has been the part that bit: someone adds a new most-derived class and the carefully chosen initialization in the middle layer silently stops happening. Virtual bases also add a pointer or offset lookup to reach the shared base and make casts more expensive. Unless you really need a shared stateful base, prefer interfaces plus composition. The diamond problem article walks through the layouts in detail.
Rules for class hierarchies
- Public inheritance only for true substitutability; composition for reuse.
- Every polymorphic base: public virtual destructor, or protected non-virtual one.
overrideon every override,finalwhere the hierarchy should end.using Base::f;whenever you override one of several overloads.- No virtual calls that are meant to reach derived code from constructors or destructors.
- Pass and store polymorphic objects by reference or smart pointer, never by base value.
- Virtual inheritance only when a single shared base is required, and initialize it in the most-derived class.