Skip to content

01 · OOP Deep Dive

Level 1 Module 7 covered a single class in isolation. Real programs need classes that relate to each other: a Circle and a Square that are both Shapes, a FileLogger that can stand in anywhere a Logger is expected. That is what inheritance and polymorphism give you — one interface, many implementations, with the decision about which implementation runs deferred until runtime.

Inheritance basics

#include <iostream>
#include <string>

class Animal {
public:
    Animal(std::string name) : name(name) {}

    void eat() const {
        std::cout << name << " is eating." << std::endl;
    }

protected:
    std::string name;   // accessible in this class AND in derived classes
};

// Dog "is-a" Animal
class Dog : public Animal {
public:
    Dog(std::string name) : Animal(name) {}   // must initialise the base part

    void bark() const {
        std::cout << name << " says Woof!" << std::endl;   // 'name' is protected, so visible here
    }
};

int main() {
    Dog rex("Rex");
    rex.eat();    // inherited from Animal
    rex.bark();   // Dog's own method
}
// Output:
// Rex is eating.
// Rex says Woof!

Three access specifiers now matter, not two:

Specifier Inside the class In derived classes Outside
public yes yes yes
protected yes yes no
private yes no no

The : public Animal part is the inheritance mode. public inheritance means "Dog is-a Animal" and is what you want almost every time. private inheritance means "Dog is implemented in terms of Animal" and is rare — prefer composition (holding an Animal member) for that.

A derived constructor must construct the base subobject first. If you don't name it in the initializer list, the compiler calls the base's default constructor — and fails to compile if there isn't one, which is exactly what happens above if you omit : Animal(name).

Virtual functions and polymorphism

Without virtual, C++ picks which function to call based on the static type of the expression — the type the compiler sees, not the object actually there.

#include <iostream>

class Shape {
public:
    virtual double area() const { return 0.0; }   // virtual => runtime dispatch
    void describe() const {                       // NOT virtual
        std::cout << "A generic shape" << std::endl;
    }
    virtual ~Shape() = default;                   // see "the two big traps" below
};

class Circle : public Shape {
public:
    Circle(double r) : r(r) {}
    double area() const override { return 3.14159265 * r * r; }
    void describe() const { std::cout << "A circle" << std::endl; }
private:
    double r;
};

int main() {
    Circle c(2.0);
    Shape& s = c;   // a Shape reference bound to a Circle object

    std::cout << s.area() << std::endl;   // 12.5664 -- virtual: calls Circle::area
    s.describe();                         // "A generic shape" -- NOT virtual: calls Shape's
}

That second call is the classic trap. describe is resolved at compile time from the declared type Shape&, so you get the base version even though the object really is a Circle. Mark anything you intend to override virtual.

The override keyword is not decoration — it makes the compiler verify that you really are overriding something. Misspell the name, get the const wrong, or change a parameter type, and you silently create a brand-new function instead of an override. With override, that becomes a compile error. Use it on every override you write.

Abstract classes and pure virtual functions

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

class Shape {
public:
    virtual double area() const = 0;          // pure virtual -- no implementation here
    virtual std::string name() const = 0;
    virtual ~Shape() = default;

    // A non-virtual helper implemented in terms of the virtual interface
    void report() const {
        std::cout << name() << " has area " << area() << std::endl;
    }
};

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

class Circle : public Shape {
public:
    Circle(double r) : r(r) {}
    double area() const override { return 3.14159265 * r * r; }
    std::string name() const override { return "Circle"; }
private:
    double r;
};

int main() {
    // Shape s;   // compile error: cannot instantiate an abstract class

    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
    shapes.push_back(std::make_unique<Circle>(1.0));

    double total = 0.0;
    for (const auto& shape : shapes) {
        shape->report();          // runtime dispatch to the right override
        total += shape->area();
    }
    std::cout << "Total area: " << total << std::endl;
}
// Output:
// Rectangle has area 12
// Circle has area 3.14159
// Total area: 15.1416

= 0 makes a function pure virtual, which makes the class abstract: you cannot create an object of it, only of a derived class that implements every pure virtual. This is how C++ expresses an interface.

Note the container type: std::vector<std::unique_ptr<Shape>>, not std::vector<Shape>. You cannot store polymorphic objects by value in a container of the base type — see the next section for why. Smart pointers get their own treatment in Module 6.

The two big traps: slicing and missing virtual destructors

#include <iostream>

class Base {
public:
    virtual void speak() const { std::cout << "Base" << std::endl; }
    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void speak() const override { std::cout << "Derived" << std::endl; }
    int extraData = 42;
};

void byValue(Base b)            { b.speak(); }   // SLICES the argument
void byReference(const Base& b) { b.speak(); }   // no slicing

int main() {
    Derived d;
    byValue(d);        // "Base"    -- the Derived part was chopped off
    byReference(d);    // "Derived" -- correct
}

