OOP · The Four Pillars
Polymorphism
One name, many forms. Know which moment decides.
Sign in to track your score
What you'll learn
- Say whether a call is resolved at compile time or while the program runs.
- Tell overloading and overriding apart on sight, and explain the difference.
- Read any call and name the variable's type and the real object's type.
- Use dynamic_cast safely — and recognise when you don't need it.
How to use this module. Wherever you see THINK, PREDICT or SPOT THE PROBLEM, stop and answer before reading on. The answer always follows immediately — cover it with your hand if you need to. Every code sample here compiles and was run to confirm its output.
A notification service has one job: tell the user something happened.
Three things can carry that message. An email. A text message. A push alert on a phone.
All three answer to the same instruction — send() — and all three do something completely different when they get it.
Think — The system could have used three unrelated names:
sendEmail(),sendSMS(),sendPushAlert().It didn't. Every one of them is just
send().Why would anyone want that? What does the code calling
send()gain by not knowing which kind it is holding?

The gain is that the calling code stops caring. It holds something that can be sent, says send(), and the right thing happens.
One name. Many forms of behaviour. The word for that is polymorphism — poly meaning many, morph meaning form.
C++ can make that decision at two different moments, and this module is about both.
5.1 One name, many forms
A reporting tool needs to save its output. Users want PDF for printing, CSV for spreadsheets, and JSON for feeding another system.
Conceptually there is one operation: export. What actually happens differs completely each time.
That is the whole idea:
Key idea — Polymorphism means the same operation can take different forms depending on the situation.
The interesting question is when the choice gets made. C++ has two answers.
Compile-time. The compiler works it out before the program ever runs, by looking at what you passed in.
Run-time. The program works it out while running, by looking at what the object actually is.

Both are genuinely polymorphism. They just answer the question at different moments, and each has its own mechanism — overloading for the first, overriding with virtual for the second.
Quick check — Which kind of polymorphism is settled before the program starts running?
Answer — compile-time The compiler can see the arguments written in your code, so it can pick the matching function and be done. Nothing is left to decide later.
Run-time polymorphism cannot work that way, because the thing it depends on — which object actually exists — often is not known until the program is running and the user has clicked something.
5.2 Compile-time: method overloading
A search box on a shopping site needs to handle several kinds of request.
Search for a keyword. Search for a keyword but only return the top ten. Search for a keyword inside one category.
Three variations of one idea. You could name them separately:
searchByKeyword("shoes");
searchByKeywordWithLimit("shoes", 10);
searchByKeywordInCategory("shoes", "kids");
That works, and it is tedious. The caller has to remember three names for what is obviously one operation.
C++ lets you keep the name and change what goes in:
class SearchEngine {
public:
void search(std::string keyword) {
std::cout << "Searching for: " << keyword << "\n";
}
void search(std::string keyword, int limit) {
std::cout << "Searching for: " << keyword
<< " (top " << limit << ")\n";
}
void search(std::string keyword, std::string category) {
std::cout << "Searching for: " << keyword
<< " in " << category << "\n";
}
};
Writing several functions with the same name but different parameters is called overloading. When they sit inside a class, as here, they are member-function overloads — which is what the phrase "method overloading" means.
Predict — Three calls, same name. What does each one print?
> SearchEngine engine;
> engine.search("running shoes");
> engine.search("running shoes", 10);
> engine.search("running shoes", "kids");
>
Answer
Searching for: running shoes
Searching for: running shoes (top 10)
Searching for: running shoes in kids
Each call went to a different function. The compiler looked at what you handed over — one string, a string and a number, a string and a string — and matched each call to the version whose parameters fit.
Why it matters: all of that happens before the program runs. By the time the code executes, the choice is already baked in. There is no decision left to make, and no cost at run time.

