Lesson 16 of 25

Inheritance

Inheritance Means "Is A"

Inheritance lets one class be defined in terms of another: class Circle : public Shape says that a Circle has everything a Shape has, plus whatever it adds. The derived class gets the base's members and can be used anywhere the base is expected.

Beginners are usually taught that the point is code reuse, and that framing leads people astray. The real point is substitutability: anywhere a function accepts a Shape, you may hand it a Circle, and everything must still work. That is why public inheritance is described as an is-a relationship — a circle genuinely is a shape, a savings account is an account, a PDF invoice is a document.

The test to apply before writing a colon is therefore not "do these two classes share code" but "can every user of the base class be handed the derived class without being surprised". A Square inheriting from Rectangle fails this test in a famous way: any code that sets a rectangle's width and height independently breaks on a square. Shared code is not enough of a reason.

When the answer is no, the alternative is composition: make the other class a member instead of a base. A Car is not an Engine, it has one, so Engine engine_; is the right design. This is the more flexible arrangement in general — you can hold several, swap them at run time, and you are not bound by the base class's interface. Modern C++ advice leans hard towards composition, and reaches for inheritance mainly when runtime polymorphism is genuinely needed.

Note the word public in : public Shape. C++ also allows private and protected inheritance, which do not create an is-a relationship and are rare enough that you can safely treat public as the only kind you write. Leaving the keyword out of a class gives you private inheritance by default, which is almost never what you meant — another reason to always write it.

Example
#include <iostream>
#include <string>

class Shape {
protected:
    std::string colour_;
public:
    explicit Shape(std::string colour) : colour_(std::move(colour)) {}
    virtual ~Shape() = default;                 // essential — see below
    virtual double area() const = 0;             // pure virtual: no default answer
    const std::string& colour() const { return colour_; }
};

class Circle : public Shape {                    // a Circle IS A Shape
    double radius_;
public:
    Circle(std::string colour, double r)
        : Shape(std::move(colour)), radius_(r) {}

    double area() const override {
        constexpr double PI = 3.14159265358979;
        return PI * radius_ * radius_;
    }
};

class Rectangle : public Shape {
    double width_, height_;
public:
    Rectangle(std::string colour, double w, double h)
        : Shape(std::move(colour)), width_(w), height_(h) {}

    double area() const override { return width_ * height_; }
};

// Substitutability: this works for every present and future Shape
void describe(const Shape& s) {
    std::cout << s.colour() << " shape, area " << s.area() << '\n';
}

int main() {
    Circle c("red", 5.0);
    Rectangle r("blue", 4.0, 6.0);
    describe(c);      // red shape, area 78.5398
    describe(r);      // blue shape, area 24
}
Notes
  • virtual double area() const = 0; is a pure virtual function: it declares that every shape has an area but refuses to guess what it is. A class with at least one pure virtual function is abstract and cannot be instantiated — Shape s("red"); will not compile — which is exactly right, because "a shape" with no particular kind is not a thing that exists.

How a Derived Object Is Built and Destroyed

A derived object contains a complete base object inside it, and the construction order follows from that. The base part is built first, then the derived class's members in declaration order, then the derived constructor's body runs. Destruction is the exact reverse: the derived destructor body, then the derived members, then the base.

This is why the derived constructor names the base constructor in its initialiser list: Circle(std::string c, double r) : Shape(std::move(c)), radius_(r) {}. If the base has a default constructor and you say nothing, it is called automatically. If the base has no default constructor — because it requires arguments — then the derived class must call it explicitly, and forgetting produces an error message about no matching function.

The consequence of this order that catches people out is calling a virtual function from a constructor. While the base constructor is running, the derived part does not exist yet, so C++ deliberately treats the object as being of the base type — the call resolves to the base's version even though the object will eventually be a Circle. The same happens in destructors, for the mirror-image reason. It looks like the virtual mechanism is broken. It is not; it is protecting you from calling a method on members that have not been initialised. Do not call virtual functions from constructors or destructors.

And now the rule from the previous lesson, in the setting where it actually bites. If you delete a derived object through a base-class pointer and the base destructor is not virtual, only the base part is destroyed. The derived class's std::string, its std::vector, its file handle — none of their destructors run. The program does not crash; it just leaks a little more every time, which is the hardest kind of bug to attribute. One line, virtual ~Shape() = default;, prevents it forever.

Example
#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Base {
    std::string tag;
    explicit Base(std::string t) : tag(std::move(t)) {
        std::cout << "Base built\n";
    }
    virtual ~Base() { std::cout << "Base destroyed\n"; }   // virtual!
};

struct Derived : Base {
    std::vector<int> data;
    Derived() : Base("derived"), data(1000, 0) {   // must call Base explicitly
        std::cout << "Derived built\n";
    }
    ~Derived() override { std::cout << "Derived destroyed\n"; }
};

int main() {
    {
        Derived d;
    }
    // Base built / Derived built / Derived destroyed / Base destroyed

    std::unique_ptr<Base> p = std::make_unique<Derived>();
    p.reset();
    // Both destructors run, because ~Base is virtual.
    // Without `virtual`, ~Derived would be skipped and `data` would leak.
}
Notes
  • The rule in one line: if a class has any virtual function, give it a virtual destructor. Having a virtual function means it is meant to be used through base pointers, and that is precisely the situation the virtual destructor exists for.

Object Slicing: The Bug That Loses Half Your Object

A derived object is bigger than a base object — it contains the base plus its own members. So what happens when you copy a Circle into a variable of type Shape?

