skip to content

Polymorphism & Dynamic Dispatch

One call site working over many implementations, with the running object's actual type deciding which code runs. Interviewers use overloading against overriding to test what is decided when.

on this pageshow

questions

9

An operation's behaviour depends on the runtime types of TWO objects, not one — for example what happens when two different kinds of game entity collide, or how a document node is rendered onto a particular device. Explain what a single-dispatch language can and cannot decide for you here, and how languages differ in what they offer.

level: middleimportance: should knowfreq 45%

answer

  1. one runtime type selected, the rest declared
  2. Julia/CLOS: table keyed by the argument tuple
  3. Clojure defmulti: dispatch value is any function
  4. singledispatch = first argument only
  5. two hops resolve two types, at the cost of a wide interface

basics

~20 s

Single dispatch consults exactly one runtime type — the receiver — so the second object's runtime type is invisible to selection. Julia and Common Lisp key methods on the whole argument tuple, Clojure lets you name the dispatch function, and elsewhere you bounce through a second call on the other object.

solid answer

~60 s

Selection uses one runtime type; every other argument contributes only its declared type. Languages diverge on how much of that gap they close. - **Julia**: `collide(a::Asteroid, b::Ship)` is one entry in a table keyed by the tuple of runtime types. Adding a new pair is a new top-level method that touches no class — and no class owns the operation any more. - **CLOS**: `defmethod` specialises several parameters at once, combining applicable methods with `call-next-method`. - **Clojure**: `defmulti` takes an arbitrary dispatch function, so you can key on both classes or on data; ties need an explicit `prefer-method`. - **Python**: `functools.singledispatch` is single by construction — it examines the first argument only. - **C#**: casting one argument to `dynamic` defers binding to runtime, buying two-type selection in one line and paying with `RuntimeBinderException` instead of a compile error. - **Manual technique**: the receiver call resolves the first type, then calls back on the second object passing itself, so two dynamic hops resolve two types. The catalogue name for that shape belongs to the design-patterns tree.

code

julia · 8 lines
julia
struct Asteroid end
struct Ship end

collide(a::Asteroid, b::Ship)     = "boom"
collide(a::Ship,     b::Asteroid) = "dodge"
collide(a::Any,      b::Any)      = "nothing"

collide(Asteroid(), Ship())   # "boom"  -- both types consulted

go deeper

for a junior

Know that a normal method call picks its body from one object's runtime type, and that the other arguments do not participate in that choice.

for a middle

Be able to describe the two-hop technique in mechanism terms and name a language whose dispatch already covers the whole argument tuple.

for a senior

Choose between two-hop dispatch, a type switch and a genuine multimethod based on which axis will grow, and state what each gives up.

for a principal

Frame it as open method tables versus closed variant sets: extensibility with runtime-only failure reporting against exhaustiveness with compile-time coverage, and pick per the stability of the type sets involved.