What actually makes two overloads different
The parameter list. That is it — the number of parameters, or their types, or both.
Here is the trap:
class Invoice {
public:
int calculate(int x);
double calculate(int x);
};
Both take a single int. Only the return type differs, and that is not enough:
error: 'double Invoice::calculate(int)' cannot be overloaded
with 'int Invoice::calculate(int)'
Think about why from the compiler's side. At the call site you write calculate(5). Nothing there says which return type you wanted. The compiler has no way to choose, so it rejects the pair outright rather than guessing.
Remember — Overloads must differ in what goes in. What comes out plays no part in the decision.
Overloading is not overriding
These two words get mixed up constantly, and the difference matters for the rest of this module.
| Overloading | Overriding | |
|---|---|---|
| Same function name | yes | yes |
| Parameter list | must differ | must match |
| Needs inheritance | no | yes |
| Decided | at compile time | at run time |
| Chosen by | the arguments you pass | the object that exists |

Overriding is the other half of polymorphism, and it is next.
5.3 Run-time: dynamic method dispatch
A media app plays audio files, video files and podcast episodes. All three can be played. None of them plays the same way.
Start with the shape:
class Media {
public:
virtual void play() { std::cout << "Playing media\n"; }
virtual ~Media() = default;
};
class Audio : public Media {
public:
void play() override { std::cout << "Playing audio\n"; }
};
class Video : public Media {
public:
void play() override { std::cout << "Playing video\n"; }
};
Now the part worth slowing down for:
Video v;
Media* media = &v;
media->play();
Predict — The variable
mediais declared as aMedia*. The object it points at is aVideo.Which
play()runs —Media::play()orVideo::play()?
Answer — **** Playing video Read the two facts separately, because they are genuinely different facts.
The variable says Media*. That is what the calling code was told it has.
The object is a Video. That is what actually exists in memory, and it has been a Video since the moment it was created.
Because play() was marked virtual in Media, C++ does not settle the question when compiling. It waits until the line runs, looks at what the object really is, and calls that object's own version.
Deciding which function to run from the real object, while the program is running, is called dynamic method dispatch.

Why this is worth having
Audio a;
Video v;
Podcast p;
Media* library[] = { &a, &v, &p };
for (Media* item : library) item->play();
Playing audio
Playing video
Playing podcast
What happened: one loop, one call, three different behaviours. The loop holds Media* and has no idea what is really in the array.
Why it matters: add a Livestream class next month and this loop does not change. Neither does anything else that walks a list of media. The new behaviour arrives with the new class, not with edits scattered across the program.
Watch out —
Media* media = new Video();behaves exactly the same way and is what you will see in most code. The stack version above is used here to keep the focus on dispatch rather than on who deletes the object.
Take away one word
Spot the problem — The same idea with
virtualremoved:The object is still a
Video. What prints now?
> class Media {
> public:
> void play() { std::cout << "Playing media\n"; }
> };
>
> class Video : public Media {
> public:
> void play() { std::cout << "Playing video\n"; }
> };
>
> Video v;
> Media* media = &v;
> media->play();
>
Answer — **** Playing media Without virtual, C++ settles the call at compile time using the only thing it can see there: the variable's type. The variable says Media, so Media::play() runs. The Video version sits there, fully written, never called.

