skip to content

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%

answer

  1. receiver value / static arg tuple / whole type / nothing
  2. return-type dispatch = traits and typeclasses only
  3. parseInt vs parseDouble = missing return-type resolution
  4. Python has no overloading; second def rebinds
  5. bounds re-add exactly the ad-hoc power they name

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.

solid answer

~50 s

Same word, three different resolution inputs. - **Subtype dispatch** keys on one runtime value, the receiver — Java virtual calls, C++ virtual members, Python attribute lookup, Go interface values. It needs a value in hand, so it can never select on the *result* type. - **Overloading** keys on the static types of the argument tuple, decided by the compiler — C++, C#, Java, Kotlin. Python and Ruby have no overloading at all: a second `def` rebinds the name, so they emulate it with runtime branching or `functools.singledispatch`, which keys on the first argument only. - **Typeclass/trait resolution** keys on the inferred type of the whole expression, return type included — Rust `"42".parse::<i32>()` and `T::default()`, Haskell `read`, `mempty`, `minBound`. Java and C# cannot express this and pay for it with a differently named factory per target type (`Integer.parseInt`, `Double.parseDouble`) or an explicit witness argument. - **Parametric polymorphism** uses nothing: the body works for every T because it cannot inspect T.

code

rust · 3 lines
rust
let n: i32 = "42".parse().unwrap();
let f: f64 = "42".parse().unwrap();  // same call text, different impl
let empty: Vec<u8> = Default::default();

go deeper

for a junior

Be able to say that one name can have many implementations, and that a subtype call picks the body from the object at run time while an overload is picked by the compiler from the argument types.

for a middle

State the three resolution inputs cleanly and give one language per flavour, including a language that lacks overloading entirely.

for a senior

Lead with the return-type fault line and show its fingerprints in API design — named factory families and witness arguments in Java and C# versus inference-directed resolution in Rust, Haskell and Swift.

for a principal

Frame the choice as which axis your API expects to grow and which resolution information your host languages can express, especially when the surface must bind into languages with no overloading and no return-type resolution.

