C++ Programming
Lesson 19 of 24

Lesson 19 · 24 lesson course

19. Inheritance

Achieving code reuse through inheritance

Programming glossary 80 terms mentioned in this lesson

Select any term for a clear definition. The return button brings you back to the exact term link you used.

Abstract foundational module branching into specialised derived structures that share a common core.

 

 

Lesson: Inheritance in C++ — Reuse, Hierarchies & Pitfalls

In procedural code, you reuse functions. In object orientation, reuse also includes state and the behaviours that regulate it. Inheritance lets a derived class reuse the interface, behaviour, and representation of a base class. In C++, classes can inherit in chains, and from multiple bases. This power enables structure and maintainability—but C++ is multi-paradigm with direct memory access, so you can easily bypass encapsulation. Design discipline is essential.

Programming Insight (AI) — Sketch the Hierarchy
  • Describe your classes; ask AI to suggest a minimal base interface and which parts should be composed instead of inherited.
  • Have AI check “is-a?” (LSP) for each proposed inheritance edge; flag violations.

1) When to Inherit (and when not to)

  • Use inheritance when a derived type is-a base type and should be substitutable (LSP).
  • Prefer composition when you just need to reuse behaviour or implementation details.
  • Keep base interfaces small and stable; each virtual adds a new path to maintain and test.
// Good: is-a
struct Shape { virtual ~Shape() = default; virtual double area() const = 0; };

struct Circle : Shape {
  double r{};
  double area() const override { return 3.14159265358979323846 * r * r; }
};

// Prefer composition (has-a) instead of inheriting a Logger just to use its method
class Service {
  Logger log_;   // compose
public:
  void run();
};
Programming Insight (AI) — Compose vs Inherit Decision
  • Paste two classes; ask AI if the relationship is “is-a” or “has-a”, with a short justification and suggested code change.

2) Inheritance Modes: public / protected / private

These control how base members are viewed through the derived class:

  • public: public→public, protected→protected (most common for “is-a”).
  • protected: public/protected of base become protected in derived’s interface.
  • private: public/protected of base become private (an implementation detail; not “is-a”).
struct B { public: void api(); protected: void hook(); };
struct D1 : public    B {}; // D1 is-a B (B::api() remains public via D1)
struct D2 : protected B {}; // B's API becomes protected via D2
struct D3 : private   B {}; // B's API not exposed via D3

3) Virtual Functions, override, final & virtual destructors

  • Mark base functions that should be overridden as virtual. In derived classes use override (catches signature mistakes).
  • Use final on a class or virtual to prevent further derivation/override (can also help optimisation).
  • Always give a polymorphic base a virtual destructor to delete via base pointers safely.
struct Base {
  virtual ~Base() = default;
  virtual void step(int) = 0;
};

struct Derived final : Base {
  void step(int) override { /* ... */ }
  // void step(double) override; // ❌ error; signature mismatch caught by 'override'
};
Programming Insight (AI) — Enforce Overrides
  • Ask AI to add override/final where appropriate and surface accidental hiding (e.g., differing const/ref qualifiers).

4) Construction/Destruction Order & Virtual Calls

  • Base subobjects construct first, then derived; destruction is the reverse.
  • Don’t call virtuals from constructors/destructors expecting derived behaviour—only the current subobject is active.
struct B {
  B(){ init(); }                     // calls B::init, not D::init
  virtual void init(){ /* base init */ }
  virtual ~B() = default;
};
struct D : B {
  void init() override { /* derived init */ } // won't run from B ctor
};

5) Multiple Inheritance & the Diamond Problem

C++ allows multiple inheritance. Ambiguity arises when two bases share a common base (the “diamond”). Use virtual inheritance to share a single base subobject.

struct A { int id = 0; };
struct B : virtual A {};
struct C : virtual A {};
struct D : B, C {
  void f(){ id = 42; } // unambiguous: only one A subobject
};
Programming Insight (AI) — Resolve the Diamond
  • Paste a diamond; ask AI to add virtual bases and proper base initialisers in the most-derived ctor.

6) Object Slicing & Storing Polymorphic Objects

Passing/returning derived objects by value to a base parameter slices away the derived part. Use references or smart pointers.