## What single dispatch actually decides In a mainstream object-oriented language a call selects a method from **one** runtime type: the receiver's. Everything else about the call — how many arguments there are and what they are declared to be — is fixed before the program runs. So an operation whose correct behaviour depends on *two* runtime types has half its selection performed by the language and half left to you. That is not a defect in any single language; it is a definition. "Single dispatch" names the choice to make one participant privileged. The interesting material is what different languages do with the other participants. ## Languages that dispatch on the tuple **Julia** is built on multiple dispatch. A method is an entry in a table keyed by a tuple of parameter types, and a call selects the most specific applicable entry using the runtime types of all positional arguments. `collide(a::Asteroid, b::Ship)` and `collide(a::Ship, b::Asteroid)` are two ordinary methods; adding a third entity type means adding methods, not editing existing ones. **Common Lisp's CLOS** does the same with `defmethod`, and adds method combination: `:before`, `:after`, `:around` methods and `call-next-method` let you compose the applicable set instead of choosing exactly one body. **Clojure's `defmulti`** generalises further: the dispatch *value* is whatever a user-supplied function returns. Dispatching on `[(class a) (class b)]` gives you type-pair dispatch; dispatching on a field value gives you something no type-based system offers. Hierarchies for these values are user-defined with `derive`, not read off the class graph. **Groovy**, running on the JVM, resolves argument types at runtime, so an ordinary set of same-named methods behaves like a small multimethod table — with the corresponding shift of selection failures from compile time to runtime. **C#** offers a targeted escape hatch: mark one argument `dynamic` and the binder re-runs selection at runtime for that call site. It is the shortest route to two-type selection in a statically bound language, and it converts a compile-time error into `RuntimeBinderException`. **Python** deliberately stops at one: `functools.singledispatch` is documented as generic-function dispatch on the *first* argument, and third-party libraries exist precisely because the standard library declined to go further. ## The manual technique When the language gives you none of the above, you can still resolve two types with two ordinary single dispatches. The first call goes to one object, which resolves that object's runtime type. Inside that body, the object's own type is now statically known, so it calls a second, differently-named operation on the other object, passing itself. The second call resolves the second runtime type — and the body it lands in knows both. Two dynamic hops, two types resolved. The cost is structural, not syntactic: the second object's interface must contain one entry per possible first type. Adding a new kind of first type therefore edits every implementation of that interface. That is the coupling that makes this technique a design decision rather than a trick, and it is why the catalogue pattern built on it (covered under design patterns, not here) is always discussed together with how stable the type set is. ## The alternative: match on the pair Instead of arranging for the language to dispatch twice, you can inspect both types yourself in one place — a type switch, or a pattern match over a tuple. Rust matching a `(Shape, Device)` tuple of enum variants, or Scala matching a pair of sealed-trait cases, gets something none of the dispatch mechanisms above provide: the compiler checks that you covered every combination and names the ones you missed. Julia and Clojure cannot offer that, because their method tables are open by construction — any module may add an entry later, so no closed set exists to check against. The trade is therefore not "clean versus hacky". It is: open method table with runtime-only ambiguity reporting (Julia, CLOS, Clojure), versus closed variant set with compile-time exhaustiveness (Rust, Scala, Kotlin), versus two-hop manual dispatch that keeps the operation extensible but freezes the participant types. ## What interviewers are checking That you can state the limitation precisely — one runtime type, the rest declared — rather than as "polymorphism does not work here"; that you know at least one language where the limitation simply does not exist; and that you can describe the two-hop technique in mechanism terms, including which axis it makes expensive.

  • What does the two-hop technique cost when a new participant type is introduced?
    The second object's interface has one entry per possible first type, so a new first type adds a member to that interface and forces an edit in every implementation of it. The operation set stays cheap to extend — you can add whole new operations without touching the participants — but the participant set becomes expensive. That asymmetry is the whole reason to choose or reject the technique.
  • Why can a pattern match over a pair of types offer exhaustiveness checking when Julia's dispatch cannot?
    Exhaustiveness requires a closed set of possibilities known to the compiler, which sealed hierarchies and enums provide. Julia's method table is open: any package may add a method for a new type pair at any time, so there is no complete set to check against. The openness that makes multiple dispatch extensible is exactly what makes static coverage checking impossible.