## Why the taxonomy exists "Polymorphism" means one name standing for many implementations. The classic classification (Strachey, refined by Cardelli and Wegner) splits it into *universal* polymorphism — one abstraction covering an unbounded family of types — and *ad-hoc* polymorphism — a finite set of separately written implementations that happen to share a name. Universal splits again into parametric and inclusion (subtype) polymorphism. The useful modern reading of the taxonomy is not the family tree but the question: **what information does the language use to pick the implementation, and when is that information available?** ## Subtype dispatch: one runtime value A virtual call inspects the receiver at run time and jumps to the implementation belonging to that object's class. Java, C++ (for `virtual` members), C#, Kotlin, Swift, Python, Ruby and Go all provide this, differing only in whether it is opt-in and whether conformance is declared or structural. The defining limitation is that dispatch needs an existing object. A factory, a parser, a "zero value", an empty collection — none of these have a receiver of the type you want, so subtype dispatch is structurally incapable of producing them polymorphically. ## Ad-hoc, form one: overloading Overload sets are resolved from the *static* types of the arguments. C++, Java, C# and Kotlin all do this at compile time; the runtime class of an argument is irrelevant. Two consequences worth naming: overloading is not a runtime mechanism at all, and it is not universal — Python and Ruby simply do not have it. In Python, defining a function twice rebinds the name; the first body becomes unreachable. Where Python wants type-directed behaviour it uses `functools.singledispatch`, which is genuinely runtime dispatch but only on the first positional argument, or it uses duck typing and lets the operation fail if the object lacks the method. ## Ad-hoc, form two: typeclasses, traits and protocols Haskell typeclasses, Rust traits, Swift protocols with associated types and Scala `given` instances resolve differently again: the compiler solves for an instance/impl using the whole inferred type of the expression, not just the arguments. The chosen instance is passed as a hidden dictionary (Haskell, and Rust for `dyn Trait`) or monomorphized in (Rust's static path). Because the solver sees the expected type of the result, **the return type participates in selection**: - Rust: `let n: i32 = "42".parse().unwrap();` and `let f: f64 = "42".parse().unwrap();` are the same call text choosing different impls. - Rust: `Default::default()`, `Vec::new()` plus inference, `T::from_str`. - Haskell: `read`, `mempty`, `minBound`, `fromInteger` — all keyed by what the context needs. ## Why return-type selection is the fault line Java and C# have subtype dispatch and overloading, so they cover argument-directed and receiver-directed choice, but they have no mechanism whose input is the expected result type. The workarounds are visible in every mainstream API: a distinct method name per target type (`Integer.parseInt` versus `Double.parseDouble`), or an explicit witness passed as an argument (`Class<T>`, a `TypeToken`, a `Supplier<T>`, a `Comparator<T>`) so the missing type information is smuggled in as a value. When you see a family of near-identical `parseX` methods, you are looking at a language without return-type-directed resolution. ## Parametric polymorphism: dispatch on nothing A parametric routine takes a type parameter it cannot examine. `identity`, `swap`, `reverse : List<T> -> List<T>` behave the same for every T because there is nothing type-specific to behave differently about. That is the point: the absence of dispatch is a guarantee to the caller, not a limitation. Bounded parameters ("T must be orderable") re-introduce exactly as much ad-hoc capability as the bound names, and no more — which is why bounds are usually expressed in the trait/typeclass vocabulary rather than the subtype one in Rust and Haskell, and why Go's type-parameter constraints are *type sets* (`~int | ~float64`) rather than interfaces when the operation is an operator that builtin types have no method for. ## Where the mainstream languages sit Java and C#: subtype plus overloading plus generics; no return-type-directed resolution. C++: subtype (opt-in), overloading, and templates whose bodies are checked structurally at instantiation, with C++20 concepts making the constraint declared. Rust: no inheritance at all, no overloading; traits carry the whole ad-hoc load, including return-type direction. Go: structurally satisfied interfaces for subtype dispatch, type-set constraints for parametric code, no overloading. Python and Ruby: everything is a runtime lookup, so the taxonomy collapses into duck typing. ## Interview framing Answer with the resolution input, not the definition: receiver value, static argument tuple, whole type environment, nothing. Then show you know the consequence — that only the third can pick an implementation before any value of that type exists.

  • Where do C++ templates sit in this taxonomy?
    A template is parametric in shape but ad-hoc in checking: before C++20 the body was type-checked only at instantiation, against whatever operations the argument type happened to provide, so the constraint was implied by the body rather than declared. Overloads and explicit specializations then let the same template name behave differently per type, which is ad-hoc selection layered on a parametric surface. C++20 concepts make the requirement declared and the error message local, without changing the generated code.
  • What kind of polymorphism do Go interfaces provide, and how does that differ from Java interfaces?
    Both give subtype dispatch through a table, but Go conformance is structural and implicit — a type satisfies an interface by having the methods, never by naming it — while Java conformance is nominal and declared. That means a Go package can define an interface that foreign types already satisfy, whereas in Java the foreign type must have been written to implement your interface or be wrapped. Go pairs this with type-parameter constraints expressed as type sets, because operators like `<` are not methods on builtin types.

saying these in an interview costs you the question

  • Saying overloading is resolved at runtime — in C++, Java, C# and Kotlin the overload set is settled from static argument types.
  • Claiming Python supports overloading; a second def simply rebinds the name, and singledispatch keys on the first argument only.
  • Describing generics as "a base type plus casts" — that conflates one implementation strategy with the kind of polymorphism and cannot explain Rust or Haskell resolving on the return type.
  • Asserting that anything a trait or typeclass does can be done with an interface and inheritance; receiver dispatch cannot select an implementation before a receiver exists (Default::default, mempty).
  • Treating bounded type parameters as "generics with inheritance" — a bound grants exactly the named operations and nothing else.

context