Nothing about the object changed between the two versions. The only thing that changed is whether the program was allowed to ask the object what it really is.
What override does
void play() override { std::cout << "Playing video\n"; }
override tells the compiler: I intend this to replace a virtual function from the base class. The compiler then checks that you really are.
Misspell the name as plya(), or give it different parameters, and you get a clear error instead of a function that quietly never runs. It costs nothing and catches a bug that is genuinely hard to spot by eye.
Quick check — Does writing
overrideon a child function make the call happen at run time?
Answer — no virtual on the base function is what enables run-time dispatch. override is a safety check on the child. Remove virtual from the base and the dispatch stops being dynamic no matter how many override keywords you add — in fact the compiler will reject the override, since there is no longer a virtual function to replace.
5.4 Upcasting, downcasting & the object's real type
An HR system tracks employees. Some are developers, who can also review code.
class Employee {
public:
virtual void describe() const { std::cout << "An employee\n"; }
virtual ~Employee() = default;
};
class Developer : public Employee {
public:
void describe() const override { std::cout << "A developer\n"; }
void reviewCode() const { std::cout << "Reviewing a pull request\n"; }
};
Payroll does not care who reviews code. It just needs employees. So it can hold a developer as a plain employee:
Developer dev;
Employee* e = &dev; // a developer, viewed as an employee
e->describe(); // A developer
Treating a child object as its parent type is called upcasting.
Notice what did not happen. The object was not converted, copied or reduced. It is the same Developer sitting in the same place. All that changed is the label on the variable looking at it — which is why describe() still prints A developer.
Two types, and they are not the same
This is the idea to get right, because most confusion about polymorphism traces back to it.
Employee* e = new Developer();
There are two types in that one line.
The type written on the variable is Employee*. It decides what you are allowed to reach through e. It is fixed the moment you write the line, and it never changes.
The type of the object created is Developer. It decides which virtual function actually runs. It is fixed when the object is created, and it never changes either.
The words for them: the type on the variable is the static type, and the type of the real object is the dynamic type or real type.

The static type is a gate, not a description. Try to use something the gate does not allow:
Developer dev;
Employee* e = &dev;
e->reviewCode();
error: 'class Employee' has no member named 'reviewCode'
The object genuinely can review code. But e is an Employee*, and Employee has no such function, so the compiler stops you. That restriction comes from the static type, and it holds even though the real type would have been perfectly happy.
Going back down
Sometimes you have an Employee* and you need the developer-only behaviour back. That is downcasting — the opposite direction.
It needs care, because the parent view fits many different children. An Employee* might point at a developer, or a designer, or a plain employee. Assuming it is a developer when it isn't would be a serious bug.
C++ has a cast that checks:
Developer dev;
Employee* e = &dev;
Developer* d = dynamic_cast<Developer*>(e);
if (d != nullptr) {
d->reviewCode(); // Reviewing a pull request
}
dynamic_cast asks the object, while the program runs, whether it really is a Developer. If yes, you get a usable pointer. If no, you get nullptr — which is why the if is not optional.
Watch it refuse:
Employee plain;
Employee* e = &plain;
Developer* d = dynamic_cast<Developer*>(e);
if (d != nullptr) {
d->reviewCode();
} else {
std::cout << "This employee is not a developer\n";
}
This employee is not a developer
Same cast, same syntax, different answer — because the object was different. That check is exactly what you are paying for.
Watch out —
static_castcan also move a pointer down a hierarchy, but it performs no run-time check. If you guess wrong it hands back a pointer that looks fine and misbehaves later. When you need to ask what an object really is,dynamic_castis the tool that actually asks.
| Upcasting | Downcasting | |
|---|---|---|
| Direction | child → parent | parent → child |
| The view becomes | more general | more specific |
| Is it safe? | always, when the inheritance is real | only if the object really is that child |
| How | plain assignment | dynamic_cast, then check for nullptr |
| Typical use | store mixed types in one list | reach behaviour only one child has |

