Polymorphism — Compile-Time vs Runtime
Introduction
In the previous tutorial, we built an Employee hierarchy where Manager, Developer, and Intern all inherited from Employee. We were already using polymorphism without naming it — when we looped through a list of Employee objects and called display_info(), each one behaved differently.
Polymorphism (from Greek: poly = many, morphe = form) means "many forms." In programming, it means the same method call can produce different behavior depending on the object's actual type.
This is the payoff of inheritance and abstraction. You write code that works with the general type (Employee, Notification, Shape), and each specific type provides its own behavior. The result: code that's extensible without modification — you can add new types without changing existing logic.
There are two forms of polymorphism:
- Compile-time (static) — resolved when the code is compiled. Achieved through method overloading (same method name, different parameters)
- Runtime (dynamic) — resolved when the program runs. Achieved through method overriding (child replaces parent's method)
Runtime polymorphism is the more important concept and the foundation of nearly every design pattern you'll learn in this course.
Runtime Polymorphism — The Core Idea
Runtime polymorphism means: a parent-type variable can hold a child-type object, and method calls execute the child's version.
The decision of which calculate_pay() to call isn't made when you write the code — it's made at runtime, based on the actual object.
from abc import ABC, abstractmethod
class Shape(ABC):
"""Abstract base — defines the contract for all shapes."""
@abstractmethod
def area(self) -> float:
pass
@abstractmethod
def describe(self) -> str:
pass
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float:
return 3.14159 * self.radius ** 2
def describe(self) -> str:
return f"Circle(r={self.radius})"
class Rectangle(Shape):
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
def describe(self) -> str:
return f"Rectangle({self.width}x{self.height})"
class Triangle(Shape):
def __init__(self, base: float, height: float):
self.base = base
self.height = height
def area(self) -> float:
return 0.5 * self.base * self.height
def describe(self) -> str:
return f"Triangle(b={self.base}, h={self.height})"
# --- The power of polymorphism ---
def print_total_area(shapes: list[Shape]) -> None:
"""Works with ANY shape — doesn't know or care about specific types."""
total = 0.0
for shape in shapes:
a = shape.area() # Which area()? Depends on the actual object!
print(f" {shape.describe()} → area = {a:.2f}")
total += a
print(f" Total area: {total:.2f}")
shapes: list[Shape] = [
Circle(5.0),
Rectangle(4.0, 6.0),
Triangle(3.0, 8.0),
Circle(2.0),
]
print_total_area(shapes)
# Output:
# Circle(r=5.0) → area = 78.54
# Rectangle(4.0x6.0) → area = 24.00
# Triangle(b=3.0, h=8.0) → area = 12.00
# Circle(r=2.0) → area = 12.57
# Total area: 127.11
# Tomorrow, add a Pentagon class. print_total_area() works unchanged.import java.util.List;
public abstract class Shape {
public abstract double area();
public abstract String describe();
}
class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public String describe() {
return String.format("Circle(r=%.1f)", radius);
}
}
class Rectangle extends Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
@Override
public String describe() {
return String.format("Rectangle(%.1fx%.1f)", width, height);
}
}
class Triangle extends Shape {
private double base;
private double height;
public Triangle(double base, double height) {
this.base = base;
this.height = height;
}
@Override
public double area() {
return 0.5 * base * height;
}
@Override
public String describe() {
return String.format("Triangle(b=%.1f, h=%.1f)", base, height);
}
}
// --- Usage ---
class PolymorphismDemo {
// Works with ANY Shape — doesn't know or care about specific types
static void printTotalArea(List<Shape> shapes) {
double total = 0;
for (Shape shape : shapes) {
double a = shape.area(); // Which area()? Decided at RUNTIME!
System.out.printf(" %s → area = %.2f%n", shape.describe(), a);
total += a;
}
System.out.printf(" Total area: %.2f%n", total);
}
public static void main(String[] args) {
List<Shape> shapes = List.of(
new Circle(5.0),
new Rectangle(4.0, 6.0),
new Triangle(3.0, 8.0),
new Circle(2.0)
);
printTotalArea(shapes);
}
}The magic line is shape.area(). At compile time (or when you write the code), shape is declared as type Shape. But at runtime, it could be a Circle, Rectangle, or Triangle — and the correct area() implementation is called automatically.
This is called dynamic dispatch — the runtime system looks at the actual object and dispatches the method call to the correct implementation.