void draw(const Shape&);                       // ✅ reference
std::vector<std::unique_ptr<Shape>> scene;     // ✅ owning polymorphic container
// std::vector<Shape> wrong;                   // ❌ slices
Programming Insight (AI) — Prevent Slicing
  • Ask AI to scan for pass-by-value of derived into base, and replace with references or unique_ptr as appropriate.

7) Fragile Base & the NVI Pattern

Changes to a base can break derived classes unexpectedly (fragile base class problem). Keep bases stable and minimal. Consider the Non-Virtual Interface (NVI) pattern: expose a public non-virtual that enforces invariants, then delegate to a private/protected virtual.

class Pipeline {
public:
  void run() final { pre(); step(); post(); }   // invariant enforced here
protected:
  virtual void step() = 0;                      // customisable part
private:
  void pre(){ /* checks */ }
  void post(){ /* metrics */ }
};
Programming Insight (AI) — Apply NVI
  • Give AI a base with many virtuals; get a refactor where public non-virtuals enforce contracts and call narrow virtual hooks.

8) Name Hiding, using Declarations & Access

  • A derived declaration with the same name hides all base overloads. Re-expose with using Base::name;.
  • Use protected sparingly—every protected member is part of your subclass API surface.
struct B { void f(int); void f(double); };
struct D : B {
  using B::f;       // bring both overloads into D's scope
  void f(std::string);
};

9) Alternatives to Runtime Inheritance: Static Polymorphism (CRTP)

For zero-overhead reuse and inlining, use templates or CRTP (Curiously Recurring Template Pattern). No vtables; decisions at compile time.

template <typename Derived>
struct AlgoBase {
  void run(){ static_cast<Derived*>(this)->step(); }
};

struct MyAlgo : AlgoBase<MyAlgo> {
  void step(){ /* ... */ }
};
Programming Insight (AI) — Turn Virtuals into Policies
  • Ask AI to replace a runtime-polymorphic base with a templated policy or CRTP when performance & closed set of types allow.

10) Performance & Layout Notes

  • Virtual dispatch adds an indirection; small cost per call, bigger cost if it blocks inlining/vectorisation.
  • final methods/classes and whole-program optimisation can enable devirtualisation.
  • Empty Base Optimisation (EBO) can remove storage for empty bases (useful in CRTP/policy classes).

11) Worked Example — Polymorphic Shapes

#include <memory>
#include <vector>

struct Shape {
  virtual ~Shape() = default;
  virtual double area() const = 0;
};

struct Rect : Shape {
  double w{}, h{};
  double area() const override { return w * h; }
};

struct Circle : Shape {
  double r{};
  double area() const override { return 3.14159265358979323846 * r * r; }
};

double total_area(const std::vector<std::unique_ptr<Shape>>& v) {
  double s = 0;
  for (auto& p : v) s += p->area();   // no slicing; virtual dispatch
  return s;
}

12) Mini Exercises

  1. Find the bug: A base without a virtual dtor is deleted via Base*. Fix the destructor, add overrides.
  2. Stop the ladder: Replace a long if/else by a base + derived types (or a dispatch table if inheritance isn’t justified).
  3. Eliminate slicing: Convert a std::vector<Base> to std::vector<std::unique_ptr<Base>>; update call sites.
  4. Diamond fix: Introduce virtual inheritance and proper base initialisation in the most-derived constructor.
Programming Insight (AI) — Inheritance Review Pass
  • Ask AI for a checklist run: virtual dtor in polymorphic base, override everywhere, no slicing, composition preferred, protected surface minimal.

Summary Checklist

  • Use inheritance only for true is-a relationships; otherwise, prefer composition.
  • Keep base interfaces small and stable; enforce invariants (NVI).
  • Mark overrides with override; add a virtual destructor to polymorphic bases; use final where appropriate.
  • Avoid slicing; store polymorphic objects as references or smart pointers.
  • Understand multiple inheritance and the diamond; use virtual bases deliberately or avoid the pattern.
  • Consider CRTP/templates for zero-overhead static polymorphism when the set of types is known.

Perspective: C++ isn’t a pure OO language; it gives you OO tools alongside low-level control. That mix is why it’s powerful—and why careful design matters.

Advanced perspective: every inheritance edge makes a promise to somebody

Public inheritance is justified by substitutability, not by the number of lines it reuses

Inheritance can reuse implementation, but public inheritance declares something more important: a derived object may be treated as an object of its public base type. That conversion is useful only if the derived type continues to honour the expectations attached to the base interface. Similar names, shared members and a tidy diagram do not establish that relationship.