Predict — Answer three things: 1. What is the variable's type? 2. What is the actual object's type? 3. Is this upcasting or downcasting?
> Employee* e = new Developer();
>
Employee*— the static type.Developer— the real type.- Upcasting. A more specific object is being held in a more general variable.
Answer — One more point worth carrying forward: needing dynamic_cast often means a virtual function is missing. If code keeps asking "which child is this?" in order to do something different, that difference usually belongs inside the classes as a virtual function instead.
Common Misconceptions
❌ Polymorphism means function overloading. Why it sounds right: overloading is usually taught first, and it is the easiest kind to see. What is true: overloading is only the compile-time half. Run-time polymorphism — a base pointer calling a virtual function and getting the child's version — is the half that most OOP design actually relies on.
❌ Overloading and overriding are the same thing. Why it sounds right: the words are nearly identical and both involve one name appearing more than once. What is true: overloading needs different parameters and no inheritance, and is settled at compile time. Overriding needs matching parameters and inheritance, and is settled at run time by the object.
❌ Changing the return type gives you an overload. Why it sounds right: the two functions do look different when you read them. What is true: the compiler chooses using the arguments at the call site, and the call site says nothing about the return type. Two functions differing only in return type are rejected.
❌ Once a function is virtual, the child's version runs everywhere. Why it sounds right: virtual sounds like a permanent switch on the function. What is true: it applies when the call goes through a pointer or reference to the base. Call it on a base object directly and you get the base version, because there is no child object involved at all.
❌ Writing override is what creates run-time polymorphism. Why it sounds right: it is the keyword you type in the child, right where the new behaviour lives. What is true: virtual in the base is what enables dynamic dispatch. override only asks the compiler to verify you are replacing something that exists. Useful, but not the mechanism.
❌ If a pointer is Base* , the object must be a Base . Why it sounds right: the declaration says Base, and declarations usually tell you what you have. What is true: a Base* can point at any class derived from Base. That is the entire foundation of run-time polymorphism — the variable is a view, not a description of the object.
❌ Downcasting is safe as long as the inheritance exists. Why it sounds right: the classes are related, so the conversion feels legitimate. What is true: the relationship makes it possible, not correct. A specific Employee* might point at a designer. dynamic_cast returns nullptr in that case, and skipping the check is how the crash happens.
❌dynamic_cast converts the object into the child type. Why it sounds right: "cast" suggests something is being transformed. What is true: the object is never touched. dynamic_cast only checks what the object already is and hands back a pointer of a different type when the answer is yes. A plain Employee cannot be turned into a Developer by any cast.
Interview Corner
Q1. What is polymorphism, in your own words? One operation that can behave differently depending on the situation. The caller writes a single instruction; what happens depends either on the arguments supplied (decided at compile time) or on the object being used (decided at run time).
Q2. Which of these three declarations is rejected, and why?
void log(std::string msg);
void log(std::string msg, int level);
int log(std::string msg);
The third. The first two differ in their parameter lists, so they are valid overloads. The third takes the same single std::string as the first and differs only in return type — and since a call like log("saved") says nothing about what should come back, the compiler has no way to choose. Overloads must differ in what goes in.
Q3. Why does the child's function run here?
class Renderer {
public:
virtual void draw() { std::cout << "shape outline\n"; }
virtual ~Renderer() = default;
};
class ChartRenderer : public Renderer {
public:
void draw() override { std::cout << "chart\n"; }
};
void render(Renderer& r) { r.draw(); }
ChartRenderer c;
render(c); // prints: chart
render takes a Renderer&, but the reference is bound to a ChartRenderer. Because draw() is virtual, the call is resolved from the real object at run time, so ChartRenderer::draw() runs. This works through references exactly as it does through pointers.
Q4. What changes if virtual is removed from Renderer::draw() ? The call resolves at compile time using the reference's type, so Renderer::draw() runs and prints shape outline instead. The compiler would also reject the override in ChartRenderer, since there is no virtual function left to replace.
Q5. What does this print, and why twice?
class Tracker {
public:
virtual void ping() { std::cout << "base "; }
virtual ~Tracker() = default;
};
class GpsTracker : public Tracker {
public:
void ping() override { std::cout << "gps "; }
};
GpsTracker g;
Tracker& t = g;
t.ping();
g.ping();
It prints gps gps. The second call is obvious — g is a GpsTracker. The first goes through a Tracker& bound to the same object, and because ping() is virtual the real object decides. Both calls reach the same function.
Q6. Why is a base pointer or reference useful at all, if it restricts what you can call? Because it lets one piece of code work with every type in the hierarchy, including types written after that code. A loop over Media* plays audio, video and podcasts, and keeps working when a fourth type is added. The restriction is the price of that independence, and virtual functions are how the specific behaviour still gets through.
Q7. Why is downcasting more dangerous than upcasting? Upcasting moves toward a type the object genuinely is, so it can never be wrong. Downcasting asserts the object is one particular child out of many possibilities, and that assertion can be false. dynamic_cast reports failure with nullptr rather than letting a wrong assumption through.
Q8. Is anything wrong here?
Employee* e = new Developer();
e->reviewCode();
Yes — it does not compile. reviewCode() exists only on Developer, and the static type of e is Employee*, which decides what may be reached. The real type being Developer does not help: the static type controls access, while the real type only controls which virtual function runs. Either use a virtual function declared on Employee, or obtain a Developer* with a checked dynamic_cast.
- 1.
class Exporter { public: void write(int rows) { std::cout << "A"; } void write(double megabytes) { std::cout << "B"; } }; int main() { Exporter e; e.write(5); e.write(5.0); }What does this print?
- 2.
class Uploader { public: void send() { std::cout << "generic\n"; } }; class S3Uploader : public Uploader { public: void send() { std::cout << "s3\n"; } }; int main() { S3Uploader s; Uploader* u = &s; u->send(); }What does this print?
- 3.
class Tracker { public: virtual void ping() { std::cout << "base "; } virtual ~Tracker() = default; }; class GpsTracker : public Tracker { public: void ping() override { std::cout << "gps "; } }; int main() { GpsTracker g; Tracker& t = g; t.ping(); g.ping(); }What does this print?
- 4.
The scenario
Build the core of a notification platform. It supports email, SMS and push alerts. Every one of them can be sent, and each sends differently.
The platform keeps a queue of pending notifications of mixed kinds and works through it. It also needs a way to send something more than once when the first attempt should be retried.
Around fifteen minutes. This is about where each concept belongs, not lines of code.
Work through these:
- What should the base class be, and should it be creatable on its own?
- Which function must be virtual, and why?
- Where does overriding happen?
- Where is run-time polymorphism actually doing work?
- Why is a base pointer or reference useful here?
- In the queue, what is the static type and what is the real type?
- Where does upcasting happen?
- Is downcasting needed? Why or why not?
- Where could overloading be useful?
Hint — Two questions will get you most of the way.
Can the base write a sensible
send()for everyone? If not, leave it unfinished with= 0.Does the queue need to know which kind of notification it is holding? If the answer is no, you have found the run-time polymorphism — and you probably do not need any casting.
A possible solution
class Notification {
public:
Notification(std::string to) : recipient(to) {}
virtual void send() const = 0; // every type must provide this
virtual ~Notification() = default;
protected:
std::string recipient;
};
class EmailNotification : public Notification {
public:
EmailNotification(std::string to) : Notification(to) {}
void send() const override {
std::cout << "Email to " << recipient << "\n";
}
};
class SMSNotification : public Notification {
public:
SMSNotification(std::string to) : Notification(to) {}
void send() const override {
std::cout << "SMS to " << recipient << "\n";
}
};
class PushNotification : public Notification {
public:
PushNotification(std::string to) : Notification(to) {}
void send() const override {
std::cout << "Push alert to " << recipient << "\n";
}
};
The retry behaviour belongs to the platform, not to any one notification — so it goes in two overloads of one operation:
void deliver(const Notification& n) {
n.send();
}
void deliver(const Notification& n, int retries) {
for (int i = 0; i < retries; i++) n.send();
}
Running it:
EmailNotification e("ana@site.com");
SMSNotification s("+1-555-0142");
PushNotification p("device-77");
const Notification* queue[] = { &e, &s, &p };
for (const Notification* n : queue) n->send();
std::cout << "--- retrying the SMS ---\n";
deliver(s, 2);
