Engineering Atlas › Low-Level Design

One interface, many behaviours: polymorphism

10 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

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

  1. Callers must not know every type, so the decision is made from data the object carries (vtable pointer, hidden class, type object).
  2. Inherited methods keep fixed slots so dispatch is one indexed load, not a search.
  3. Indirect calls block inlining and most sites see one type, so JITs speculate with inline caches.
  4. 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

OptionStrengthsWeaknessesChoose when
Subtype dispatchNew types without touching callersNew operations touch every typeOpen set of types, such as plugins
Exhaustive switch over a sealed typeNew operations in one place, checkedNew types touch every switchClosed types, growing operations, such as ASTs
Monomorphised genericsDirect, inlinable callsBinary size, compile timeHot generic containers and algorithms
Shared generics (erasure, dictionaries)Small binaries, fast buildsIndirect calls or castsMost application code
Multiple dispatchBehaviour depending on two typesRare outside Julia and LispNumeric and symbolic code

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. In Java, Money defines equals(Money other). Why does set.contains(m) ignore it?

    Show answer

    HashSet calls equals(Object). equals(Money) is an overload chosen from static types, not an override, so Object.equals runs.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →