skip to content

Subtype Polymorphism

A call made through a supertype reference runs the actual object's override, resolved through a virtual method table. Interviewers ask for the mechanism, not just the definition.

on this pageshow

questions

2

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%

answer

  1. vptr in object = closed set of abstractions
  2. Go interface value = (itab, data); Rust dyn = fat pointer
  3. itab built lazily by method-set match
  4. no table: selector lookup + inline cache + method_missing
  5. C++ multiple inheritance = extra vptrs and this-thunks

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.

solid answer

~50 s

Three placements, each with a different open/closed tradeoff. - **Inside the object (C++, Java)**: a vtable pointer per object, fixed at construction; calls are a constant-offset load. C++ multiple inheritance pays extra vptrs and `this`-adjusting thunks. The abstractions a class participates in are frozen at its declaration. - **Inside the reference (Go, Rust)**: a Go interface value is (itab, data), built lazily at runtime by matching method sets; Rust's `&dyn Trait` is a fat pointer (data, vtable) built per (type, trait) at compile time. The object itself carries nothing, so conformance can be added after the type exists — structurally in Go, via a later `impl` in Rust, bounded by the orphan rule. - **No table (Ruby, Objective-C, Smalltalk)**: send a selector, walk the ancestor chain, cache the hit. `method_missing` / `forwardInvocation` let a receiver answer a message it has no entry for — transparent proxies for free, no static guarantee, and every redefinition invalidates caches.

code

ruby · 8 lines
ruby
class Remote
  def method_missing(name, *args)
    "forwarded #{name}"
  end
  def respond_to_missing?(*) = true
end

Remote.new.anything_at_all   # => "forwarded anything_at_all"

go deeper

for a junior

Be able to say that the runtime type of the receiver picks the body, and that C++ and Java use a per-class table pointed to from the object.

for a middle

Name all three placements — in the object, in the reference, none — with one representative language each and the extensibility consequence of each.

for a senior

Discuss the cost side: C++ thunks under multiple inheritance, Go's lazily built itabs and the nil-interface-holding-nil-pointer trap, cache invalidation under Ruby metaprogramming.

for a principal

Frame it as who owns the abstraction — type author, consumer, or nobody — and use that to decide plugin and library boundaries, including whether your platform needs a coherence rule.

