1. Why dynamic polymorphism is needed
When you write a program, you almost always want to make your life easier: have unified code that “does something” instead of scattering conditions like “if it's a cat — meow, if it's a dog — bark” across the project. Yes, if/else is useful, but sometimes it turns into a monstrous chain of conditions that is scary to touch: you add a new type — and run to fix a dozen places.
Dynamic polymorphism in C++ addresses exactly this pain: we describe a common interface in the base class and allow derived classes to override behavior, and method calls happen “correctly” even when we work with an object through the base type (via reference/pointer). The C++ standard even highlights the term “virtual function call”, emphasizing that this is a special kind of call that picks the implementation at runtime.
Inheritance and the meaning of is-a
Inheritance is not primarily a “way to reuse code” (although it sometimes looks like that); first and foremost it is a type relationship. If you write class Cat : public Animal, you claim: “A cat is an animal.” That means any Cat should be acceptable where an Animal is expected. This is more important than syntax.
The syntax itself is pretty calm: after the class name you put a colon and the base type name. The keyword public means the base class's public interface remains public in the derived class (for our educational purposes — this is almost always what you want).
#include <string>
struct Animal {
std::string name;
};
struct Cat : public Animal {
int lives = 9;
};
Here Cat has everything that Animal has, plus its own fields. You can think of it this way: inside Cat the base part Animal is “embedded” (sometimes called the base subobject), and next to it lie the cat's additional fields.
Small mental diagram:
flowchart TB
C[Cat object] --> B[Base part: Animal]
C --> D[Derived part: Cat fields]
2. Regular and virtual methods: different rules
A very common beginner mistake looks like this: someone makes a base class, writes a method sound() in it, then in the derived class writes a method sound() with the same name — and expects it to “just override”. But C++ is like: “I promised nothing.”
If a method is not virtual, then when called via Base&/Base* the implementation is selected by the type of the reference/pointer in the code, i.e. by the static type. This behavior can look “unfair” until you get used to the model.
Let's compare with a short example.
Without virtual: no polymorphism
Here comes the disappointing example — but it's very useful.
#include <iostream>
#include <string>
struct Animal {
std::string sound() const { return "???"; } // NOT virtual
};
struct Cat : public Animal {
std::string sound() const { return "meow"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // ??? (not meow)
}
int main() {
Cat c;
print_sound(c);
}
Why does it print "???"? Because print_sound takes const Animal&, and the compiler chooses Animal::sound() “as written”, since the method is not virtual.
With virtual: enable dynamic implementation selection
Now add virtual in the base class — and suddenly everything behaves as we intuitively expected.
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Cat : public Animal {
std::string sound() const { return "meow"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // meow
}
int main() {
Cat c;
print_sound(c);
}
Now the call a.sound() goes by virtual function rules: the implementation is selected by the real type of the object (that is, by what the object actually is at runtime). This is dynamic polymorphism.
4. override: protection against “almost overridden”
When you start writing virtual methods, a new class of errors appears: “I thought I overrode the method, but actually wrote a different one.” The reasons are usually small and annoying: forgot const, mixed up pass-by-reference/value, added or removed an & somewhere.
For this C++ has the keyword override. It is written in the derived class and means: “Compiler, please check that I am indeed overriding a virtual method from the base class.”
A very useful discipline: if you override — always write override. This is not decoration, it's insurance against bugs like “why doesn't it work”.
Signature mistake: override saves you
struct Base {
virtual int value() const { return 1; }
};
struct Derived : public Base {
// int value() override { return 2; } // compilation error: missing const
int value() const override { return 2; }
};
Here override forces the compiler to say: “Stop, friend, you intended to override but the signature doesn't match.” Without override the code could compile, and you'd get two different methods, and polymorphism would not work as you expected.
What constitutes a matching signature
| What must match | Example of a “small” difference that breaks override |
|---|---|
| Parameter list | vs |
on the method |
vs |
| Reference-ness of parameters | vs |
/ qualifiers (not diving deep here) |
rare for beginners, but also matters |
5. Static and real type: how a method is chosen
At this point beginners often feel: “Okay, virtual is some kind of magic.” To reduce the magic, keep two types in mind:
Static type — what is written in code: the type of the variable, parameter, pointer/reference. It is known to the compiler.
Real (dynamic) type — what the object actually is at runtime.
Example: if you have Cat c;, then both static and real type are Cat. But if you do Animal& a = c;, then the static type of a is Animal&, while the real type of the object it refers to is Cat.
Diagram to fix the idea:
sequenceDiagram
participant Code as Code (static type)
participant Obj as Object (real type)
Code->>Obj: Animal& a = c (c is Cat)
Note over Code: a has type Animal&
Note over Obj: object is still Cat
Code->>Obj: a.sound()
Note over Obj: with virtual choose Cat::sound()
This difference is exactly why virtual makes sense: it tells the compiler “allow choosing the implementation by the real type of the object, not by the type of the reference/pointer”.
6. Virtual call via Base& and Base*
Note: virtual dispatch is especially useful when you access an object through the base type. If you call a method directly on Cat c; — c.sound(); — then even without polymorphism it's obvious: the cat's method is called.
Dynamic polymorphism manifests in situations where you accept a parameter of type Base&/Base*, store a pointer to the base class, or return a base reference/pointer from a function.
Call via reference
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Dog : public Animal {
std::string sound() const override { return "woof"; }
};
void print_sound(const Animal& a) {
std::cout << a.sound() << '\n'; // woof
}
int main() {
Dog d;
print_sound(d);
}
Call via pointer
#include <iostream>
#include <string>
struct Animal {
virtual std::string sound() const { return "???"; }
};
struct Cat : public Animal {
std::string sound() const override { return "meow"; }
};
int main() {
Cat c;
Animal* p = &c;
std::cout << p->sound() << '\n'; // meow
}
Note p->sound(): this is an access through Animal*, i.e. through the base type, and therefore virtual dispatch decides which implementation to call.
7. Practical example: actions in a console app
To avoid the topic staying as a “zoo of cats and dogs”, let's add polymorphism to a more practical context. Imagine you previously wrote a simple console app (a hypothetical “TaskTracker”), where there's an application state object App and several actions: add task, print list, print help. A beginner usually writes one huge if/else per command string.
Now we'll take a step toward a cleaner architecture: describe the base class Action and several derived classes. We won't build complex collections of polymorphic objects (that's a separate large topic), we'll just show the virtual call mechanism itself.
Base action class
First we make the base entity “action”. It can return a name (for display) and execute itself.
#include <string>
struct App {
int tasks = 0; // conceptually: number of tasks
};
struct Action {
virtual std::string title() const { return "unknown"; }
virtual void run(App& app) const { (void)app; }
};
Both methods are virtual so that derived classes can change behavior.
Action “add task”
#include <string>
struct AddTaskAction : public Action {
std::string title() const override { return "add"; }
void run(App& app) const override {
++app.tasks;
}
};
Action “show stats”
#include <iostream>
#include <string>
struct StatsAction : public Action {
std::string title() const override { return "stats"; }
void run(App& app) const override {
std::cout << "Tasks: " << app.tasks << '\n'; // Tasks: 0 (or more)
}
};
Function that works through the base type
Here's the key point: the function doesn't know which exact action it received. It only sees Action.
#include <iostream>
void execute(const Action& action, App& app) {
std::cout << "Executing: " << action.title() << '\n';
action.run(app);
}
Now assemble a mini-scenario:
int main() {
App app;
AddTaskAction add;
StatsAction stats;
execute(add, app);
execute(stats, app);
}
And this is exactly where the intended effect happens: execute takes a const Action&, but actually calls different implementations of title() and run() depending on the real type of the passed object.
Note an important (and slightly tricky) detail: we passed add and stats by reference. This preserves the “connection” to the real object and allows virtual dispatch to work. In future lectures we'll discuss in detail which ways of passing/storing can break polymorphism expectations, but for now remember a practical rule: polymorphic use almost always means working through Base& or Base*.
8. Typical mistakes when working with virtual/override
Mistake #1: forgot to make the method virtual in the base, but expect behavior substitution.
Such code compiles and looks logical, but it invokes the base version when accessed via Base&/Base*. Because of this you can stare at the program thinking “but I definitely wrote Cat::sound()”. Fix is simple: if you expect dynamic implementation selection — the method in the base class must be virtual.
Mistake #2: the override “almost matched” the signature, and you ended up with a new method.
Classic: forgot const on the method, mixed up std::string and const std::string&, or changed a parameter type. As a result you didn't override the method, you added a different one. That's why override is not decoration but everyday seat-belt: it makes the compiler complain immediately.
Mistake #3: expecting polymorphism where there is no access via the base type.
If you create Cat c; and call c.sound(), you are already calling the cat's method — here virtual doesn't change anything fundamentally. Polymorphism shows up when you intentionally look at the object “as a Base”, i.e. use Base& or Base*.
Mistake #4: using inheritance “for shared implementation” even when it is not an is-a relationship.
Sometimes you want to inherit class Logger : public std::string or class Button : public Vector, because “it's easier to get functionality that way”. Usually this ends in odd design and unexpected limitations: the type starts promising the world something it isn't. A useful habit: before writing : public Base, say out loud “Derived is a Base”. If that sounds silly — stop and think.
Mistake #5: not specifying which method is the extension point.
When the base class has too many virtual methods, derived classes become hostages of the base, and any interface change turns into a mini-apocalypse across the project. When virtual is too rare — you return to if/else. A good engineering middle ground appears when you explicitly decide: “this behavior will vary — so the method is virtual”, and everything else remains ordinary methods.
GO TO FULL VERSION