Object slicing: copying a Derived into a Base-typed variable copies only the Base subobject. extraData is gone and virtual dispatch reverts to the base. The rule that follows: pass polymorphic types by reference or pointer, never by value. (Module 8 of Level 1 covers the reference syntax itself.)

class BadBase {
public:
    virtual void work() {}
    ~BadBase() { std::cout << "~BadBase" << std::endl; }   // NOT virtual -- bug
};

class BadDerived : public BadBase {
public:
    ~BadDerived() { std::cout << "~BadDerived" << std::endl; }
};

int main() {
    BadBase* p = new BadDerived();
    delete p;   // undefined behaviour: ~BadDerived never runs, its members leak
}
// Output (typical):
// ~BadBase

If you ever delete a derived object through a base pointer, the base destructor must be virtual. Rule of thumb: any class with a virtual function should also have a virtual destructor (or a protected non-virtual one, if you never intend polymorphic deletion). virtual ~Shape() = default; costs one line and prevents a whole family of leaks.

final, and how dispatch actually works

class Widget {
public:
    virtual void draw() const;
    virtual ~Widget() = default;
};

class Button final : public Widget {   // nobody may derive from Button
public:
    void draw() const override;
};

Under the hood, each polymorphic class gets a vtable — a table of function pointers — and each object stores a hidden pointer to it. A virtual call costs one extra pointer indirection: read the vtable pointer, index into it, call. That is cheap, but it usually blocks inlining. final lets the compiler prove no further overrides exist and devirtualise the call back into a direct one.

Calling the base implementation

Overriding does not have to mean replacing:

#include <iostream>
#include <string>

class Employee {
public:
    Employee(std::string name, double base) : name(name), base(base) {}
    virtual double pay() const { return base; }
    virtual ~Employee() = default;
protected:
    std::string name;
    double base;
};

class Manager : public Employee {
public:
    Manager(std::string name, double base, double bonus)
        : Employee(name, base), bonus(bonus) {}

    double pay() const override {
        return Employee::pay() + bonus;   // explicit qualification calls the base version
    }
private:
    double bonus;
};

int main() {
    Manager m("Ada", 5000, 1500);
    Employee& e = m;
    std::cout << e.pay() << std::endl;   // 6500
}

Employee::pay() with the explicit class qualifier bypasses virtual dispatch and calls that specific implementation — the standard way to extend rather than replace behaviour.

Cheat sheet

Concept Syntax What it buys you
Public inheritance class D : public B "D is-a B"; D usable wherever B is
Virtual function virtual void f(); Runtime dispatch on the real object type
Override void f() override; Compiler-checked override
Pure virtual virtual void f() = 0; Abstract class / interface
Virtual destructor virtual ~B() = default; Safe delete through a base pointer
final class D final / void f() final Blocks further derivation; enables devirtualisation
protected protected: int x; Visible to derived classes, not to the outside

How It Actually Works

A base class with virtual functions gains a hidden member: the vptr (virtual table pointer), typically the first 8 bytes of every object of that type. It points at a vtable — one per class, not per object, generated once by the compiler — an array of function-pointer slots, one per virtual function. When a derived class overrides a function, its vtable has that slot pointing at the derived implementation instead of the base one; slots it doesn't override still point at the inherited base implementation.

Calling shape->area() on a Shape* that actually points at a Circle compiles into: load the vptr from the object, index into the vtable at the fixed slot assigned to area, and call whatever address is there — an extra memory load and an indirect call compared to a plain function call, which is the real (small but nonzero) cost of virtual dispatch. Crucially, which object the vptr points at determines which vtable is consulted, at runtime — that's the entire mechanism behind polymorphism: the pointer's static type (Shape*) only matters for what the compiler will allow you to call; the vptr installed by whichever constructor actually ran (Circle's) determines what runs.

This is also why constructors can't dispatch virtually: while Shape's constructor is running, the object's vptr still points at Shape's vtable (the derived constructor hasn't run yet to install Circle's), so calling a virtual function from a base constructor invokes the base version even if the concrete object is a Circle. A base class destructor that isn't marked virtual is a real bug for the same reason in reverse: delete basePtr; without a virtual destructor looks up no vtable at all — it calls the destructor whose address was hardcoded at compile time from the static type, skipping the derived class's cleanup and leaking any resources it owned.

Exercise

Build a small shape hierarchy. Define an abstract class Shape with pure virtual double area() const, double perimeter() const, and std::string name() const, plus a virtual destructor. Derive Rectangle, Circle, and Triangle (use Heron's formula for the triangle's area). Store several of them in a std::vector<std::unique_ptr<Shape>>, print a table of name / area / perimeter, and report which shape has the largest area.

Then break it deliberately: remove virtual from ~Shape, add a std::vector<double> member to one derived class, and run the program with g++ -std=c++17 -fsanitize=address shapes.cpp -o shapes. Observe the reported leak, put virtual back, and confirm it disappears.