Module 04: Polymorphism
Goal: Master compile-time and runtime polymorphism, virtual functions, vtable, operator overloading, and RTTI. Time: 2 days of focused study Prerequisites: Module 01-03
Table of Contents
- What is Polymorphism?
- Compile-Time Polymorphism
- Runtime Polymorphism
- Virtual Functions — Under the Hood
- Pure Virtual Functions & Abstract Classes
- Operator Overloading
- RTTI — Runtime Type Information
- Compile-Time vs Runtime Polymorphism
- Covariant Return Types
- Common Mistakes
- Practice Problems
- Interview Questions
1. What is Polymorphism?
Polymorphism = "many forms." The same interface behaves differently depending on the underlying type.
Real-World Analogy: A REMOTE CONTROL
"Press Play" (same interface):
On a DVD player → plays a movie
On a music player → plays a song
On a game console → resumes the game
Same button, different behavior depending on the device.
Two Types:
COMPILE-TIME (Static / Early Binding):
Decision made at compile time.
→ Function overloading
→ Operator overloading
→ Templates
RUNTIME (Dynamic / Late Binding):
Decision made at runtime.
→ Virtual functions
→ Function overriding (via base pointer/reference)2. Compile-Time Polymorphism
Function Overloading
Same function name, different parameter lists. Resolved at compile time.
class Printer {
public:
// Same name, different parameter types
void print(int val) {
cout << "Integer: " << val << endl;
}
void print(double val) {
cout << "Double: " << val << endl;
}
void print(const string& val) {
cout << "String: " << val << endl;
}
void print(int val, int base) {
cout << "Int in base " << base << ": ";
// ... print in given base
}
};
int main() {
Printer p;
p.print(42); // Calls print(int)
p.print(3.14); // Calls print(double)
p.print("hello"s); // Calls print(const string&)
p.print(255, 16); // Calls print(int, int)
// The compiler decides WHICH function to call based on the arguments.
// This is resolved at COMPILE TIME (static dispatch).
}Overloading Rules
CAN overload based on:
✅ Number of parameters
✅ Type of parameters
✅ const/non-const (for member functions)
✅ lvalue/rvalue reference
CANNOT overload based on:
❌ Return type only
❌ Default arguments that make calls ambiguous// ❌ These are AMBIGUOUS:
int getVal() { return 1; }
double getVal() { return 1.0; }
// Error: functions only differing by return type can't be overloaded
// ❌ AMBIGUOUS with default arguments:
void foo(int x, int y = 10) {}
void foo(int x) {}
// foo(5); — Which one? Ambiguous!Templates (Generic Programming)
// Write code ONCE that works with ANY type
template <typename T>
T getMax(T a, T b) {
return (a > b) ? a : b;
}
int main() {
cout << getMax(3, 7) << endl; // int version generated at compile time
cout << getMax(3.14, 2.72) << endl; // double version generated
cout << getMax('a', 'z') << endl; // char version generated
}
// Class template
template <typename T>
class Stack {
vector<T> data;
public:
void push(const T& val) { data.push_back(val); }
T pop() {
if (data.empty()) throw runtime_error("Stack empty!");
T val = data.back();
data.pop_back();
return val;
}
bool empty() const { return data.empty(); }
size_t size() const { return data.size(); }
};
int main() {
Stack<int> intStack;
Stack<string> strStack;
intStack.push(42);
strStack.push("hello");
}3. Runtime Polymorphism
Runtime polymorphism is achieved through virtual functions and base class pointers/references.
class Shape {
public:
virtual double area() const = 0;
virtual string name() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
string name() const override { return "Circle"; }
};
class Rectangle : public Shape {
double w, h;
public:
Rectangle(double w, double h) : w(w), h(h) {}
double area() const override { return w * h; }
string name() const override { return "Rectangle"; }
};
class Triangle : public Shape {
double base, height;
public:
Triangle(double b, double h) : base(b), height(h) {}
double area() const override { return 0.5 * base * height; }
string name() const override { return "Triangle"; }
};
// THIS FUNCTION DOESN'T KNOW THE ACTUAL TYPE!
// It works with ANY shape — current or FUTURE shapes!
void printShapeInfo(const Shape& shape) {
cout << shape.name() << ": area = " << shape.area() << endl;
}
int main() {
Circle c(5);
Rectangle r(4, 6);
Triangle t(3, 8);
// All treated as Shape — the CORRECT method is called at RUNTIME
printShapeInfo(c); // Circle: area = 78.5398
printShapeInfo(r); // Rectangle: area = 24
printShapeInfo(t); // Triangle: area = 12
// Polymorphic collection
vector<unique_ptr<Shape>> shapes;
shapes.push_back(make_unique<Circle>(10));
shapes.push_back(make_unique<Rectangle>(5, 3));
shapes.push_back(make_unique<Triangle>(6, 4));
double totalArea = 0;
for (const auto& shape : shapes) {
totalArea += shape->area(); // Calls the right area() for each
}
cout << "Total area: " << totalArea << endl;
}4. Virtual Functions — Under the Hood
The vtable (Virtual Table)
When a class has virtual functions, the compiler creates a VTABLE:
a lookup table of function pointers for that class.
Each OBJECT gets a hidden pointer called VPTR (virtual pointer)
that points to its class's vtable.
class Animal {
virtual void speak() {} // slot 0
virtual void eat() {} // slot 1
};
class Dog : public Animal {
void speak() override {} // replaces slot 0
// eat() inherited // keeps slot 1
virtual void fetch() {} // slot 2 (new)
};
class Cat : public Animal {
void speak() override {} // replaces slot 0
void eat() override {} // replaces slot 1
};
VTABLE for Animal:
[0] → Animal::speak()
[1] → Animal::eat()
VTABLE for Dog:
[0] → Dog::speak() ← overridden!
[1] → Animal::eat() ← inherited
[2] → Dog::fetch() ← new
VTABLE for Cat:
[0] → Cat::speak() ← overridden!
[1] → Cat::eat() ← overridden!
Object layout:
Animal obj: [vptr → Animal_vtable | other_data]
Dog obj: [vptr → Dog_vtable | Animal_data | Dog_data]
Cat obj: [vptr → Cat_vtable | Animal_data | Cat_data]How Virtual Dispatch Works
Animal* ptr = new Dog();
ptr->speak();
// Compiler generates (pseudocode):
// 1. Get the vptr from the object: vptr = ptr->__vptr
// 2. Look up the function in vtable: func = vptr[0] (speak is slot 0)
// 3. Call the function: func(ptr)
//
// Since ptr points to a Dog, __vptr points to Dog_vtable,
// so vptr[0] is Dog::speak(), not Animal::speak()!
// This is "dynamic dispatch" — the function call is DISPATCHED
// at RUNTIME based on the actual object type.Performance Cost of Virtual Functions
Virtual function call:
1. Load vptr from object (memory access)
2. Index into vtable (memory access)
3. Call through function pointer (indirect call)
Total: ~2 extra memory accesses vs direct call
Non-virtual function call:
1. Call function directly (address known at compile time)
Cost: ~1-5 nanoseconds per virtual call overhead
Usually negligible unless in a very tight loop.
Memory overhead:
- One vptr per object (~8 bytes on 64-bit)
- One vtable per CLASS (not per object — shared)5. Pure Virtual Functions & Abstract Classes
class PaymentProcessor {
public:
// Pure virtual — MUST be implemented by derived classes
virtual bool processPayment(double amount) = 0;
virtual bool refund(double amount) = 0;
virtual string getProviderName() const = 0;
// Non-pure virtual — has default implementation
virtual void logTransaction(double amount, bool success) {
cout << "[" << getProviderName() << "] $" << amount
<< (success ? " SUCCESS" : " FAILED") << endl;
}
virtual ~PaymentProcessor() = default;
};
class StripeProcessor : public PaymentProcessor {
public:
bool processPayment(double amount) override {
// Call Stripe API...
bool success = true;
logTransaction(amount, success);
return success;
}
bool refund(double amount) override {
// Call Stripe refund API...
return true;
}
string getProviderName() const override { return "Stripe"; }
};
class PayPalProcessor : public PaymentProcessor {
public:
bool processPayment(double amount) override {
// Call PayPal API...
bool success = true;
logTransaction(amount, success);
return success;
}
bool refund(double amount) override { return true; }
string getProviderName() const override { return "PayPal"; }
};
// Works with ANY payment processor — now or in the future!
void checkout(PaymentProcessor& processor, double total) {
if (processor.processPayment(total)) {
cout << "Payment successful!" << endl;
}
}6. Operator Overloading
Operator overloading lets you define how operators (+, -, <<, ==, etc.) work with your custom types.
Basic Operators
class Vector2D {
double x, y;
public:
Vector2D(double x = 0, double y = 0) : x(x), y(y) {}
// Addition: vec1 + vec2
Vector2D operator+(const Vector2D& other) const {
return Vector2D(x + other.x, y + other.y);
}
// Subtraction: vec1 - vec2
Vector2D operator-(const Vector2D& other) const {
return Vector2D(x - other.x, y - other.y);
}
// Scalar multiplication: vec * 3.0
Vector2D operator*(double scalar) const {
return Vector2D(x * scalar, y * scalar);
}
// Equality: vec1 == vec2
bool operator==(const Vector2D& other) const {
return x == other.x && y == other.y;
}
bool operator!=(const Vector2D& other) const {
return !(*this == other);
}
// Negation: -vec
Vector2D operator-() const {
return Vector2D(-x, -y);
}
// Compound assignment: vec1 += vec2
Vector2D& operator+=(const Vector2D& other) {
x += other.x;
y += other.y;
return *this;
}
// Subscript: vec[0] = x, vec[1] = y
double& operator[](int index) {
if (index == 0) return x;
if (index == 1) return y;
throw out_of_range("Index must be 0 or 1");
}
// Stream output (must be friend because left operand is ostream)
friend ostream& operator<<(ostream& os, const Vector2D& v) {
os << "(" << v.x << ", " << v.y << ")";
return os;
}
// Scalar * vec (reverse order — must be friend/non-member)
friend Vector2D operator*(double scalar, const Vector2D& v) {
return v * scalar;
}
};
int main() {
Vector2D a(3, 4), b(1, 2);
Vector2D c = a + b; // (4, 6)
Vector2D d = a * 2.0; // (6, 8)
Vector2D e = 3.0 * a; // (9, 12)
bool eq = (a == b); // false
cout << c << endl; // (4, 6)
cout << -a << endl; // (-3, -4)
a += b;
cout << a << endl; // (4, 6)
cout << a[0] << endl; // 4
}Comparison Operators (C++20 Spaceship)
#include <compare>
class Version {
int major, minor, patch;
public:
Version(int ma, int mi, int pa) : major(ma), minor(mi), patch(pa) {}
// C++20: one operator generates all comparisons!
auto operator<=>(const Version& other) const = default;
// Generates: ==, !=, <, >, <=, >=
};
// Pre-C++20, you had to write each one manuallyIncrement/Decrement
class Counter {
int value;
public:
Counter(int v = 0) : value(v) {}
// Pre-increment: ++counter (returns modified object)
Counter& operator++() {
value++;
return *this;
}
// Post-increment: counter++ (returns old value)
Counter operator++(int) { // 'int' is a dummy parameter to distinguish
Counter old = *this;
value++;
return old;
}
friend ostream& operator<<(ostream& os, const Counter& c) {
return os << c.value;
}
};
int main() {
Counter c(5);
cout << ++c << endl; // 6 (increments, then returns)
cout << c++ << endl; // 6 (returns, then increments)
cout << c << endl; // 7
}Function Call Operator (Functors)
// A class that can be "called" like a function
class Multiplier {
int factor;
public:
Multiplier(int f) : factor(f) {}
int operator()(int x) const {
return x * factor;
}
};
int main() {
Multiplier triple(3);
Multiplier doubler(2);
cout << triple(10) << endl; // 30
cout << doubler(10) << endl; // 20
// Functors can be used with STL algorithms:
vector<int> nums = {1, 2, 3, 4, 5};
transform(nums.begin(), nums.end(), nums.begin(), triple);
// nums = {3, 6, 9, 12, 15}
}Conversion Operators
class Fraction {
int num, den;
public:
Fraction(int n, int d) : num(n), den(d) {}
// Implicit conversion to double
operator double() const {
return static_cast<double>(num) / den;
}
// Explicit conversion to bool
explicit operator bool() const {
return num != 0;
}
};
int main() {
Fraction f(3, 4);
double d = f; // 0.75 (implicit conversion)
// bool b = f; // ❌ Error if explicit
if (f) { ... } // ✅ explicit operator bool works in conditions
}What CAN'T Be Overloaded
CANNOT overload:
:: (scope resolution)
. (member access)
.* (member pointer access)
?: (ternary)
sizeof
typeid
CANNOT create NEW operators (no ** or @@)
CANNOT change operator precedence or associativity
CANNOT change the number of operands (+ is always binary or unary)7. RTTI
Runtime Type Information — checking the actual type of an object at runtime.
#include <typeinfo>
class Animal {
public:
virtual ~Animal() = default; // MUST have virtual function for RTTI!
};
class Dog : public Animal {};
class Cat : public Animal {};
int main() {
Animal* a = new Dog();
// ---- typeid ----
cout << typeid(*a).name() << endl; // "Dog" (implementation-defined)
if (typeid(*a) == typeid(Dog)) {
cout << "It's a dog!" << endl;
}
// ---- dynamic_cast ----
// Safely cast base to derived (returns nullptr if wrong type)
Dog* d = dynamic_cast<Dog*>(a);
if (d) {
cout << "Successfully cast to Dog" << endl;
// Use d as Dog*
}
Cat* c = dynamic_cast<Cat*>(a);
if (c) {
cout << "It's a cat" << endl;
} else {
cout << "NOT a cat" << endl; // ← This one
}
// dynamic_cast with references (throws bad_cast on failure)
try {
Cat& catRef = dynamic_cast<Cat&>(*a);
} catch (const bad_cast& e) {
cout << "Cast failed: " << e.what() << endl;
}
delete a;
}
/*
dynamic_cast vs static_cast:
static_cast:
- Compile-time check only
- No runtime overhead
- UNSAFE for downcasting (base→derived) — undefined behavior if wrong type!
dynamic_cast:
- Runtime check (uses RTTI)
- Slight performance overhead
- SAFE — returns nullptr or throws bad_cast if wrong type
- Requires at least one virtual function in the base class
PREFER dynamic_cast for downcasting.
But BETTER: avoid downcasting entirely — use polymorphism instead.
*/8. Compile-Time vs Runtime
Feature │ Compile-Time │ Runtime
──────────────────┼────────────────────────┼────────────────────────
Also called │ Static, Early binding │ Dynamic, Late binding
Resolved at │ Compile time │ Runtime
Mechanism │ Overloading, templates │ Virtual functions
Speed │ Faster (no indirection)│ Slight overhead (vtable)
Flexibility │ Types known at compile │ Types can vary at runtime
Keyword │ (none needed) │ virtual
Errors │ Caught at compile time │ Caught at runtime
Example │ add(int) vs add(double)│ shape->area() calls the right one
WHEN TO USE WHICH:
Compile-time:
✅ Performance-critical code
✅ Types are known at compile time
✅ Generic programming (templates)
Runtime:
✅ Heterogeneous collections (vector<Shape*>)
✅ Plugin architectures (types not known at compile time)
✅ Framework/library code (users define new types)9. Covariant Return Types
class Animal {
public:
virtual Animal* clone() const {
return new Animal(*this);
}
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
// Return type can be a DERIVED type of the base's return type!
Dog* clone() const override { // Returns Dog*, not Animal*
return new Dog(*this);
}
};
int main() {
Dog d;
Dog* copy = d.clone(); // Returns Dog* directly (no casting needed)
Animal* a = &d;
Animal* aCopy = a->clone(); // Returns Animal* (but actually a Dog*)
delete copy;
delete aCopy;
}
// Covariant return types let derived classes return a more specific type
// while still satisfying the base class interface.10. Common Mistakes
// ❌ MISTAKE 1: Forgetting `virtual` for polymorphism
class Base {
public:
void speak() { cout << "Base" << endl; } // NOT virtual!
};
class Derived : public Base {
public:
void speak() { cout << "Derived" << endl; }
};
Base* p = new Derived();
p->speak(); // "Base" ← Wrong! Not virtual, so no dynamic dispatch.
// ❌ MISTAKE 2: Calling virtual functions in constructor/destructor
class Base {
public:
Base() {
init(); // Calls Base::init(), NOT Derived::init()!
}
virtual void init() { cout << "Base init" << endl; }
};
class Derived : public Base {
public:
void init() override { cout << "Derived init" << endl; }
};
// During Base construction, the object IS a Base (Derived part not yet constructed)
// ❌ MISTAKE 3: Overloading when you mean to override
class Base {
virtual void process(int x) {}
};
class Derived : public Base {
void process(double x) {} // This is OVERLOADING (different type), not overriding!
// void process(int x) override {} ✅ This is overriding
};
// ❌ MISTAKE 4: Overloading operators inconsistently
// If you overload ==, also overload !=
// If you overload <, also overload >, <=, >=
// Or use C++20 <=> operator11. Practice Problems
Problem 1: Polymorphic Calculator
class Operation {
public:
virtual double calculate(double a, double b) const = 0;
virtual string symbol() const = 0;
virtual ~Operation() = default;
};
class Add : public Operation {
public:
double calculate(double a, double b) const override { return a + b; }
string symbol() const override { return "+"; }
};
class Subtract : public Operation {
public:
double calculate(double a, double b) const override { return a - b; }
string symbol() const override { return "-"; }
};
class Multiply : public Operation {
public:
double calculate(double a, double b) const override { return a * b; }
string symbol() const override { return "*"; }
};
class Divide : public Operation {
public:
double calculate(double a, double b) const override {
if (b == 0) throw runtime_error("Division by zero!");
return a / b;
}
string symbol() const override { return "/"; }
};
// Usage:
void compute(const Operation& op, double a, double b) {
cout << a << " " << op.symbol() << " " << b
<< " = " << op.calculate(a, b) << endl;
}
int main() {
Add add; Multiply mul; Divide div;
compute(add, 10, 3); // 10 + 3 = 13
compute(mul, 4, 5); // 4 * 5 = 20
compute(div, 10, 3); // 10 / 3 = 3.33333
}Problem 2: Smart Matrix Class with Operators
class Matrix {
vector<vector<double>> data;
int rows, cols;
public:
Matrix(int r, int c) : rows(r), cols(c), data(r, vector<double>(c, 0)) {}
double& operator()(int r, int c) { return data[r][c]; }
double operator()(int r, int c) const { return data[r][c]; }
Matrix operator+(const Matrix& other) const {
if (rows != other.rows || cols != other.cols)
throw invalid_argument("Matrix size mismatch!");
Matrix result(rows, cols);
for (int i = 0; i < rows; i++)
for (int j = 0; j < cols; j++)
result(i, j) = data[i][j] + other(i, j);
return result;
}
bool operator==(const Matrix& other) const {
if (rows != other.rows || cols != other.cols) return false;
for (int i = 0; i < rows; i++)
for (int j = 0; j < cols; j++)
if (data[i][j] != other(i, j)) return false;
return true;
}
friend ostream& operator<<(ostream& os, const Matrix& m) {
for (int i = 0; i < m.rows; i++) {
for (int j = 0; j < m.cols; j++)
os << m(i, j) << "\t";
os << "\n";
}
return os;
}
};12. Interview Questions
Q1: What is polymorphism? Explain its types.
Answer: Polymorphism means "many forms" — the same interface behaves differently depending on the type. Two types: (1) Compile-time (static) — resolved at compile time via function overloading, operator overloading, and templates. (2) Runtime (dynamic) — resolved at runtime via virtual functions and base pointers/references. The compiler generates vtable lookups for dynamic dispatch.
Q2: What is a virtual function? How does it work internally?
Answer: A virtual function enables runtime polymorphism. When called through a base pointer, the actual derived version is called. Internally: the compiler creates a vtable (array of function pointers) per class, and each object gets a vptr (pointer to its class's vtable). Virtual dispatch: load vptr → index vtable → call function pointer. Overhead: ~8 bytes per object (vptr) + ~2 memory accesses per call.
Q3: What is the vtable and vptr?
Answer: The vtable (virtual table) is a compile-time generated array of function pointers, one per class with virtual functions. It maps each virtual function to its implementation for that class. The vptr (virtual pointer) is a hidden member in each object that points to its class's vtable. When you override a function, the derived class's vtable entry is updated to point to the new implementation.
Q4: Can constructors be virtual? Can destructors?
Answer: Constructors CANNOT be virtual — the vtable doesn't exist during construction. Destructors CAN and SHOULD be virtual when the class is used as a base class. Without a virtual destructor, deleting a derived object through a base pointer skips the derived destructor, causing resource leaks.
Q5: What is function overloading vs overriding?
Answer: Overloading: same function name, different parameters, in the SAME class. Resolved at compile time. Overriding: same function signature in derived class replaces base class virtual function. Resolved at runtime. Overloading is compile-time polymorphism; overriding is runtime polymorphism.
Q6: What is operator overloading? What operators can't be overloaded?
Answer: Operator overloading defines custom behavior for operators with user-defined types. Can't overload: ::, ., .*, ?:, sizeof, typeid. Can't create new operators or change precedence. Implement as member for unary and compound assignment; as friend/non-member for binary operators where the left operand might not be your type (like cout << obj).
Q7: What is dynamic_cast? When do you use it?
Answer: dynamic_cast safely converts base pointers/references to derived types at runtime using RTTI. Returns nullptr (pointer) or throws bad_cast (reference) on failure. Requires at least one virtual function. Use when you need type-specific behavior that can't be achieved through polymorphism. But prefer virtual functions over dynamic_cast — it often signals a design problem.
Q8: Why are virtual functions slower than non-virtual?
Answer: Virtual calls require two extra memory accesses (load vptr, index vtable) and an indirect function call (through pointer). They also prevent inlining. Non-virtual calls are direct (address known at compile time) and can be inlined. The overhead is ~1-5ns per call — negligible except in extremely performance-critical tight loops.
Q9: Can you call a virtual function in a constructor?
Answer: Technically yes, but it won't behave polymorphically. During base class construction, the vptr points to the base class vtable (the derived class doesn't exist yet). So calling a virtual function in a Base constructor calls Base's version, even if Derived overrides it. This is a common source of bugs.
Q10: What is a functor?
Answer: A functor is a class that overloads operator(), making objects of that class callable like functions. Advantages over function pointers: they can hold state (member variables), they're faster (compiler can inline), and they work naturally with STL algorithms. Lambdas in C++11 are essentially compiler-generated functors.
Q11: Explain the difference between static_cast and dynamic_cast.
Answer: static_cast performs compile-time type checking only — no runtime check, undefined behavior if the actual type is wrong. dynamic_cast performs runtime type checking using RTTI — returns nullptr/throws if the cast is invalid. Use static_cast for known-safe conversions (int→double, upcasting). Use dynamic_cast for downcasting when you're not sure of the actual type.
Q12: What are covariant return types?
Answer: When overriding a virtual function, the return type can be a derived type of the base function's return type. If Base::clone() returns Base*, Derived::clone() can return Derived*. This avoids unnecessary casting when the function is called on a known derived type.
Next Module: 05 - SOLID Principles — The five fundamental design principles for clean, maintainable OOP code.