Virtual Functions & Polymorphism

C++ · Lesson 17 of 32 6 min read Updated

At the end of the last lesson, a Vehicle& that referred to a Bus still called Vehicle::describe. Usually you want the opposite: code that works with "any vehicle" should automatically use the bus's own behaviour when it is given a bus. This is polymorphism (Greek for "many forms"): one interface, many implementations, chosen while the program runs.

In this lesson you will make functions virtual, mark overrides with override, build abstract interfaces with pure virtual functions, store different types in one container, and learn the two classic traps: slicing and missing virtual destructors.

virtual and override

Add virtual to a base class function to say "derived classes may replace this, and the replacement must be used even through a base reference or pointer". In the derived class, mark the replacement with override.

C++
#include <print>
#include <string>

class Vehicle {
public:
    explicit Vehicle(const std::string& number) : number_(number) {}
    virtual ~Vehicle() = default;

    virtual std::string describe() const {
        return "Vehicle " + number_;
    }

protected:
    std::string number_;
};

class Bus : public Vehicle {
public:
    Bus(const std::string& number, int seats) : Vehicle(number), seats_(seats) {}

    std::string describe() const override {
        return "Bus " + number_ + " with " + std::to_string(seats_) + " seats";
    }

private:
    int seats_;
};

void announce(const Vehicle& v) {
    std::println("Now arriving: {}", v.describe());
}

int main() {
    Vehicle van("RJ-14-XY-1111");
    Bus city_bus("HR-12-AB-3456", 52);
    announce(van);
    announce(city_bus);
}
Output
Now arriving: Vehicle RJ-14-XY-1111
Now arriving: Bus HR-12-AB-3456 with 52 seats

announce knows nothing about buses, yet it prints the bus's description. Because describe is virtual, the call is decided at run time by the actual object type. This is called dynamic dispatch. Behind the scenes, each object of a class with virtual functions carries a hidden pointer to a table of function addresses (the vtable), and a virtual call looks up the right function in that table. The cost is tiny, but it exists, which is why C++ functions are not virtual unless you ask.

override is optional but strongly recommended. If the signature does not exactly match a virtual function in the base class, the compiler reports an error instead of silently creating a new, unrelated function:

C++
class Truck : public Vehicle {
public:
    using Vehicle::Vehicle;
    std::string describe() override { return "Truck"; }   // error: missing const,
};                                                         // so it overrides nothing

Abstract classes and interfaces

Sometimes the base class has no sensible implementation. What is the area of "a shape"? A pure virtual function, written = 0, has no body and must be overridden. A class with at least one pure virtual function is abstract: you cannot create objects of it, only of derived classes that implement everything.

C++
#include <memory>
#include <numbers>
#include <print>
#include <string>
#include <vector>

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

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 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_;
    double h_;
};

class Triangle : public Shape {
public:
    Triangle(double base, double height) : base_(base), height_(height) {}
    double area() const override { return 0.5 * base_ * height_; }
    std::string name() const override { return "Triangle"; }

private:
    double base_;
    double height_;
};

int main() {
    std::vector<std::unique_ptr<Shape>> plot;
    plot.push_back(std::make_unique<Circle>(7));
    plot.push_back(std::make_unique<Rectangle>(4, 2.5));
    plot.push_back(std::make_unique<Triangle>(6, 3));

    double total = 0;
    for (const auto& shape : plot) {
        std::println("{:<10} {:>8.2f}", shape->name(), shape->area());
        total += shape->area();
    }
    std::println("{:<10} {:>8.2f}", "Total", total);
    // Shape s;      // error: Shape is abstract
}
Output
Circle       153.94
Rectangle     10.00
Triangle       9.00
Total        172.94

A container must hold one type, so the vector holds std::unique_ptr<Shape>: smart pointers to shapes, each owning one object of any derived type. std::make_unique<Circle>(7) creates a circle on the heap; the arrow -> calls a member through a pointer. You will study smart pointers properly in Smart Pointers vs new & delete. std::numbers::pi comes from <numbers> (C++20).