Before drawing an arrow from Player to Entity, identify the caller who will use an Entity and the promises that caller relies upon. Can every proposed derived object satisfy every permitted base operation? Does it preserve the meaning of results, failures and state changes? If the caller must discover the derived type before it can use the object correctly, the base has not provided the advertised abstraction.

Inheritance argumentWhat it establishesWhat remains unproved
Both types contain an identifier.There is duplicated representation.That one type satisfies the other's behavioural contract.
The nouns form an "is a" sentence.The domain language suggests a possible relationship.That every base operation remains meaningful for the derived type.
A base pointer can refer to the object.The language permits the conversion for public inheritance.That ownership, destruction and virtual behaviour are correct.
The hierarchy removes repeated code.Some implementation is shared.That future base changes will not impose the wrong dependency.

A derived object contains a base subobject

#include <iostream>

class Entity {
public:
    explicit Entity(int identifier) : identifier_{identifier} {}

    int identifier() const {
        return identifier_;
    }

private:
    int identifier_;
};

class Player : public Entity {
public:
    Player(int identifier, int score) : Entity{identifier}, score_{score} {}

    int score() const {
        return score_;
    }

private:
    int score_;
};

void print_identity(const Entity& entity) {
    std::cout << entity.identifier() << '\n';
}

int main() {
    const Player player{7, 120};
    print_identity(player);
    std::cout << player.score() << '\n';
}

The Player object is one complete object containing an Entity base subobject and its own score_ member. The Player constructor supplies 7 to the base constructor and 120 to the member initialiser. Base construction completes before derived members and before the derived constructor body. The complete object is therefore built from an established base part outward.

When print_identity(player) binds its const Entity&, the reference designates the base subobject within the existing player. It does not create a separate copied entity. The non-virtual identifier() operation is available through that base interface and prints 7. Back in main, the player interface prints its score, 120.

Program pointComplete objectView used by the expressionObserved result
After constructionOne Player with an Entity base subobject.The local name has type const Player.Identifier 7 and score 120 exist in their respective parts.
Inside print_identityThe same player remains alive.A const Entity& refers to its base subobject.identifier() returns 7.
Final outputThe same player remains alive.The derived interface is used again.score() returns 120.

Destruction follows the reverse structural order. The derived part is destroyed before its base subobject. This lets derived cleanup use an existing base part and ensures that the base does not disappear while derived destruction is still under way. It also explains why construction and destruction are not ordinary moments for virtual dispatch to a not-yet-constructed or already-destroyed derived part.

Write the base contract before testing a derived type

Substitutability is sometimes reduced to the phrase "is a" and left there. The useful work begins when the base contract is stated. A derived override must accept calls permitted by the base, preserve the base's promised results and invariants, and avoid introducing surprising obligations that a base caller could not know. The derived type may offer additional operations or stronger guarantees, but it cannot make a previously valid base use invalid without changing the abstraction.

Contract partQuestion for every derived typeFailure signal
Accepted inputsDoes the derived operation accept every input the base permits?It rejects a normal base call solely because of its exact type.
PostconditionDoes success still mean what the base promised?The same return value now describes a weaker or different result.
InvariantDoes the derived state remain compatible with base observations?A base query can observe a state the base says is impossible.
Failure behaviourAre errors reported within the base's stated model?Callers need derived-type tests to interpret failure correctly.

This does not require every base to publish a formal contract language. It does require enough behavioural precision that tests can exercise a derived object through the base interface. If the only test calls derived-specific functions, it has not tested substitution.

Object slicing is a value operation, not partial polymorphism

Initialising a separate base object from a derived object copies the base portion into a new base value. The derived members are not part of that destination object. This is object slicing. It can be deliberate when the program genuinely wants an independent base value, but it cannot preserve the derived identity or behaviour expected from runtime polymorphism.

A base reference or pointer can preserve access to the base subobject within the complete derived object. An owning smart pointer to the base can preserve dynamic identity when the design allocates objects polymorphically. These choices answer different ownership questions; replacing every sliced value with a raw pointer would merely exchange one problem for another.

FormWhat exists after the operationDerived identity retained?
Entity entity = player;A new, independent Entity value copied from the base portion.No.
const Entity& entity = player;No new entity; a reference to the player's base subobject.Yes, while the player remains alive.
std::unique_ptr<Entity> owning a derived objectOne allocated complete object owned through the base interface.Yes, with correct polymorphic destruction policy.