## The problem being solved Subtype polymorphism means a call site names an operation, not an implementation: the *receiver* (the object the call is sent to) decides which body runs, based on its runtime type rather than the declared type of the variable holding it. That is one sentence of assumed background. The interesting engineering question is *where the mapping from operation to body physically lives*, because that placement — not the dispatch rule — decides who is allowed to extend the system later. There are exactly three answers in wide use. ## Table inside the object C++ gives every polymorphic class a virtual method table and every instance a hidden pointer to it, written during construction. A call becomes: load vptr, load slot at a fixed index, call. The index is known at compile time because the class hierarchy is known at compile time. Single inheritance keeps this clean. C++ multiple inheritance does not: an object that inherits two polymorphic bases carries two vptrs, and calling through the second base requires a *thunk* that adjusts the `this` pointer before entering the body. Virtual inheritance adds another level of indirection for the shared subobject. This is the real cost of C++'s expressiveness, and it is invisible in the source. Java keeps classes single-inheritance, so class vtables stay simple, but interfaces reintroduce the problem: one class implements many interfaces and the slot index for a given method differs per interface, so interface calls use an interface method table search or, in practice, a polymorphic inline cache in the JIT. The consequence of table-in-object is a **closed world per type**: the set of abstractions an instance can participate in is fixed at the class's declaration. Someone else's class cannot be made to satisfy your abstraction; you wrap it. ## Table inside the reference Go stores nothing in the struct. An interface value is two words: a pointer to an *itab* (interface type + concrete type, with the resolved method pointers) and a pointer to the data. The itab is constructed lazily at runtime the first time a concrete type is stored into that interface type, by matching method names and signatures, and then cached. Rust does the same at compile time. `&dyn Trait` and `Box<dyn Trait>` are fat pointers carrying (data pointer, vtable pointer), where the vtable exists per (concrete type, trait) pair. A plain `Foo` value has no header and no hidden field; dispatch information appears only when you erase the type behind `dyn`. The consequence is an **open world**: a type can be joined to an abstraction it never heard of. Go does it structurally — declare the interface beside the consumer and everything with matching methods already fits. Rust does it nominally but retroactively, with the orphan rule preventing two crates from supplying rival implementations for the same pair. The cost is symmetric: a concrete value carries no dispatch information at all, so recovering the dynamic type needs extra machinery — Go type assertions read the type descriptor in the interface value, Rust needs `Any` with an explicit downcast. ## No table at all Ruby, Objective-C and Smalltalk send *messages*. The receiver's class is searched, then its ancestors, for a method with that name; misses are handled by a hook — `method_missing` in Ruby, `doesNotUnderstand:` in Smalltalk, `forwardInvocation:` in Objective-C. Python is close: attribute lookup walks the MRO produced by C3 linearization, through per-class dictionaries, mediated by descriptors. JavaScript walks the prototype chain. Because lookup is by name at send time, a receiver can answer a message for which no body exists — the basis of Ruby's ActiveRecord dynamic finders, Objective-C's NSProxy, and JavaScript's `Proxy`. The price is that nothing is statically guaranteed, and the engines' inline caches (Ruby's method cache, V8's hidden classes, objc_msgSend's selector caches) are invalidated whenever someone redefines a method, so metaprogramming has a performance cliff. ## Reading the three against each other - Table in object: fastest and most rigid; the type author owns the abstraction list. - Table in reference: nearly as fast, and the *consumer* can own the abstraction — which immediately raises the coherence question of who may supply the mapping. - Name lookup: maximally open and reflective; correctness moves from the compiler to your test suite. One systems note: the same choice reappears outside languages — a plugin host that requires plugins to declare an interface is table-in-object, and one that probes for a method by name is message lookup. ## What interviewers are checking That you know "dynamic dispatch" is a family of implementations with different extensibility properties, not a synonym for "vtable", and that you can say what each one forbids.

  • Go interfaces are satisfied structurally. What does that cost on a large codebase?
    Satisfaction is accidental: any type with a matching method name and signature joins the interface whether or not it means the same thing, and there is no declaration site to grep for when you ask who implements an abstraction. Teams recover intent with consumer-side interfaces kept tiny and with compile-time assertions such as `var _ Closer = (*Conn)(nil)`. The upside is that vendored types you cannot edit already fit.
  • Why does Rust have an orphan rule, and when does it bite?
    Coherence: there must be exactly one implementation of a trait for a type across the whole program, otherwise linking two crates that each supplied one would produce two different vtables and the winner would depend on link order. The rule enforces that by requiring either the trait or the type to be local to your crate. It bites when you want a foreign trait on a foreign type, and the standard workaround is a newtype wrapper.
  • If dispatch is name-based in Ruby and JavaScript, why are those calls not catastrophically slow?
    Engines cache the resolved target per call site — inline caches keyed on the receiver's class or hidden class — so a monomorphic site costs a compare plus a direct call. The cost surfaces when sites go megamorphic, or when a redefinition or prototype mutation invalidates the caches, which is why heavy monkey patching at runtime degrades performance in a way that is hard to attribute.

Table in the object is a badge sewn into the uniform at the factory; table in the reference is a visitor pass handed out at the door, so anyone can be admitted later; name lookup is shouting a request into the room and seeing who answers.

saying these in an interview costs you the question

  • Saying every dynamically dispatched call is a vtable lookup — Ruby, Objective-C and JavaScript have no vtable and resolve by name with caches.
  • Assuming the dispatch pointer is always inside the object; in Rust and Go it is in the reference, and a plain struct value has no hidden header.
  • Treating Go's structural interfaces as syntax sugar for declared ones — they move ownership of the abstraction from the type's author to the consumer.
  • Claiming C++ multiple inheritance is free because it is compile-time — it costs extra vptrs and this-adjusting thunks.
  • Believing a concrete value always knows its own dynamic type; in Rust you need Any plus an explicit downcast to get it back.

context

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