OOP and Polymorphism

OOP and Polymorphism

Use inheritance and virtual dispatch carefully, and learn when composition is better.

OOP and Polymorphism

Base class design

class Shape {
public:
    virtual ~Shape() = default;
    virtual double area() const = 0;
};

Derived type

class Rectangle : public Shape {
public:
    Rectangle(double w, double h) : width_{w}, height_{h} {}
    double area() const override { return width_ * height_; }

private:
    double width_{};
    double height_{};
};

Main lessons

Using the interface through a base reference

double print_area(const Shape& shape) {
    return shape.area();
}

The caller does not need to know whether the concrete object is a rectangle, circle, or something else. That is the real value of runtime polymorphism.

When std::variant is simpler

If the set of alternatives is fixed, a sum type can be simpler than an inheritance tree:

using Shape = std::variant<Circle, Rectangle, Triangle>;

This avoids heap allocation and virtual dispatch in many designs.

Use a variant when the set of alternatives is closed and known ahead of time. Use inheritance when outside code should be able to add new runtime-derived types later.

Interface design rules

Composition versus inheritance

class Renderer {
public:
    void draw_circle(double radius);
};

class Button {
public:
    explicit Button(Renderer& renderer) : renderer_{renderer} {}

private:
    Renderer& renderer_;
};

This is composition: Button uses another object to do work instead of inheriting just to gain access to that behavior.

Object slicing

void print_area(Shape shape) { // takes by value, not reference
    std::cout << shape.area() << '\n';
}

Rectangle rect{3.0, 4.0};
print_area(rect); // sliced: Rectangle part is lost, Shape part is copied

When you pass a derived object by value where a base type is expected, the derived-class data is silently stripped. Always use references or pointers when working with polymorphic types.

Polymorphic containers

Use smart pointers to store mixed types through a common interface.

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>(5.0));

for (const auto& shape : shapes) {
    std::cout << shape->area() << '\n';
}

This is one of the most common real-world patterns for runtime polymorphism: a collection of objects that all satisfy an interface but have different implementations.

The final keyword

class SecureShape final : public Shape {
public:
    double area() const override { return 0.0; }
};

Marking a class final prevents further derivation. Marking an override final stops the virtual chain at that point.

class Square : public Rectangle {
public:
    double area() const override final { return side_ * side_; }

private:
    double side_{};
};

Use final when you know the class or override is not designed to be extended further, and you want the compiler to enforce that.

Practical decision rule

Example in practice

struct Point {
    int x{};
    int y{};
};

int main() {
    Point p{3, 4};
    return p.x + p.y;
}

Try this variation

Replace direct object use with a base-class reference and one virtual function call. That makes the dynamic dispatch cost and benefit visible immediately.

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

struct Square : Shape {
    int side{};
    int area() const override { return side * side; }
};