saying these in an interview costs you the question

  • Saying the language 'cannot do polymorphism on two types' without distinguishing single dispatch from multiple dispatch as language designs.
  • Believing every OO language resolves arguments by their runtime types, then being surprised by a statically bound selection.
  • Assuming Python's functools.singledispatch examines all arguments.
  • Presenting the two-hop technique as free, ignoring that it freezes the participant type set.
  • Reaching for reflection or instanceof chains without considering that the language may already have a dispatch mechanism (Clojure defmulti, C# dynamic).

context

open as a page

You are designing a library API whose surface will be consumed from languages that have no method overloading at all — Go, Python, JavaScript and Objective-C among them. How should you handle operations that would naturally be overloads, and what do overload sets turn into on the other side?

level: middleimportance: should knowfreq 30%

basics

~20 s

An overload set is not a portable API concept — only a name plus an arity survives a boundary. Express variants as distinct names (Go's style), keyword or default parameters (Python), an options object (JavaScript), or argument labels (Objective-C). Never distinguish variants by argument type alone.

open as a page

Parametric, subtype and ad-hoc polymorphism are usually listed as three flavours of one idea. What information does each of them actually use to choose an implementation, and why can some languages select an implementation from the return type while others cannot?

level: middleimportance: should knowfreq 45%

basics

~20 s

Subtype dispatch chooses on the receiver's runtime type. Overloading chooses on the static argument types. Typeclass/trait resolution (Haskell, Rust, Swift) chooses on the whole inferred type, so it can select from the return type. Parametric polymorphism chooses on nothing.

open as a page

In most class-based languages a call like obj.method() is resolved from the object's actual class, while a read like obj.field is resolved from the declared type of the expression obj. Why does state bind to the declared type, and how do the languages that do let a subtype substitute state actually achieve it?

level: middleimportance: should knowfreq 42%

basics

~20 s

A field read is a load at an offset the compiler fixes from the declared type, so there is nothing to reroute. A virtual call is a table lookup, which can be rerouted. Substitutable state must therefore be method-shaped: properties, accessors, descriptors.

open as a page

When a call is resolved from the receiver's runtime type, the runtime needs a table of implementations somewhere. Compare where that table lives in C++, Go, Rust and Ruby, and what each placement makes possible or impossible.

level: middleimportance: should knowfreq 45%

basics

~20 s

C++ and Java store a table pointer inside the object, fixed at construction. Go and Rust store it in the reference (interface value, fat pointer), so a type can be made dispatchable after it exists. Ruby and Objective-C have no table: they look up by message name and cache.

open as a page

Why does a routine written against an uninspectable type parameter give its caller a stronger guarantee than the same routine written against a common base interface, and which languages let a generic body break that guarantee?

level: seniorimportance: should knowfreq 38%

basics

~20 s

A body that cannot inspect its type parameter can only move values around — it cannot invent, compare or branch on them, and the caller gets the exact type back. Languages that expose type parameters at run time (C# typeof(T), Go via reflect or a type switch, Scala TypeTags) let the body cheat.

open as a page

Kotlin, C++ and C# make a class's methods non-overridable unless the author writes `open` or `virtual`; Java and Python make them overridable unless the author writes `final` or an equivalent. Setting the API-design argument aside, what does each default buy or cost the machinery that has to resolve the call — an ahead-of-time compiler, a JIT, and a framework that generates subclass proxies at runtime?

level: seniorimportance: should knowfreq 31%

basics

~20 s

Sealing is a compile-time promise. An ahead-of-time compiler needs it to skip the dispatch table and inline. A JIT gets the same win speculatively from the classes actually loaded, and undoes it when a second implementation appears — so final buys it far less.

open as a page

A dispatcher that selects a method from the tuple of all argument runtime types has to answer questions a single-receiver dispatcher never faces. Name those questions, and explain how real systems such as Julia, Common Lisp's CLOS and Clojure's multimethods answer them.

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

Specificity over tuples is only a partial order, so two candidates can be incomparable — neither is more specific than the other. Julia reports an ambiguity error at the call, CLOS imposes a total order by leftmost argument, Clojure demands an explicit preference. Method tables also become open and unowned.

open as a page

Selecting a method from the receiver's runtime type costs more than calling a fixed function. Compare how these four runtimes claw that cost back, and what each gives up: Rust's choice between monomorphised generics and dyn Trait objects; a C++ compiler that can devirtualise only under link-time optimisation or an explicit final marker; the HotSpot JVM's inline caches and speculative devirtualisation backed by deoptimisation; and the per-class method caches in Objective-C's objc_msgSend and in Ruby.

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

The indirect jump is cheap; losing inlining is the real cost. Rust chooses per call site: monomorphise (fast, code bloat) or dyn Trait (indirect, one copy). C++ devirtualises only when it can prove the exact type. HotSpot speculates from profiles and deoptimises when wrong. Objective-C and Ruby cache lookups and flush on class mutation.

open as a page