Back to the map

Module 20 · OOP fundamentals · Lesson 4 of 5

Polymorphism

One interface, many implementations. How dynamic dispatch picks a method at runtime through a vtable, why overloads are chosen at compile time, and replacing type switches with polymorphic calls.

CoreMust-knowJunior 12 min Updated Nov 2026

Why it matters

Polymorphism is what lets code work with things it was written before. A payment service calls charge on a payment method and does not care whether it is a card, a wallet or a bank transfer added next year. Every plugin system, framework callback and strategy pattern relies on it. Understanding how the call is resolved also explains a common bug: overloads and overrides behave differently, and the compiler picks one while the runtime picks the other.

The idea

Polymorphism means one interface, many forms: the same call does different things depending on the object it is made on. There are three kinds, and they are resolved at different times.

KindWhat variesResolvedExample
Subtype (dynamic dispatch)The object's runtime classAt runtimeshape.area() calls Circle's or Square's area
Ad hoc (overloading)The declared types of the argumentsAt compile timeprint(int) versus print(String)
Parametric (generics)A type parameterAt compile time (with runtime effects varying by language)A List of T that works for any T

How dynamic dispatch works

In Java, C# and C++ (for virtual methods), each class has a virtual method table: an array of pointers to the implementation of each overridable method. Each object carries a hidden pointer to its class's table. A call like shape.area() compiles to "load the object's table pointer, load the entry for area, call it". The compiler does not need to know the runtime class; the object brings its own table.

circle objectvptr · radiussquare objectvptr · sideCircle tablearea → Circle.area · draw → Circle.drawSquare tablearea → Square.area · draw → Square.drawshape.area()same call site
Each object points to its class's method table. The same call site jumps through the table to whichever implementation the object's class supplies.

The cost is one or two extra memory loads per call and, more importantly, that the compiler cannot inline the target. JIT compilers recover most of that by observing that a call site only ever sees one or two classes and inlining those, which is why "virtual calls are slow" is rarely true in practice. Dynamically typed languages (Python, JavaScript, Ruby) dispatch by looking the method up by name on the object, often with inline caches; the effect for the programmer is duck typing: anything with the right method works.

Overloading is decided by declared types

Overloading picks among methods with the same name by the compile-time types of the arguments. Overriding picks by the runtime class of the receiver. Mixing them surprises people:

void greet(Object o) { System.out.println("object"); }
void greet(String s) { System.out.println("string"); }

Object x = "hello";
greet(x);          // prints "object": x is declared Object, so the compiler chose greet(Object)

Only the receiver (the object before the dot) is dispatched dynamically in these languages. Choosing behaviour based on the runtime type of an argument needs a different technique, such as double dispatch (the visitor pattern in the Low-Level Design track) or pattern matching.

Replacing type switches

Code that switches on a type field (if type == CARD ... else if type == WALLET ...) must be edited every time a new type appears, and the switches multiply across the codebase. Moving each branch into a method on its type means adding a type is adding a class, and no existing code changes. That is the open-closed principle in practice.

// Before: every new method of payment edits this, and every similar switch elsewhere
long fee(Payment p) {
    switch (p.type()) { case CARD: return p.amount() * 29 / 1000; case WALLET: return 0; default: throw ... }
}

// After: each type knows its own fee
interface PaymentMethod { long fee(long amount); }
final class Card implements PaymentMethod { public long fee(long a) { return a * 29 / 1000; } }
final class Wallet implements PaymentMethod { public long fee(long a) { return 0; } }

Modern languages with sealed types and exhaustive pattern matching (Kotlin, Scala, Rust, recent Java and C#) make switches safe again when the set of types is closed and the set of operations grows; the OOP versus functional lesson covers that trade.

Trade-offs

Subtype polymorphism makes adding new types cheap and keeps type-specific logic together, at the cost of spreading one operation across many classes, which can make it harder to read end to end. Overloading improves call-site readability and becomes confusing when overloads accept related types. Pattern matching over closed hierarchies is the counterweight when operations change more often than types.

In an interview

Use polymorphism to make extension a non-event: "Fee calculation is a method on PaymentMethod, so adding a new provider means one new class implementing it, and the checkout code never changes." If asked how it works underneath, the vtable explanation in one sentence is enough.

Check yourself

Animal a = new Dog(); a.speak() prints 'Woof'. But describe(a) prints 'some animal', even though there is an overload describe(Dog d) that prints 'a dog'. Why?

Polymorphism · Backplane