Skip to main content

Polymorphism — Compile-Time vs Runtime

Editorial

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.

Polymorphism, two forms. Runtime (method overriding, the important one): a parent-type variable holds a child object and the actual object decides which method runs - dynamic dispatch; shape.area() runs Circle.area() (pi r^2), Rectangle.area() (w h), or Triangle.area() (1/2 b h). Compile-time (method overloading, mostly Java): same method name with different parameter lists resolved by the compiler from argument types - add(int,int), add(double,double), add(String,String). Open/Closed: a polymorphic total_area(shapes) returning sum(s.area()) never changes when you add Pentagon/Hexagon/Star, unlike an if/elif isinstance chain that must be edited for every new type.

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

Build a list of shapes and run total_area(). In polymorphic mode each shape.area() dispatches to the object's own override at runtime - add a Pentagon and it works with zero caller changes (Open/Closed). Switch to the if/elif isinstance caller and the Pentagon falls through unhandled, showing why type-checks are closed for extension.

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/typeof checks — 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