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:
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 orconstmismatch 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 everyfin the base. Addusing Base::f;.
Exercises¶
- Implement
Shape,Circle,Rect, andTriangle. Store them instd::vector<std::unique_ptr<Shape>>, sort by area, and print. - Remove
virtualfromShape's destructor, giveCirclea destructor that prints, and delete aCirclethrough aShape*. What happens? What does-Wall(or-Wdelete-non-virtual-dtor) say? - Remove
overrideand changearea() consttoarea()inRect. What happens with and withoutoverride? - Write a
Loggerinterface withConsoleLogger,FileLogger, andMemoryLoggerimplementations, and test a class that uses aLogger&by injectingMemoryLogger. - Re-implement the shapes with
std::variantandstd::visitusing 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 overridesoverride.= 0makes a class abstract; abstract classes with only pure virtuals are interfaces.- Polymorphic bases need a
virtualdestructor and should usually be non-copyable (avoid slicing). - Prefer composition; use
std::variantfor closed sets of types.
Next: Templates →