An abstract class containing only pure virtual functions and a virtual destructor, like Shape, is what other languages call an interface.

Always give a polymorphic base a virtual destructor

When an object is destroyed through a pointer to its base class, the base destructor must be virtual, or the derived part is never cleaned up. Formally, deleting a derived object through a base pointer without a virtual destructor is undefined behaviour.

C++
#include <memory>
#include <print>

struct Logger {
    virtual ~Logger() { std::println("~Logger"); }
};

struct FileLogger : Logger {
    ~FileLogger() override { std::println("~FileLogger: log file closed"); }
};

int main() {
    std::unique_ptr<Logger> log = std::make_unique<FileLogger>();
}   // log is destroyed here
Output
~FileLogger: log file closed
~Logger

With the virtual removed, a typical compiler would print only ~Logger, and the log file would never close. Rule: if a class has any virtual function, give it a virtual destructor, usually virtual ~Name() = default;.

Object slicing

Polymorphism works through references and pointers. If you copy a derived object into a base object, only the base part is copied; the rest is "sliced off".

C++
Bus city_bus("HR-12-AB-3456", 52);
Vehicle copy = city_bus;              // slicing: copy is a plain Vehicle
std::println("{}", copy.describe());
Output
Vehicle HR-12-AB-3456

This is why the shape vector stores pointers, and why functions take const Vehicle& rather than Vehicle.

Common mistakes

  • Forgetting virtual in the base class. The derived function then only hides the base one, and base references call the base version.
  • Small signature differences. A missing const or a different parameter type creates a new function instead of an override; override catches this.
  • No virtual destructor in a polymorphic base. Deleting through a base pointer skips the derived destructor.
  • Passing polymorphic objects by value. The object is sliced; pass by reference or store smart pointers.

Key points

  • A virtual function is chosen by the object's real type at run time, even through a base reference or pointer.
  • Mark every overriding function with override so the compiler checks the signature.
  • A pure virtual function (= 0) makes a class abstract; abstract classes define interfaces.
  • Store different derived types together as std::vector<std::unique_ptr<Base>>.
  • Give every polymorphic base class a virtual destructor, and avoid slicing by using references or pointers.

Exercise

Create an abstract class Payment with a pure virtual double fee(double amount) const and std::string method() const. Derive Upi (no fee), Card (2 percent fee) and NetBanking (flat Rs. 15). Store one of each in a std::vector<std::unique_ptr<Payment>> and print the method and fee for a payment of Rs. 2500, with two decimals.

Show solution
C++
#include <memory>
#include <print>
#include <string>
#include <vector>

class Payment {
public:
    virtual ~Payment() = default;
    virtual double fee(double amount) const = 0;
    virtual std::string method() const = 0;
};

class Upi : public Payment {
public:
    double fee(double) const override { return 0.0; }
    std::string method() const override { return "UPI"; }
};

class Card : public Payment {
public:
    double fee(double amount) const override { return amount * 0.02; }
    std::string method() const override { return "Card"; }
};

class NetBanking : public Payment {
public:
    double fee(double) const override { return 15.0; }
    std::string method() const override { return "Net banking"; }
};

int main() {
    std::vector<std::unique_ptr<Payment>> options;
    options.push_back(std::make_unique<Upi>());
    options.push_back(std::make_unique<Card>());
    options.push_back(std::make_unique<NetBanking>());

    for (const auto& p : options) {
        std::println("{:<12} fee Rs. {:.2f}", p->method(), p->fee(2500));
    }
}
Output
UPI          fee Rs. 0.00
Card         fee Rs. 50.00
Net banking  fee Rs. 15.00

The loop never checks which kind of payment it holds; each object's own fee runs through dynamic dispatch. Unused parameters are left unnamed (double) to avoid compiler warnings.

Quick Quiz

1. What makes a call through a base reference run the derived class's version?

2. What is an abstract class?

3. Why should a polymorphic base class have a virtual destructor?

4. What happens in Vehicle v = some_bus;?

Learn C++ with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call