Exactly what the sizes suggest. The base part is copied and the derived part is discarded. This is called object slicing, and the result is a genuine Shape, not a Circle in disguise. Its radius is gone. Its virtual functions now resolve to the base versions, because the object really is a base object now. And none of this is an error — it compiles cleanly and runs, giving wrong answers.

It happens in three common places. Assigning a derived object to a base variable. Passing a derived object to a function that takes the base by value rather than by reference or pointer. And, most damagingly, pushing derived objects into a std::vector<Base> — every element is sliced as it goes in, and your carefully built polymorphic collection is a collection of base objects.

The rule that prevents all three is short: polymorphism only works through references and pointers. A Shape& or a Shape* refers to the original object, whatever its real type, so virtual calls dispatch correctly. A Shape by value is a new, smaller object. Take parameters as const Base&, and store collections as std::vector<std::unique_ptr<Base>>.

It is worth noticing that this is not an arbitrary flaw. A std::vector<Shape> stores its elements contiguously and must know their size, and different shapes have different sizes — so there is nowhere for the extra data to go. The vector of smart pointers works because every pointer is the same size and the objects themselves live on the heap.

Example
#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Shape {
    virtual ~Shape() = default;
    virtual std::string kind() const { return "shape"; }
};

struct Circle : Shape {
    double radius = 5.0;
    std::string kind() const override { return "circle"; }
};

void byValue(Shape s)         { std::cout << s.kind() << '\n'; }   // SLICES
void byReference(const Shape& s) { std::cout << s.kind() << '\n'; } // correct

int main() {
    Circle c;

    byReference(c);          // circle
    byValue(c);              // shape   <- the Circle part was thrown away

    Shape sliced = c;        // also slices: sliced.radius does not exist
    std::cout << sliced.kind() << '\n';     // shape

    // A vector of values slices every element on the way in
    std::vector<Shape> wrong;
    wrong.push_back(c);
    std::cout << wrong[0].kind() << '\n';   // shape

    // A vector of smart pointers keeps the real types
    std::vector<std::unique_ptr<Shape>> right;
    right.push_back(std::make_unique<Circle>());
    std::cout << right[0]->kind() << '\n';  // circle
}
Notes
  • Slicing produces no warning by default, which is what makes it so effective at hiding. If a polymorphic call is inexplicably reaching the base version, check every place the object was copied — a single by-value parameter anywhere in the chain is enough.

override, Name Hiding, and Multiple Inheritance

To override a virtual function, the derived version must match the base's signature exactly — same name, same parameter types, and the same const. Get any of it wrong and you have not overridden anything; you have quietly declared a brand-new function that happens to share a name. Calls through a base pointer keep reaching the base version, and there is no error to tell you why.

The override keyword, added in C++11, exists solely to catch this. Writing double area() const override asks the compiler to verify that this really does override something in a base class, and to refuse to compile if it does not. Put it on every override you write; the day it saves you is the day you would otherwise have lost an hour to a missing const. Its companion final declares that a function may not be overridden any further, or that a class may not be inherited from at all.

A related surprise is name hiding. If a derived class declares any function called print, then all of the base class's print overloads become invisible through the derived class — even ones with completely different parameters. The compiler stops looking as soon as it finds the name in the derived scope. The error message complains that no overload matches, which sends people hunting for a type problem that does not exist. The fix is one line: using Base::print; in the derived class, which brings the base overloads back into scope.

Finally, C++ permits multiple inheritance — a class may have more than one base. It is genuinely useful for combining interfaces: classes with pure virtual functions and no data, like Printable and Serialisable. It gets difficult when two bases both inherit from a common ancestor, giving the most-derived class two copies of that ancestor's data. This is the diamond problem, and the language's answer is virtual inheritance, which makes the shared base exist once.

The pragmatic advice: inherit from at most one class with data, and as many pure-interface classes as you like. That covers essentially every real use and keeps you well away from the diamond.

Example
#include <iostream>
#include <string>

struct Base {
    virtual ~Base() = default;
    virtual double area() const { return 0.0; }
    void print() const            { std::cout << "base\n"; }
    void print(int n) const       { std::cout << "base " << n << '\n'; }
};

struct Good : Base {
    // `override` makes the compiler check this really overrides something
    double area() const override { return 42.0; }

    using Base::print;            // keep the base overloads visible
    void print() const            { std::cout << "good\n"; }
};

struct Broken : Base {
    // Missing `const`: a NEW function, not an override. `override` would
    // have rejected it at compile time.
    double area() { return 99.0; }
};

// Interfaces: pure virtual, no data — safe to combine
struct Printable    { virtual ~Printable() = default;    virtual void show() const = 0; };
struct Serialisable { virtual ~Serialisable() = default; virtual std::string toText() const = 0; };

struct Invoice : Printable, Serialisable {
    void show() const override        { std::cout << "invoice\n"; }
    std::string toText() const override { return "INV-001"; }
};

int main() {
    Good g;
    const Base& b = g;
    std::cout << b.area() << '\n';    // 42 — correctly overridden
    g.print(7);                       // works thanks to `using Base::print;`

    Broken br;
    const Base& b2 = br;
    std::cout << b2.area() << '\n';   // 0 — the base version, silently

    Invoice inv;
    inv.show();
    std::cout << inv.toText() << '\n';
}
Notes
  • Deep inheritance chains age badly. Three or four levels in, a change to the base has consequences nobody can predict, and reading any single class means reading all of its ancestors. Prefer a shallow hierarchy — often one abstract interface and its direct implementations — and use composition for everything else.
Ask AI