Destruction policy follows permitted ownership

If a Shape* designates a separately allocated Circle and callers are allowed to execute delete shape;, destruction must reach the complete Circle. A public virtual destructor in the base expresses that policy. Without it, deletion through that base pointer does not correctly destroy the derived object and has undefined behaviour.

That does not mean every base class mechanically needs a public virtual destructor. A base used only as a non-owning interface may prohibit deletion through the base, for example with a protected non-virtual destructor. A non-polymorphic base-subobject relationship may never involve virtual calls or base-pointer ownership at all. Ask who may destroy the object and through which static type. The answer determines the destructor policy.

Hiding, overriding and overloading are different events

A derived declaration with the same name as base functions can hide the base overload set during lookup, even when its parameter list differs. That is name hiding. Overriding requires a matching virtual function contract, including relevant qualifiers. Overloading supplies multiple declarations under one name for different parameter forms. Treating all three as "the derived version" makes compiler errors look arbitrary.

Use override whenever an override is intended. If the parameter type, const qualifier or reference qualifier fails to match, the compiler rejects the declaration instead of silently creating a different function. A using Base::draw; declaration can deliberately reintroduce hidden base overloads into the derived scope. These keywords do not design the hierarchy, but they make the chosen design more checkable.

final has a narrower meaning. On a virtual function it prevents further overrides; on a class it prevents further derivation. It is not a decorative synonym for complete, safe or non-virtual.

Correction to the earlier Pipeline example: void run() final is ill-formed when run is not virtual. A function marked final must override a virtual function. In the Non-Virtual Interface pattern, the public run is normally non-virtual and unmarked; it enforces the stable sequence and calls selected protected or private virtual hooks. The compiler rejection is useful evidence that the declaration and the claimed pattern do not agree.

Composition avoids promises that are not required

A car uses an engine as part of its responsibility. Making the car publicly inherit from the engine would expose engine operations as car operations and invite substitution where none is intended. Composition says only that the car contains or collaborates with an engine. It allows the car to restrict, coordinate or replace that implementation without claiming to satisfy the engine interface itself.

Private inheritance can also reuse implementation while withholding the public base relationship, and empty bases have specialised layout uses. Yet composition usually makes ownership and delegation more visible. Choose inheritance when access to protected members, virtual customisation or another language property is part of the design, not because delegation requires a few explicit functions.

Keep the customisation surface smaller than the public story

Every protected data member and virtual hook becomes part of the contract offered to derived authors. A base change can therefore break code outside the base's ordinary public callers. The Non-Virtual Interface pattern reduces that surface: a public non-virtual operation enforces ordering and invariants, while narrow virtual steps supply permitted variation. It does not remove the need to document those steps, but it prevents an override from replacing the entire public contract by accident.

Multiple inheritance needs the same restraint. Combining independent interface roles can be coherent. Combining stateful bases that share a common base can create multiple base subobjects and ambiguous access. Virtual inheritance can arrange one shared virtual base, whose initialisation is ultimately the responsibility of the most-derived class. That mechanism solves a particular object-model problem; it does not prove that the resulting hierarchy is understandable.

Performance follows the required substitution model

Virtual dispatch commonly involves an indirect call and can inhibit some optimisations. Templates and other static techniques can expose concrete types to the compiler. Neither fact decides the architecture in isolation. Runtime-loaded implementations, binary interfaces or heterogeneous owning collections may require runtime substitution. A closed set of operations in performance-critical code may suit compile-time variation.

Measure the real call path before replacing a clear interface with templates or CRTP. The dispatch cost may be material, hidden by larger work, or outweighed by code size and build complexity. "Zero overhead" is a claim about a particular generated program and workload, not a magic property of angle brackets.

Programming Insight (AI): demand a caller, a contract and a destruction path

When AI proposes inheritance, ask which caller consumes the base interface and list that interface's accepted inputs, postconditions, invariants and failure meanings. Run the same tests against every derived implementation through a base reference. Then ask how each complete object is owned and which static type may be used to destroy it.

Compare a composition design. Count not only repeated lines but exposed operations, protected dependencies, lifetime edges and cases where a caller must inspect the dynamic type. If the hierarchy is justified only by related nouns or shared fields, the proposed base has not earned authority over its derived types.

