Polymorphism6 min · 10 of 16

Dynamic dispatch

Trace a virtual call through the vtable, price it against the work it wraps, and decide from the cost whether a switch should become a type.

A call site names a method, not an implementation. shape.area() compiles to one instruction, and which of six bodies runs is decided when that instruction executes, by whatever object sits to the left of the dot. That one indirection is the whole machinery behind "add a seventh shape without touching the code that draws them".

The two questions people get wrong about it — what it costs, and when to prefer a switch — both have arithmetic answers.

What the machine actually does

In C++ the compiler emits one vtable per polymorphic class — any class with a virtual function; in Java the JVM builds one per class at load time, because javac emits only a symbolic invokevirtual. It is an array of function pointers, laid out so a method declared in the base class occupies the same slot index in every subclass, and every object carries a pointer to its class's table in its header. A virtual call is then three steps: load the class pointer out of the object, load the function pointer from a fixed offset in the table, call through it.

The slot index is fixed at compile time. Only the table it is read from changes, and the object chooses that.
One call site, two receivers: the vtable pointer inside the object picks the method body1 load1 load2 slot 32 slot 3shape.area()one call siteCircle objectheader + fieldsSquare objectheader + fieldsCircle vtableper class, not perobjectSquare vtableper class, not perobjectCircle.areapi r squaredSquare.areaside squared

Scroll to zoom · drag to pan · 0 fits · Esc closes

Two dependent loads and an indirect branch. Both loads normally hit L1 — the vtable is one small array per class, so it stays hot — which puts them at roughly 1 ns each on the shared latency ladder, 2 ns for the pair. The branch splits the rest into two regimes. A monomorphic site sees one receiver type, predicts perfectly, and a JIT inlines the body and folds the loads away with it: 0–2 ns. A megamorphic site, five types in rotation, mispredicts — order 10–20 cycles, about 5 ns at 3 GHz — so 2 ns of loads plus 5 ns of mispredict, roughly 7 ns.

Price the megamorphic case. A loop over 1 million shapes where each area() body reads one field that is not in cache:

dispatch:   1,000,000 × 7 ns   =   7 ms   (2 ns loads + 5 ns mispredict)
the bodies: 1,000,000 × 100 ns = 100 ms   (one main-memory reference each)
dispatch share = 7 / 107 ≈ 6.5%

Deleting polymorphism from that loop buys at most 6.5%, and nothing if the site was already monomorphic. Getting those reads into L1 turns the 100 ms of memory into 1 ms: 107 ms becomes 8 ms, a 93% cut. "Virtual calls are slow" is almost always a claim about the wrong 6.5%.

Python has no vtable. shape.area() looks in the instance __dict__, then walks the classes in type(shape).__mro__ until it finds the name. Each step is a dict probe — a hash plus one or two memory references, order 100 ns on the cost model from hash maps. So a Python method call runs roughly two probes, order 200 ns, against the 2–7 ns a Java virtual call costs across those two regimes. CPython 3.11 caches the resolved attribute per bytecode instruction and skips the walk while the receiver's type holds steady, which brings a single-type site back to one probe. The gap is still 30–100×, and it is a property of attribute lookup in general, not of polymorphism.

C++ changes one answer: dispatch is virtual only where you wrote virtual. A non-virtual call binds statically and costs nothing extra, and calling a virtual method on a base-class value rather than a reference slices the object and runs the base implementation. Python and Java have no equivalent trap: every method dispatches dynamically by default.

The refactor, and its real arithmetic

Start with the version that is wrong for the reason worth seeing:

def area(shape):
    if shape["kind"] == "circle":
        return math.pi * shape["r"] ** 2
    if shape["kind"] == "square":
        return shape["side"] ** 2
    raise ValueError(shape["kind"])

The defect is not this function. It is that perimeter, bounding_box and to_svg each contain the same ladder. Six shapes and four operations is 24 arms spread across four files, and a seventh shape means editing all four. Miss one and nothing breaks until a triangle reaches that code path in production.