Compile-Time Polymorphism — Method Overloading
Compile-time polymorphism (also called static polymorphism) lets you define multiple methods with the same name but different parameter lists. The compiler decides which version to call based on the arguments.
This is primarily a Java concept. Python doesn't support traditional method overloading (defining multiple methods with the same name would just overwrite the earlier ones).
public class Calculator {
// Same method name, different parameter lists — method overloading
// Version 1: add two integers
public int add(int a, int b) {
System.out.println("add(int, int) called");
return a + b;
}
// Version 2: add three integers
public int add(int a, int b, int c) {
System.out.println("add(int, int, int) called");
return a + b + c;
}
// Version 3: add two doubles
public double add(double a, double b) {
System.out.println("add(double, double) called");
return a + b;
}
// Version 4: concatenate strings
public String add(String a, String b) {
System.out.println("add(String, String) called");
return a + b;
}
public static void main(String[] args) {
Calculator calc = new Calculator();
// Compiler picks the right version based on arguments:
System.out.println(calc.add(3, 5)); // add(int, int) → 8
System.out.println(calc.add(1, 2, 3)); // add(int, int, int) → 6
System.out.println(calc.add(2.5, 3.5)); // add(double, double) → 6.0
System.out.println(calc.add("Hello ", "World")); // add(String, String) → "Hello World"
}
}In Python, you achieve similar flexibility with default parameters and type checking:
class Calculator:
def add(self, a, b, c=None):
if c is not None:
return a + b + c
return a + b
Or more idiomatically, with the @singledispatch decorator from the functools module for type-based dispatch.
Why Runtime Polymorphism Matters More
Compile-time polymorphism is a convenience — it lets you use the same method name for related operations. But runtime polymorphism is a design tool — it lets you write code that works with abstract types and automatically adapts to concrete types. Nearly every design pattern in this course relies on runtime polymorphism.
Visualization

Polymorphism in Practice — The Open/Closed Principle Preview
Polymorphism is the technical mechanism behind one of the most important design principles: the Open/Closed Principle (OCP) — "software entities should be open for extension but closed for modification."
Here's what that means in practice:
# WITHOUT polymorphism — closed for extension
def calculate_area(shape_type: str, dimensions: dict) -> float:
if shape_type == "circle":
return 3.14159 * dimensions["radius"] ** 2
elif shape_type == "rectangle":
return dimensions["width"] * dimensions["height"]
elif shape_type == "triangle":
return 0.5 * dimensions["base"] * dimensions["height"]
# Adding Pentagon? Edit this function. Dangerous.
# WITH polymorphism — open for extension
def calculate_area(shape: Shape) -> float:
return shape.area() # Works with ANY shape. Forever.
The polymorphic version never needs to change. Add a Pentagon, Hexagon, or Star — as long as they implement area(), the function works. We'll study OCP formally in Section 5 (SOLID Principles).
Common Mistakes
Mistake 1: Using isinstance/typeof Instead of Polymorphism
❌ Wrong:
def process(shape):
if isinstance(shape, Circle):
# circle-specific logic
elif isinstance(shape, Rectangle):
# rectangle-specific logic
This is conditional polymorphism — it defeats the purpose. Every new shape requires editing this function.
✅ Right: Define the method on the shape itself and just call it:
def process(shape: Shape):
shape.process() # Each shape knows how to process itself
Mistake 2: Confusing Overloading with Overriding
Overloading: Same method name, different parameters, same class. Compile-time.
Overriding: Same method name, same parameters, child class replaces parent's version. Runtime.
We'll do a detailed comparison in the next tutorial.
Mistake 3: Forgetting @Override (Java)
❌ Wrong: Defining a method in a child class that's supposed to override the parent's, but has a slightly different signature (e.g., caluclatePay instead of calculatePay). Without @Override, Java creates a new method instead of overriding.
✅ Right: Always use @Override annotation. It tells the compiler to verify that you're actually overriding an existing method.
Key Takeaways
- Polymorphism = same method call, different behavior depending on the actual object type
- Runtime polymorphism (method overriding) is the more important form — a parent-type variable holds a child-type object, and the child's method runs. This is dynamic dispatch
- Compile-time polymorphism (method overloading) is a convenience — same method name with different parameter lists, resolved by the compiler
- Polymorphism enables the Open/Closed Principle: write code that works with abstract types, then extend it by adding new concrete types without modification
- Avoid
isinstance/typeofchecks — if you're checking the type to decide behavior, you should be using polymorphism instead - Runtime polymorphism is the backbone of nearly every design pattern: Strategy, Observer, Factory, Template Method, and more (Sections 9-12)
- In the next tutorial, we'll do a detailed side-by-side comparison of method overloading vs method overriding to cement the distinction