Skip to content

7. Inheritance & Polymorphism

What & why

Inheritance lets one class build on another. Combined with virtual functions, it gives runtime polymorphism: code that works with a base-class reference can call the right derived behavior without knowing the concrete type. That's how plugin systems, GUI widgets, and shape hierarchies work. It's also overused — this lesson covers both how and when.

Basic inheritance

class Animal {
public:
    explicit Animal(std::string name) : name_{std::move(name)} {}
    const std::string& name() const { return name_; }
private:
    std::string name_;
};

class Dog : public Animal {           // Dog IS-AN Animal
public:
    explicit Dog(std::string name) : Animal{std::move(name)} {}   // call base constructor
    void fetch() const { std::cout << name() << " fetches!\n"; }
};

Dog d{"Rex"};
d.fetch();
std::cout << d.name();                // inherited

Always use public inheritance for "is-a" relationships. (private/protected inheritance exist but are rare.)

Virtual functions

#include <iostream>
#include <memory>
#include <numbers>
#include <vector>

class Shape {
public:
    virtual ~Shape() = default;                   // ESSENTIAL — see below
    virtual double area() const = 0;              // pure virtual: must be overridden
    virtual std::string name() const { return "shape"; }   // has a default
};

class Circle : public Shape {
public:
    explicit Circle(double r) : r_{r} {}
    double area() const override { return std::numbers::pi * r_ * r_; }
    std::string name() const override { return "circle"; }
private:
    double r_;
};

class Rect : public Shape {
public:
    Rect(double w, double h) : w_{w}, h_{h} {}
    double area() const override { return w_ * h_; }
    std::string name() const override { return "rect"; }
private:
    double w_, h_;
};

void describe(const Shape& s) {            // works for ANY shape
    std::cout << s.name() << ": " << s.area() << '\n';
}

int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(1.0));
    shapes.push_back(std::make_unique<Rect>(2.0, 3.0));

    for (const auto& s : shapes) {
        describe(*s);                      // dispatches to Circle::area, Rect::area
    }
}
  • virtual — calls through a base reference/pointer go to the most-derived override.
  • = 0 — pure virtual; the class becomes abstract (can't be instantiated).
  • override — tells the compiler you intend to override. If the signature doesn't match, it's an error instead of a silent new function. Always write it.
  • final — no further overriding (double area() const final) or derivation (class Circle final : public Shape).

How it works

Each polymorphic class has a vtable (a table of function pointers); each object carries a hidden pointer to its class's vtable. A virtual call is one extra indirection — cheap, but it prevents inlining.

Virtual destructors

std::unique_ptr<Shape> s = std::make_unique<Circle>(1.0);
// when s is destroyed, it calls Shape's destructor...

If Shape::~Shape() isn't virtual, deleting a Circle through a Shape* is undefined behavior — the Circle part is never destroyed. Any class meant to be used polymorphically needs a public virtual destructor.

Object slicing

Polymorphism only works through references or pointers. Copying a derived object into a base object slices off the derived part:

Circle c{1.0};
Shape s = c;          // won't compile here (Shape is abstract), but with a concrete base:
                      // the copy is a plain base object; virtual calls go to the BASE version

std::vector<Shape> v;               // can't hold Circles — use vector<unique_ptr<Shape>>
void describe(Shape s);             // slices! take const Shape& instead

A common defense is to disable copying in polymorphic base classes:

class Shape {
public:
    Shape() = default;
    virtual ~Shape() = default;
    Shape(const Shape&) = delete;
    Shape& operator=(const Shape&) = delete;
    // ...
};

Calling the base version

std::string name() const override {
    return "big " + Shape::name();     // explicitly call the base implementation
}

Constructors, destructors, and virtual calls

During the base class constructor, the derived part doesn't exist yet. Virtual calls inside constructors and destructors don't dispatch to derived overrides. Don't rely on them.

protected

Members visible to derived classes but not to outside code. Use sparingly — protected data couples derived classes to base internals. Protected functions (hooks for derived classes) are more defensible.

Interfaces

An abstract class with only pure virtual functions plus a virtual destructor is an interface:

class Logger {
public:
    virtual ~Logger() = default;
    virtual void log(std::string_view message) = 0;
};

class ConsoleLogger final : public Logger {
public:
    void log(std::string_view m) override { std::cout << m << '\n'; }
};

class MemoryLogger final : public Logger {        // handy in tests
public:
    void log(std::string_view m) override { lines.emplace_back(m); }
    std::vector<std::string> lines;
};

class OrderService {
public:
    explicit OrderService(Logger& log) : log_{log} {}   // depends on the interface
    void place() { log_.log("order placed"); }
private:
    Logger& log_;
};

dynamic_cast

Safely downcast when you must know the concrete type:

if (auto* c = dynamic_cast<const Circle*>(&shape)) {
    // it's a Circle
}

Needing dynamic_cast often means a virtual function is missing from the base. Use it rarely.

Composition over inheritance

Inheritance is the tightest coupling C++ offers. Prefer composition ("has-a") unless you genuinely need substitutability:

// Instead of: class Car : public Engine
class Car {
    Engine engine_;      // Car HAS-AN engine
    std::vector<Wheel> wheels_;
};

And for polymorphism over a closed set of types, std::variant + std::visit avoids heap allocation and virtual calls:

#include <variant>

struct Circle { double r; };
struct Rect { double w, h; };
using Shape = std::variant<Circle, Rect>;

double area(const Shape& s) {
    return std::visit([](const auto& sh) -> double {
        using T = std::decay_t<decltype(sh)>;
        if constexpr (std::is_same_v<T, Circle>) return 3.14159 * sh.r * sh.r;
        else return sh.w * sh.h;
    }, s);
}

std::vector<Shape> shapes{Circle{1}, Rect{2, 3}};   // values, no pointers

Rule of thumb: virtual functions for open sets (plugins, new types added later), std::variant for closed sets (a known list of message types, AST nodes).

Common mistakes

  • Missing virtual destructor in a polymorphic base.
  • Missing override — a typo or const mismatch silently creates a new function.
  • Slicing — passing/storing polymorphic objects by value.
  • Calling virtual functions from constructors expecting derived behavior.
  • Deep hierarchies — more than two or three levels is a smell.
  • Inheriting for code reuse where composition fits better.
  • Hiding base overloads — declaring f(int) in the derived class hides every f in the base. Add using Base::f;.

Exercises

  1. Implement Shape, Circle, Rect, and Triangle. Store them in std::vector<std::unique_ptr<Shape>>, sort by area, and print.
  2. Remove virtual from Shape's destructor, give Circle a destructor that prints, and delete a Circle through a Shape*. What happens? What does -Wall (or -Wdelete-non-virtual-dtor) say?
  3. Remove override and change area() const to area() in Rect. What happens with and without override?
  4. Write a Logger interface with ConsoleLogger, FileLogger, and MemoryLogger implementations, and test a class that uses a Logger& by injecting MemoryLogger.
  5. Re-implement the shapes with std::variant and std::visit using an "overloaded" lambda set. Compare the two designs: which is easier to add a new shape to? A new operation?

Recap

  • class D : public B — D is-a B and inherits B's interface.
  • virtual + references/pointers = runtime polymorphism. Always mark overrides override.
  • = 0 makes a class abstract; abstract classes with only pure virtuals are interfaces.
  • Polymorphic bases need a virtual destructor and should usually be non-copyable (avoid slicing).
  • Prefer composition; use std::variant for closed sets of types.

Next: Templates →