The polymorphic form moves the same 24 bodies into six files of four methods:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...

class Circle(Shape):
    def __init__(self, r: float): self.r = r
    def area(self) -> float: return math.pi * self.r ** 2

class Square(Shape):
    def __init__(self, side: float): self.side = side
    def area(self) -> float: return self.side ** 2
abstract class Shape {
    abstract double area();
}

final class Circle extends Shape {
    private final double r;
    Circle(double r) { this.r = r; }
    @Override double area() { return Math.PI * r * r; }
}

final class Square extends Shape {
    private final double side;
    Square(double side) { this.side = side; }
    @Override double area() { return side * side; }
}

The amount of code is unchanged; what changed is who notices a gap and when. Adding a shape is one new file and no edits to existing ones — open to extension, closed to modification. Leave a method out and Java refuses to compile the class; Python's abc refuses to instantiate it — construction time rather than render time. Both beat a ValueError on a page a customer is looking at.

When the switch is the better design

Classes make adding a type cheap and adding an operation expensive; a switch makes adding an operation cheap and a type expensive. You cannot have both, so pick by the axis that actually moves. Three shapes unchanged for five years, and a new operation every sprint, is a switch: one new function per operation against three class edits.

Three more cases where the switch wins outright:

  • You need exhaustiveness over a closed set. A sealed interface with a pattern-matching switch in Java, or std::variant with std::visit in C++, makes a missing case a compile error. An open hierarchy cannot, because by design you do not know all the subclasses.
  • The tag is data, not identity. Dispatching on a protocol version from a header, or a mode string from config, does not improve by wrapping the tag in an object.
  • It is small and it appears once. Replacing five lines over three cases with an interface and three classes is four new files for five lines.

The trigger for the refactor is the second switch on the same tag. One switch is data. Two is a type you have not declared yet, and where the drift starts.

In an interview

What is tested is whether you can name where the decision happens. Say it plainly: "the method is selected at run time from the receiver's type, through a per-class table of function pointers; the slot index is fixed at compile time." That sentence covers static versus dynamic binding without the vocabulary quiz.

The follow-up is nearly always extension — a new payment method, a new export format, a new notification channel. Answer with the axis: "operations here are stable and types keep arriving, so I want a type per case and no switch. If it were the other way round I would keep the switch and make it sealed so the compiler catches a missing case."

The mistake that loses points: asserting virtual calls are expensive with no number. Give the 7-of-107 ms split instead, and say what you would actually profile. The second mistake is refactoring the first switch you see; say that the second occurrence is the signal.

Overriding is only one of three things that get called polymorphism — overloading and duck typing are separate mechanisms that resolve at different times.

Check yourself

A loop calls area() on 1 million shapes, and each body reads one field from main memory. The call site is megamorphic, so dispatch costs about 7 ns. What fraction of the loop is dispatch, and what should you optimise?

1,000,000 × 7 ns = 7 ms of dispatch against 1,000,000 × 100 ns = 100 ms of memory: 7/107 ≈ 6.5%. Removing polymorphism caps at a 6.5% win. Getting those reads into L1 at 1 ns turns 100 ms into 1 ms and the loop into 8 ms. Fix the data layout.

Three shape types that have not changed in years, and roughly one new operation a month. Hierarchy or switch?

Switch, ideally over a sealed type so the compiler flags a missing case. The moving axis is operations: one new function versus three class edits per operation. The hierarchy only pays off when types are what keep arriving.

You need area() to behave differently for 1 million live instances. Assign to the class, or to each instance?

The class: one store, and every instance picks it up on its next call, because lookup walks type(shape).__mro__ after missing the instance __dict__. Per instance is 1,000,000 dict writes at order 100 ns — 0.1 s — and each one shadows the class attribute, so the method now lives in two places.