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.
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.
| Kind | What varies | Resolved | Example |
|---|---|---|---|
| Subtype (dynamic dispatch) | The object's runtime class | At runtime | shape.area() calls Circle's or Square's area |
| Ad hoc (overloading) | The declared types of the arguments | At compile time | print(int) versus print(String) |
| Parametric (generics) | A type parameter | At 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.
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?