Skip to main content

Polymorphism

Polymorphism is the runtime figuring out what an object actually is, and calling its version of a method even though your code only ever addressed the shared supertype. Declare makeSound() abstract on Animal, put cats and dogs in a bag, iterate the bag as Animal, and each one emits its own noise. Your code addressed the abstraction; the implementations answered.

What breaks without it

Without polymorphism, code that needs to handle several related types ends up as a chain of type checks: if (shape instanceof Circle) drawCircle(shape); else if (shape instanceof Rectangle) drawRectangle(shape); else if (shape instanceof Triangle) .... Every new type means editing this same conditional, in every place it's copied, and forgetting one branch is a silent bug rather than a compile error. Polymorphism moves the "what kind of shape is this" question from every call site into exactly one place - the object itself, via override - so adding a new type means writing one new class and touching nothing else.

Worked example: one call site, many answers

Declare makeSound() abstract on Animal. Cat and Dog each override it with their own implementation. Code that holds an Animal reference and calls makeSound() never checks which subclass it has - the object itself resolves that.

«abstract»Animal...+ makeSound()Cat...+ makeSound()Dog...+ makeSound()print("Meow!")print("Woof!")Abstract class andmethod names arein italics
Call makeSound() on an Animal and the subclass answers. These are UML comments: notes about how a method is implemented.

Worked example: adding a type costs one class

A drawing app keeps a list of Shape objects: some Circle, some Rectangle, some Triangle. It never asks which is which - it loops over the list and calls shape.area() and shape.render() on each one, and every shape answers with its own formula and its own drawing routine.

«abstract»Shape+ area()+ render()Circle+ area()+ render()Rectangle+ area()+ render()Triangle+ area()+ render()
The canvas-drawing loop calls area() and render() on every Shape without a single type check.

Adding a new Hexagon later means writing one new class; the loop that draws the canvas does not change, because it never knew - and never needed to know - the full list of shape types in the first place.

Language mechanics: compile-time vs runtime polymorphism

This is the split most explanations skip, and it's the one worth being precise about.

Compile-time (static) polymorphism is overloading: several methods with the same name but different parameter lists, resolved by the compiler, looking only at the declared types of the arguments at the call site.

class Logger {
void log(String message) { ... }
void log(String message, Throwable error) { ... }
}
 
logger.log("started"); // compiler picks log(String)
logger.log("failed", exception); // compiler picks log(String, Throwable)

Nothing about the actual runtime object matters here - logger's real class could be any subclass of Logger, and the compiler would still pick the overload the same way, purely from what's written at the call site.

Runtime (dynamic) polymorphism is overriding: a subclass replaces a method it inherited, and the decision of which version runs is made by the runtime, based on the actual class of the object, not the declared type of the reference holding it.

Animal a = new Cat();
a.makeSound(); // prints "Meow!" - decided at runtime from a's real class, not its declared type

Under the hood, every object carries a reference to a table of its actual class's method implementations (commonly called a vtable). Calling an overridden method looks up the implementation in that table - the one belonging to the real object - rather than the table belonging to whatever type the variable was declared as. That lookup, happening at call time instead of compile time, is what "dynamic dispatch" means.

Upcasting and downcasting

Animal a = new Cat(); is an upcast - going from a more specific type to a more general one - and it's always implicit and always safe, because every Cat genuinely is an Animal; nothing is lost by referring to it more generally.

Cat c = (Cat) a; is a downcast - going from a general type back to a specific one - and it must be written explicitly, because not every Animal is a Cat. The cast is a claim the code is making about what's really there, and the runtime checks that claim: if a actually holds a Dog, the cast throws a ClassCastException rather than silently producing garbage.

Common mistakes:

  • Confusing overloading with polymorphism. Writing three methods, area(Circle c), area(Rectangle r), area(Triangle t), on some AreaCalculator class and calling it a day looks similar but isn't: the compiler picks the overload at compile time based on the argument type it sees written at the call site. Polymorphism is the runtime picking the override based on the object it actually holds, through a single shape.area() call site that never needs to grow a new overload when a new shape shows up.
  • Downcasting to dispatch manually. Writing if (shape instanceof Circle) drawCircle(shape) else if (shape instanceof Rectangle) drawRectangle(shape) after already overriding render() on every shape reintroduces the exact type-check chain polymorphism exists to remove - it's a switch statement wearing instanceof as a costume, and it means every new shape still requires editing this chain, which defeats the entire point of overriding render() in the first place.

Where this shows up in the patterns catalogue

  • Strategy is polymorphism applied to an algorithm: swap which object is plugged in, and the call site (strategy.execute()) never changes.
  • State is the same trick applied to a lifecycle: the object's behavior changes because the state object behind it changed, not because any caller branched on a status flag.
  • Visitor is built around double dispatch - two overridden method calls in sequence, each one resolved at runtime, to pick behavior based on two actual types instead of one.

Try it yourself: the drawing app's loop calls shape.render() on every shape - work through what happens the day someone adds a Group shape that contains other shapes, and whether the same one-method-many-answers trick still holds. Then take the Logger example above and add a third overload, log(String message, LogLevel level) - write the call logger.log("retrying", RETRY_LEVEL) and confirm for yourself that resolving it is a compile-time decision, not a runtime one, even though LogLevel is itself an object.

Check yourself

Question 1 of 4

You pull animals out of a bag with your eyes shut, call makeSound() on each, and hear a meow then a woof. What made that work?