Transfer task: test the promise before accepting the hierarchy

A base Counter promises that every successful call to increment() increases value() by exactly one. Someone proposes BoundedCounter : public Counter, but at its limit the override silently leaves the value unchanged while reporting success. Write the observation a base caller would make and explain precisely which promise is broken.

Redesign the relationship in two ways. First, change the base contract so reaching a limit is an explicit result that every implementation may report. Second, keep the original Counter contract and build a bounded component by composition that refuses the request before delegating. For each design, state who benefits from the common interface, whether base-pointer deletion is allowed, and which tests must run through the base view. The fewer lines version does not win automatically. The version whose promises are true does.

Reveal answer

A base caller does not need to know the derived type to expose the fault:

Counter& counter = bounded;
const auto before = counter.value();
const bool reported_success = counter.increment();
const auto after = counter.value();

// At the bound:
// reported_success == true
// after == before

The broken implication is reported_success => after == before + 1. The override has not merely chosen a different implementation. It has changed the meaning of success while a caller is relying upon the base meaning.

Design one: make the limit part of the common contract

#include <cstdint>

enum class IncrementResult {
    incremented,
    at_limit
};

class Counter {
public:
    virtual ~Counter() = default;

    [[nodiscard]] virtual IncrementResult increment() = 0;
    [[nodiscard]] virtual std::uint64_t value() const noexcept = 0;
};

This interface promises two observable outcomes. If the result is incremented, the new value is the old value plus exactly one. If the result is at_limit, the value is unchanged. A bounded implementation can now report its limit honestly, and an implementation limited only by the numeric representation can use the same result at UINT64_MAX.

The common interface benefits callers that genuinely know how to handle either outcome without inspecting the dynamic type. If objects are owned and deleted through Counter*, the public virtual destructor makes that operation valid. Contract tests must run against every implementation through a Counter&: a normal increment must report incremented and add one; a limit result must report at_limit and change nothing; repeated limit calls must remain unchanged. An owning test should also destroy each derived object through std::unique_ptr<Counter>.

Design two: retain the exact counter and compose the bound

#include <cstdint>
#include <limits>
#include <stdexcept>

class ExactCounter final {
public:
    explicit ExactCounter(std::uint64_t initial = 0) noexcept
        : value_{initial} {}

    void increment()
    {
        if (value_ == std::numeric_limits<std::uint64_t>::max()) {
            throw std::overflow_error{"counter cannot be incremented"};
        }
        ++value_;
    }

    [[nodiscard]] std::uint64_t value() const noexcept {
        return value_;
    }

private:
    std::uint64_t value_ = 0;
};

class BoundedCounter {
public:
    BoundedCounter(std::uint64_t initial, std::uint64_t limit);

    [[nodiscard]] bool try_increment()
    {
        if (counter_.value() == limit_) {
            return false;
        }
        counter_.increment();
        return true;
    }

    [[nodiscard]] std::uint64_t value() const noexcept {
        return counter_.value();
    }

private:
    ExactCounter counter_;
    std::uint64_t limit_ = 0;
};

The exact-counter overflow policy is now explicit: an increment at the representation limit throws std::overflow_error and leaves the value unchanged. Its tests therefore include the maximum value as well as ordinary successful increments. BoundedCounter::try_increment() does not reach that exceptional path because a valid constructor establishes counter_.value() <= limit_ and the method refuses the request when equality is reached.

The BoundedCounter constructor rejects initial > limit. At the limit, try_increment() refuses the request before delegating, so ExactCounter::increment() is called only when its original promise can be kept. The bounded component is deliberately not substitutable for ExactCounter. There is therefore no conversion to an ExactCounter* and no deletion of a bounded object through that static type.

Clients that require an increment to succeed retain the smaller exact-counter interface. Clients that understand refusal use the bounded component and test its Boolean result. The bounded tests run through BoundedCounter, not through an invented base view: below the limit, success adds one; at the limit, refusal changes nothing; construction above the limit fails. The two designs serve different callers. The first earns polymorphism by sharing the limit-aware contract. The second preserves the stronger original contract by declining to claim that relationship.

Inheritance changes object layout, construction, lookup, destruction and the obligations visible to callers. Use it when those changes express a real substitutable relationship. Code reuse is welcome after that decision; it is not the decision.