One interface, many behaviours: polymorphism
Open in the interactive app: practice, quiz, mock interview →In one minute
- Polymorphism means one piece of code works with values of many types, and the right behaviour for each type is chosen for you. The caller writes
r.monthly_cost()once; a VM, a bucket and a function each answer their own way. - Subtype polymorphism picks the method at runtime from the receiver's type. Parametric polymorphism (generics) writes one algorithm for any type. Ad hoc polymorphism (overloading) picks a function at compile time from argument types.
- Dispatch makes adding a type cheap and adding an operation expensive; a
switchover a closed set of types makes the opposite trade. This is the expression problem. - A virtual call costs about a nanosecond when predicted, but mostly costs lost inlining. JITs recover it with inline caches, fast while a call site sees one to four types.
- Agents produce either long
isinstancechains or a hierarchy for two cases. Ask which axis will grow: types or operations.
How it works
One call, many behaviours
A cloud billing job prices resources. The first version branches on a type tag:
def monthly_cost(r):
if r.kind == "vm":
return r.vcpus * 21.0 + r.ram_gb * 2.8
if r.kind == "bucket":
return r.stored_gb * 0.021
if r.kind == "function":
return r.invocations / 1_000_000 * 0.20
raise ValueError("unknown kind " + r.kind)The same chain reappears in the invoice renderer, the budget alerter and the carbon estimator. Adding a managed database means editing all four, and a missed one fails only at runtime. With subtype polymorphism each class carries its own monthly_cost and callers write r.monthly_cost(). The branch still exists, but the language performs it, in one place, from the object's type.
The kinds of polymorphism
- Subtype (dynamic dispatch). A
Resourcevariable holds any subtype and the method runs from the object's actual class: Java, Python and JavaScript methods, C++virtual, Go interfaces. - Parametric (generics).
MaxT cmp.Ordered Tis one algorithm for everyT. - Ad hoc (overloading).
print(int)andprint(string)are different functions sharing a name; the compiler picks one from static argument types. Operator overloading is the same idea. - Coercion.
1 + 2.5converts the integer so one addition works.
What matters most is when the choice is made. Overriding is decided at runtime from the object; overloading and generics at compile time from declared types.
How many arguments decide?
Most languages dispatch on the receiver only. If behaviour depends on two types, such as a collision between an asteroid and a ship, single dispatch needs a second hop: the Visitor pattern's double dispatch. Julia and Common Lisp dispatch on all arguments. Python's functools.singledispatch dispatches a function on its first argument, a handy way to add an operation to classes you do not own.
The expression problem
Picture a grid with types as rows and operations as columns. Subclasses add a row cheaply but a new column edits every class. A switch per operation is the reverse.
Choose by which axis grows. Third-party payment providers grow rows, so use interfaces. A compiler's syntax tree has fixed node types and keeps gaining passes, so use exhaustive matching. Java 21 sealed interfaces with pattern-matching switch, Rust enums, and TypeScript discriminated unions with a never check all fail to compile when a case is missing.
Reviewing agent-written code
Agents add another elif isinstance(...) branch because it is the smallest diff, or, asked to "make it extensible", build an abstract base with one subclass. Check that a new type needs edits in one place, that type switches are exhaustive and compiler-checked, and that overloads do not depend on static types the caller lacks: a Java equals(Money other) overloads rather than overrides equals(Object), so collections never call it.
The same shape elsewhere
Dispatch is the finding things fast shape: map a key (the type) to a handler and cache the answer. An HTTP router maps method and path to a handler, and a CPU's branch target buffer maps an instruction address to a predicted target. Each fails the same way: too many distinct keys at one point overflow the fast cache and force a slow path.
Down the stack
Layer 1The contract
For an overridden method, the most specific implementation for the receiver's runtime type runs, even when the static type is a parent or interface. Overloads are fixed at compile time from static argument types. Generic code is type-checked once (Java, Go, TypeScript) or per instantiation (C++ templates). Nothing promises how fast dispatch is.
Layer 2The mechanism
C++ and the JVM give each class a vtable; a virtual call loads the vtable pointer, loads a fixed slot and calls it. Inherited methods keep their parent's slot, so the slot is a compile-time constant. The JIT first tries an inline cache: compare the receiver's class with the one last seen and jump straight to its method. HotSpot profiles receiver types and inlines sites seeing one or two classes behind a guard; sites seeing more are megamorphic and use the vtable.
V8 keeps an inline cache per access and call site, keyed by hidden class ("map"): uninitialised, monomorphic, polymorphic (about four maps), then megamorphic with a global stub cache. Objects given the same properties in different orders get different maps, so a site turns polymorphic for no visible reason.
Generics. C++ templates and Rust generics are monomorphised: one copy per type argument, so calls are direct and inlinable, at the cost of binary size. Java erases type parameters to Object and casts. Go uses GC shape stenciling: one copy per memory shape, all pointer types sharing one, plus a dictionary argument, so a method call on a type parameter is indirect and can be slower than an interface call.
Python looks the method up on type(obj) through the MRO. CPython 3.11 and later specialise hot bytecode, turning LOAD_ATTR into a method-load variant guarded by the type's version tag. singledispatch caches class to implementation, walking the MRO on a miss.
Layer 3The machine
A predicted virtual call is two dependent loads and an indirect branch, around 1 ns with L1 hits. A mispredicted indirect branch costs roughly 15 to 20 cycles, about 5 ns at 3.5 GHz. A loop over seven classes in random order defeats the branch target buffer; the same list grouped by class predicts almost perfectly. The larger cost is what the compiler cannot do through an indirect call: inline, hoist checks or vectorise. Layout matters more still: a list of polymorphic objects is a list of pointers to separate allocations, and each DRAM miss costs around 80 to 100 ns. A slice of one concrete struct is contiguous and prefetchable.
Why it's built this way
- Callers must not know every type, so the decision is made from data the object carries (vtable pointer, hidden class, type object).
- Inherited methods keep fixed slots so dispatch is one indexed load, not a search.
- Indirect calls block inlining and most sites see one type, so JITs speculate with inline caches.
- Copying code per type is fast but bloats binaries, so C++ and Rust copy, Java erases, and Go copies per shape.
Diagram
stateDiagram-v2 [*] --> Uninitialized Uninitialized --> Monomorphic: first receiver class Monomorphic --> Monomorphic: same class (hit) Monomorphic --> Polymorphic: second class Polymorphic --> Polymorphic: up to 4 classes Polymorphic --> Megamorphic: 5th distinct class Megamorphic --> Megamorphic: every call does a full lookup
Worked example
A nightly Go billing run prices 300 million usage records held as Rater interface values across seven concrete types, interleaved by timestamp. It takes about 41 seconds on one core, roughly 137 ns per record.
The profile shows little time in the arithmetic. About half goes to cache misses chasing each interface's pointer to a separately allocated struct, and a noticeable share to mispredicted indirect calls. The team partitions records by type into seven slices of concrete structs (about 2.4 seconds of extra work) and processes each slice with a loop over the concrete type, so calls are direct and inlined.
The run drops to about 9 seconds. The public API still accepts Rater, so new types plug in and use the generic path until they earn a specialised loop. The numbers are illustrative, but the ratio is typical when dispatch sits in a tight loop over pointer-heavy data.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Subtype dispatch | New types without touching callers | New operations touch every type | Open set of types, such as plugins |
| Exhaustive switch over a sealed type | New operations in one place, checked | New types touch every switch | Closed types, growing operations, such as ASTs |
| Monomorphised generics | Direct, inlinable calls | Binary size, compile time | Hot generic containers and algorithms |
| Shared generics (erasure, dictionaries) | Small binaries, fast builds | Indirect calls or casts | Most application code |
| Multiple dispatch | Behaviour depending on two types | Rare outside Julia and Lisp | Numeric and symbolic code |
Pitfalls in production
- An overload is chosen by static type, so
equals(Money)is never called by aHashSet. Fix: override the exact signature with@Override. - A plugin adds a fifth receiver type to a hot call site and latency rises with no core change. Fix: find megamorphic sites in profiles; split hot loops by type.
- JavaScript objects built with properties in different orders make access sites polymorphic. Fix: initialise every field in the constructor in one order.
- A new subtype is silently swallowed by a
defaultbranch. Fix: exhaustive matching without a default, so the compiler lists every place to update. functools.singledispatchon a method dispatches onself. Fix: usesingledispatchmethod.
Leadership lens
- In design review, ask which axis grows next year, types or operations, and check the design makes that one cheap.
- Treat a widely implemented interface as a platform API: each new required method is work for every implementing team.
- Do not ban
switchor mandate hierarchies; require exhaustiveness checks wherever types are matched. - Before accepting "make it polymorphic" refactors on hot paths, budget a profiling pass; the cost is usually memory layout, not the call.
Check yourself
Why does a new payment provider take one new class with interfaces but edits in many files with type switches?
Show answer
With interfaces each type carries all its operations, so a new type is one unit. With switches each operation lists every type, so a new type touches every operation.
In Java,
Moneydefinesequals(Money other). Why doesset.contains(m)ignore it?Show answer
HashSetcallsequals(Object).equals(Money)is an overload chosen from static types, not an override, soObject.equalsruns.A V8 call site was fast in benchmarks and slow in production, where six object shapes reach it. Why?
Show answer
The inline cache holds only a few shapes before going megamorphic, so every call falls back to a generic lookup and cannot be inlined.
Why can a C++ template call be faster than the same logic behind a virtual call?
Show answer
The template is compiled per type, so the target is known and can be inlined. The virtual target is only known at runtime.
Watch
- The Clean Code Talks: Inheritance, Polymorphism and Testing (Misko Hevery, Google TechTalks): replacing conditionals with polymorphism to make code testable.
- "Clean" Code, Horrible Performance (Casey Muratori, Molly Rocket): a measured counterpoint on virtual dispatch cost in tight loops.
Free resources
- On Understanding Types, Data Abstraction, and Polymorphism (Cardelli and Wegner): the classic paper naming the kinds of polymorphism.
- VirtualCalls (OpenJDK HotSpot wiki): vtables and inline caches in HotSpot.
- JavaScript engine fundamentals: Shapes and Inline Caches (Mathias Bynens): hidden classes and inline caches in JavaScript engines.
- Go generics implementation: dictionaries and GC shape stenciling: how Go compiles generic code.
- Expression problem (Wikipedia): the types-versus-operations trade-off.