Polymorphism
0. Introduction
Polymorphism is commonly understood through inheritance and overriding. The classic example: put a Dog object into an Animal variable, call Speak(), and Dog.Speak() actually executes.
using System;
public abstract class Animal
{
public abstract void Speak();
}
public sealed class Dog : Animal
{
public override void Speak()
{
Console.WriteLine("Woof");
}
}
public sealed class Cat : Animal
{
public override void Speak()
{
Console.WriteLine("Meow");
}
}
public static class Program
{
public static void Main()
{
Animal animal = new Dog();
animal.Speak(); // Woof
}
}(These days implementing an interface is more common, but this uses the older style for clarity.) Here, the static type of the variable animal is Animal, but the actual object is Dog. So calling animal.Speak() executes Dog.Speak(). The supertype does the talking, while the subtype determines the actual behavior.
This is not a bad starting point for beginners, and it conveys quite a lot, making it an excellent foundation for grasping the basics of object-oriented programming.
However, trying to explain the full picture of polymorphism with just this one example quickly hits a wall. Inheritance-based polymorphism is powerful when the hierarchy is shallow and behavior is clear, but as the structure deepens, it often ends up undermining code flexibility. In truth, most polymorphism implementations have points worth criticizing. The reason inheritance-based polymorphism draws an unusually large share of criticism is, paradoxically, that it was too successful.
What is more, this example only shows the surface appearance of "same name, different behavior," and does not adequately explain why polymorphism is needed or what problems it exists to solve.
The English term is Polymorphism. poly means many, and morph means form. In biology, the word refers to a phenomenon in which different forms appear within the same species depending on sex, genotype, or environmental conditions. Think of butterfly wing patterns as an example of multiple morphs coexisting. (This is distinct from metamorphosis, the transformation of a larva into an adult.)
When this analogy is carried into programming, the metaphor shifts from the natural world to the mechanical. On the surface everything looks the same: the same name, the same function, the same interface. But underneath, two different things are happening. In some cases, structurally identical code works across different types; in other cases, a different implementation is selected depending on the type. To the caller there is one name, but the compiler and runtime may be managing different functions, different tables, different code-generation strategies, and different memory representations behind the scenes.
This document approaches polymorphism through the question: "How does a single name relate to multiple types?" Inheritance and overriding are just one scene in that story. Once you include generics, overloading, coercion, type classes, trait objects, and multiple dispatch, things get considerably more complex. The plan is to start from the original definitions and work through them in order.
Strachey's Two Axes
Strachey organized this complexity along two axes.
Category | Meaning | Example |
|---|---|---|
Parametric | Operates uniformly regardless of type. |
|
Ad hoc | Uses the same name but selects a different implementation for each type. | When |
Cardelli-Wegner's Extension
Cardelli and Wegner broadened that distinction further, placing parametric, inclusion/subtyping, overloading, and coercion together in a single table.
Universal polymorphism | Ad hoc polymorphism |
|---|---|
parametric | overloading |
inclusion / subtyping | coercion |
Wadler-Blott's Shift
Wadler and Blott took a different direction: rather than discarding ad hoc polymorphism, they proposed disciplining it within the type system.
A type class does not work by saying "handle any type as you see fit." Instead, it establishes contracts like Eq, Ord, and Show, and the compiler finds implementation evidence (a dictionary) satisfying those contracts and passes it as an implicit argument.
How later languages express contracts
Haskell: type class
Rust: trait
Swift: protocol
Scala: given/using, type class pattern
C#: interface + generic constraint
TypeScript: structural constraint
We often think of Haskell's type classes when we hear "contracts for polymorphism," but the contract mechanisms in actual languages are not direct descendants of Haskell's. This is more like carcinization in biology (where organisms from entirely different lineages independently evolve a crab-like form): these mechanisms started from completely separate lineages and converged on similar shapes. Tracing each lineage back to its origins makes it clear why viewing C# through a Haskell lens feels awkward, and why TypeScript charted its own independent course.
Looking at the actual implementations across languages, you find a mixture of distinct lineages: OOP interfaces, ML module systems, structural typing, and implicit parameters. C#'s generic constraints are closer to the nominal type and OOP interface lineage. You can require that a type implements a contract with a specific name, as in where T : IFoo, or you can apply constraints on how a type is represented or whether it can be instantiated, as with class, struct, new(), and unmanaged. TypeScript's structural constraints are closer to bringing the sensibility of duck typing into static type checking: rather than calling something at runtime because "it looks like it should work," TypeScript verifies at compile time whether the required structure is present. Each language implements polymorphism in its own distinct way, yet they are all subject to the same pressure and converge on the same goal. Declaring "I'll accept any type" is far too dangerous, so the system must impose strict constraints that admit only types satisfying certain capabilities or structures.
Statically typed languages try to surface those conditions on the type level, while dynamic languages fill that role with protocols, duck typing, and runtime validation. Ultimately, polymorphism is less about freedom and more about contracts. Those contracts must exist somewhere: in static types, in tests, or in runtime validation. If they exist nowhere, it is not polymorphism but a runtime gamble.
1. Strachey 1967: ad hoc vs parametric
Christopher Strachey's Fundamental Concepts in Programming Languages originated from a lecture given at a summer school in Copenhagen in 1967. The text is frequently cited as the primary source that introduced and popularized expressions such as L-value/R-value, referential transparency, ad hoc polymorphism, and parametric polymorphism.12
The exact context in which Strachey raised the concept of polymorphism is not something we can reconstruct with certainty today, but reading him from a modern vantage point, one can infer it went something like this.
"Treating types dynamically does not make the problems of polymorphic operators disappear, because you still have to decide which version of the operator to select and where to insert the necessary coercions."1 The distinction he drew proved remarkably durable.
ad hoc polymorphism:
Selects a different implementation for each type.
parametric polymorphism:
Operates uniformly without knowing the concrete structure of the type.
For example, + has different implementations depending on the type.
int + int, float + float, and string + string all use the same symbol, yet the internal implementation differs for each type. This is ad hoc polymorphism.
Personal note: This is where Korean-speaking programmers face a subtle disadvantage. We memorize
ad hocas "애드혹" andparametricas "매개변수적," but neither "임시 다형성" nor "매개변수 다형성" feels natural to us. English-speaking learners start from the nuance of the terms themselves, while we start from the shell of a translated label. And these translations are often rendered in Sino-Korean vocabulary, which adds yet another layer of learning cost.The inner substance of programming is buried under layers of shell so thick that it is genuinely hard for Korean speakers to reach it.
The identity function, by contrast, operates with the same structure regardless of the type.
id :: a -> a
id x = xWhether a is an Int, a String, or a List<T> does not matter; what the function does never changes. It returns whatever it receives. This is parametric polymorphism.
The reason Strachey's distinction has lasted so remarkably long is simple: the question it poses is still valid.
Given the same name, do you select a different implementation for each type?
Or does it operate with the same structure even without knowing the type?
In effect, you can already see the major fork in the road that modern languages take.

Our objective is to understand the notion of *type* in programming languages, present a model of typed, polymorphic programming languages that reflects recent research in type theory, and examine the relevance of recent research to the design of practical programming languages.
— Luca Cardelli and Peter Wegner, "On Understanding Types, Data Abstraction, and Polymorphism," *Computing Surveys*, Vol. 17, No. 4, 1985
2. Cardelli-Wegner 1985: The Classification Takes Shape
Luca Cardelli and Peter Wegner's 1985 paper On Understanding Types, Data Abstraction, and Polymorphism is the canonical reference that brought greater systematization to the classification of polymorphism.3
The frequently cited classification looks roughly like this.

(Figure 1 from On Understanding Types, Data Abstraction, and Polymorphism)
Polymorphism
├─ Universal polymorphism
│ ├─ Parametric polymorphism
│ └─ Inclusion polymorphism
└─ Ad hoc polymorphism
├─ Overloading
└─ Coercion
Of everything shown here, what OOP readers will recognize most readily is inclusion polymorphism, which corresponds closely to what we now call subtype polymorphism.
IShape shape = new Circle();
shape.Draw();
The static type of shape is IShape. The implementation actually called is Circle.Draw(). The caller speaks in terms of the supertype, and the runtime object picks the implementation.
Most beginners encounter polymorphism here for the first time. As a result, it's easy to memorize it as "polymorphism = inheritance + override" and leave it at that. I did the same at first, and most people do. Textbooks are written that way too.
Personal note: Korean textbooks don't explain any of this at all, and I find myself wondering why I'm trapped by the cursed Korean language, unable to reach the broader intellectual world.
From the Cardelli-Wegner perspective, that is just one cell in the grid.
The
TinList<T>is parametric polymorphism.The
+in1 + 2and1.0 + 2.0is overloading.The automatic conversion of
inttodoubleis coercion.Treating a
Dogas anAnimalis inclusion/subtype polymorphism.
Cardelli-Wegner classify ad hoc polymorphism as a more non-uniform and implementation-dependent form, distinct from universal polymorphism. This is because overloading and coercion may look like a single operation being applied across multiple types on the surface, but at the implementation level they typically resolve to selecting different functions or performing type conversions.3
At this point, it becomes difficult to talk about polymorphism as if it were a single feature. It is better understood as a collection of ways in which a single piece of code, name, operation, or abstraction boundary can relate to multiple types.
3. Wadler-Blott 1989: Making Ad Hoc Less Ad Hoc
Philip Wadler and Stephen Blott's 1989 paper How to Make Ad-Hoc Polymorphism Less Ad Hoc is the foundational work behind Haskell type classes.45
The title is practically a summary of the paper. Overloading is convenient, but it tends to run wild without discipline.
the operation must be usable across multiple types.
However, it cannot be used with every type.
Furthermore, the implementation must differ for each type.
In Strachey's terms, this is ad hoc polymorphism, because the implementation differs for each type.
Wadler and Blott's idea was not to eliminate this ad hoc nature, but to bring it inside the type system.

(How to Make Ad-Hoc Polymorphism Less Ad Hoc Figure 3)
class Eq a where
(==) :: a -> a -> Bool
instance Eq Int where
(==) = primIntEq
instance Eq Bool where
(==) True True = True
(==) False False = True
(==) _ _ = False
Now == is no longer a symbol open to any type; it becomes an operation available only on types for which there is evidence of Eq a.
member :: Eq a => a -> [a] -> Bool
This type should be read as: "if a is a type that has an Eq instance, then given one value of type a and a list of a, it returns a Bool."
Type classes allow overloading while making the type system track which types support a given operation. The paper's title is well chosen: rather than eliminating ad hoc polymorphism, it makes it less ad hoc.
4. How Polymorphism and Variation Are Handled in FP
If you learned polymorphism only through OOP, it is worth turning your attention to the FP side.
Strachey's parametric polymorphism and Wadler-Blott's type classes are actually seen more clearly in functional programming.
The polymorphism beginners commonly encounter in OOP looks like this.
IShape shape = new Circle();
shape.Draw();
At the center is a receiver object. You send a Draw message to an object called shape, and the actual implementation is determined by the runtime object.
FP, by contrast, has a different sensibility.
map :: (a -> b) -> [a] -> [b]
map has no knowledge of what a and b are. It simply traverses the list structure and applies the function, so it operates with the same structure regardless of what the element type is.
length :: [a] -> Int
length also has no knowledge of the element type inside the list. Because it never needed to know, length [1,2,3] works just as well as length ["a","b"]. This is the sensibility of parametric polymorphism.
This perspective has to be understood as quite different from OOP's picture of "an object overriding its own methods."
OOP subtype polymorphism:
Send the same message, and the receiver's actual type selects the implementation.
FP parametric polymorphism:
Only structures that hold without knowing the type are used.
FP has ad hoc polymorphism as well. Haskell type classes are the prime example.
sort :: Ord a => [a] -> [a]
sort does not sort a list of just any type. It only accepts types that satisfy the Ord a contract. The key point is not "any a will do" but "any a that can be compared by ordering will do."
In this respect, type classes smell similar to OOP interfaces, but the core idea must be understood differently.
OOP interface:
An object has methods.
obj.compareTo(other)
FP type class:
There is an operation dictionary/evidence for the type.
compare(dict, x, y)
Another important technique for handling variation in FP is ADTs and pattern matching.
data Shape
= Circle Double
| Rectangle Double Double
area :: Shape -> Double
area shape =
case shape of
Circle r -> pi * r * r
Rectangle w h -> w * h
Here you don't call shape.Draw(). Instead, you open up the shape of the value and branch on its cases.
This difference is precisely why FP and OOP feel like they extend in different directions.
OOP:
Adding a new type is easy.
Existing operations are grouped as interface methods.
Adding a new operation may require modifying multiple classes.
FP ADT:
Adding a new operation is easy.
You just add a single pattern matching function.
Adding a new variant requires updating all existing pattern matches.
This leads directly to the expression problem that Wadler described.6
Examples like map deserve a closer look here. map :: (a -> b) -> [a] -> [b] takes a function as an argument, making it a higher-order function, and at the same time it leaves a and b unspecified, making it a parametric polymorphic function. In real FP code, the two frequently overlap in exactly this way. They are conceptually distinct categories, yet in practice they often show up together inside a single function.
To summarize, polymorphism and variation handling appear in the following forms on the FP side.
1. parametric polymorphism
Operates with the same structure without knowing the type.
Examples: `map`, `length`, `id`
2. ad hoc polymorphism / type class
Uses an operation of the same name only on types that have a specific capability.
Examples: `Eq`, `Ord`, `Show`
3. higher-order function
Rather than polymorphism in the classical sense,
takes a function as an argument and passes the choice of behavior as a value.
Examples: `map`, `filter`, `fold`
4. ADT + pattern matching
Rather than polymorphism in the classical sense,
represents multiple forms of a value as a sum type and branches with `case`.
Examples: `Maybe`, `Either`, `Shape`
OOP experiences polymorphism primarily through objects and message dispatch, while FP experiences it through type parameters, type classes, function arguments, and sum types.
Debating which side has the "real" polymorphism is best saved for when you have plenty of spare time. Higher-order functions and ADTs with pattern matching sit slightly outside classical polymorphism in the strict sense, but they are the tools FP reaches for when handling variation and behavior selection. What ultimately deserves attention is the direction of extensibility and the cost structure.
5. The Core Questions of Polymorphism Implementation
Viewing polymorphism purely through syntax names leads to confusion quickly. interface, generic, trait, and type class all cluster around polymorphism, yet the actual costs arise in different places for each. When examining an implementation, start by framing it around four questions.
1. When is it decided which implementation to call?
At compile time, or at runtime?
2. Does type information survive at runtime?
Is it erased, retained as metadata, or replicated per type?
3. Is a single piece of code shared?
Or is specialized code generated per type?
4. Where does the cost go?
Is it a runtime dispatch cost, or a compile-time/binary size cost?
Skip these questions and all you're left with are claims like "generics are fast," "interfaces are slow," or "type classes are the same as OOP." Most of the time that level of understanding is enough to get by, but it isn't precisely correct, and that gap is the hardest part. Things that work fine with a rough understanding yet aren't accurate. Sometimes the rough version holds, sometimes it doesn't, and the difference comes down exactly to when resolution happens, what type information is available, and how code is shared.
6. Subtype Polymorphism: vtable, Message Lookup, and Interface Dispatch
The most familiar approach to implementing OOP-style polymorphism is runtime dispatch. The caller sees only the supertype and makes the call, but which implementation actually runs is decided at execution time by the object's representation and the dispatch mechanism. Put simply, it works roughly like this.
Base reference
└─ Follows the actual object's vtable / method table
└─ Calls the actual implementation method
In practice the implementation varies by language and VM, using vtables, method tables, interface tables, selector lookup, inline caches, and so on, but digging into those details is the kind of thing only compiler internals enthusiasts bother with. C++'s virtual, Java/C#'s virtual and interface dispatch, and Rust's dyn Trait all belong to this family. The syntax differs, but the underlying character is the same. The key point is that the function address is not fixed at the call site; instead, the call goes through an indirect path, with dispatch information provided by the object's representation or a trait object.
Simula and virtual procedures
Simula 67 introduced core OOP mechanisms early on, including classes, subclasses, and virtual procedures.78 A virtual procedure in Simula can be overridden in a subclass, and a call made on the base type routes to the actual subclass implementation. The BETA language's discussion of virtual classes is worth consulting alongside this lineage to see how these ideas were extended later.910
This is where the basic structure of OOP-style polymorphism originates.
Speak in terms of the supertype: the caller doesn't need to know the concrete target; it simply trusts the agreed-upon interface (contract) and sends the message.
The subtype decides: the object that receives the message responds in its own way, according to its nature (the concrete class).
Runtime governs: all of this binding takes place not when the code is written, but at the dramatic moment the code actually executes (Runtime).
Smalltalk and message sending
Smalltalk placed "message sending" front and center, rather than "method calls." An object receives a message and looks up the corresponding selector through its class and inheritance hierarchy.11 From the Smalltalk perspective, then, polymorphism is less a special feature and more a natural consequence of how objects communicate with one another.
shape drawThe caller does not know whether shape is a circle or a rectangle. It simply sends the draw message. If the object can handle it, it does; if not, control goes to doesNotUnderstand:. This idea spans the same problem space as modern dynamic dispatch, duck typing, and protocol/interface-based invocation.
Lumping Smalltalk, Java interfaces, and Rust trait objects into one category is a mistake. Even so, the underlying intuition carries through: the caller sends an abstract request, and the selection of the actual implementation is deferred.
The cost of vtables
The direct cost of vtable/interface dispatch typically comes from three sources.
Indirect calls
Difficulty of inlining
Branch misprediction
Why are indirect calls expensive? It does not end with simply "reading one extra pointer." From the CPU pipeline's perspective, the call target address cannot be fixed at compile time, and the processor must follow the vtable or method table at runtime. If the branch predictor guesses the target correctly, the cost is small. At a polymorphic call site where multiple implementations keep interleaving, however, misprediction probability rises, and when the prediction is wrong the CPU must discard already-fetched instructions and refill the pipeline. This is the pipeline flush cost people commonly refer to.
Memorizing "virtual calls are always slow" is also wrong. A monomorphic call site that repeatedly hits a single implementation can be devirtualized and inlined away by a JIT or PGO. A megamorphic call site where the actual type keeps changing, by contrast, is at a disadvantage on both prediction and inlining. In the end, the cost comes from the call pattern, not from the syntax alone.
When reference-based object collections, heap allocation, and pointer chasing are added on top, cache locality can suffer as well. In that case the real source of cost is closer to object placement and memory layout than to the vtable itself. If you judge performance by looking only at a single virtual call, you usually end up directing your frustration at entirely the wrong thing.
That said, there are clear benefits on the other side as well.
Runtime replaceability
Plugin architecture
Heterogeneous object collections
Implementation hiding behind an ABI boundary
In practice, the right question to ask is not "is polymorphism slow?" but "does this site require runtime dispatch, and is this call site on a hot path?" Because polymorphism implies so many different things, you need to isolate the specific aspect you care about before asking the question.
7. What Does Polymorphism Look Like in DOP/Data-Oriented Design?
From a Data-Oriented Programming or Data-Oriented Design perspective, the question shifts somewhat. Trying to fit DOP into the Strachey or Cardelli-Wegner polymorphism taxonomy produces some awkward results (and frankly, practitioners in this space rarely ask such questions to begin with). DOP is partly a reaction to the hot-path cost of subtype polymorphism, but more broadly it represents an execution model that reframes programs around data layout and access patterns rather than object relationships.
In OOP examples, the typical pattern looks like this.
foreach (IShape shape in shapes)
{
shape.Draw();
}
The caller knows only IShape, and the actual implementation is determined at runtime by concrete types such as Circle, Rectangle, and Line. This looks clean in OOP textbooks, and it is genuinely useful from a design standpoint as well.
Someone thinking in DOP, however, sees this code and immediately pictures a very different scene.
Each object may have a different actual type.
Indirect dispatch may occur every time.
Objects may be scattered across the heap.
Hot fields and cold fields may be interleaved.
The CPU may struggle to see the same operation in sequence.
SIMD/vectorization opportunities may be reduced.DOP does not require polymorphism to be expressed through an object's virtual methods. Instead, data is split by kind, tagged, or grouped so that items sharing the same operation can be processed in a batch. There are moments when "how continuously can we process data of the same shape?" matters more than "who owns the behavior?"
For example, it looks something like this.
circles:
centerX[]
centerY[]
radius[]
rectangles:
x[]
y[]
width[]
height[]
Rendering then moves away from each object calling its own Draw(). A system or pass processes all data of the same kind in one go.
drawCircles(circles)
drawRectangles(rectangles)
In this case, part of what subtype polymorphism was doing shifts from object dispatch to data classification and processing passes. The better way to understand it is not that polymorphism disappears, but that the site where polymorphism manifests has changed.
OOP:
The object chooses its own behavior.
shape.Draw()
DOP:
Data is classified,
and the system processes data of the same form in a batch.
drawCircles(circleData)
drawRectangles(rectangleData)
DOP's skepticism toward subtype polymorphism is often explained in terms of "object abstraction being bad," but the more direct reason is that runtime dispatch, pointer chasing, cache misses, and vectorization interference can all appear together on the hot path. In game loops, rendering, physics, and large-scale simulations, these costs become noticeable faster than one might expect.
Moving to DOP doesn't make variation disappear. Just as circles, rectangles, lines, particles, and colliders can't all have the same shape, variation is unavoidable. This problem also connects to the SOLID/LSP discussion about how "pushing all variation into an inheritance hierarchy can break the model."
tagged union / enum
type id + switch
종류별 dense array
function pointer table
command buffer
ECS의 component query
A tagged union works well when the set of possible variants is relatively closed. Adding new operations is easy, but adding a new variant requires updating every switch site. Subtype polymorphism, by contrast, makes it easy to add new subtypes, but the cost is per-object dispatch and scattered data layout on the hot path. These approaches naturally carry their own costs: a growing switch brings branching overhead and code complexity, and per-type arrays introduce data movement and management overhead. On the hot loop, however, grouping data of the same kind enables better cache locality and more SIMD opportunities.
That said, type id + switch does not always outperform virtual dispatch. Iterating over a mixed-type array and branching through a switch each time is effectively just spinning through a large branch table. What matters in DOP is not that you replaced virtual with switch, but whether you actually batched data of the same tag/type together to reduce branches and cache misses.
FP's ADT + pattern matching and DOP's tagged data overlap in some respects, but their concerns are different.
FP ADT:
Constrain the possible shapes of a value within a type,
and branch on cases via pattern matching.
DOP:
Reorganize data around memory layout and processing passes,
reducing cache, SIMD, and batch processing costs in the hot loop.
Seen from this angle, the FP section is closer to an expression model, while the DOP section is closer to a memory and execution model. Even when both look at the same tag, one side focuses first on types and case analysis, while the other focuses first on array layout and processing passes.
Putting it briefly, the picture can be summarized roughly as follows.
OOP views polymorphism as the selection of an object's behavior,
FP views it through type parameters, type classes, and sum types,
while DOP considers data layout, access patterns, and the cost of batch processing together.
8. Overloading/Coercion: same name, but the compiler picks
The two axes Cardelli and Wegner placed under ad hoc polymorphism are overloading and coercion.
Overloading
void Print(int x) { ... }
void Print(string x) { ... }
The compiler looks at the argument types to decide which Print to call. The same name is used, but the actual functions are different, and this decision is typically resolved at compile time. In practice, people often call this "overloading" rather than referring to it as polymorphism.
Coercion
double x = 1; // int -> double
Even when the types don't match exactly, the compiler inserts a conversion. In this case the caller passes 1, but the actual computation may proceed under double rules.
Most developers today would not call coercion "polymorphism." In the Cardelli-Wegner classification, however, it is included as one form of ad hoc polymorphism.
Personal note: this is a point I find genuinely interesting, and it's something I noticed myself. Practitioner terminology and type-theory terminology frequently diverge. In the programming community, "polymorphism" typically brings interfaces and overriding to mind, while in type theory the term also encompasses overloading and coercion. Neither usage is wrong, but people hear the same word and picture different things, which is why conversations so often go in circles. And so the ways that programming community arguments get started are themselves polymorphic in all their varieties.
9. Parametric Polymorphism: Applying the Same Structure to Multiple Types
Parametric polymorphism accepts type parameters such as T, a, and α. It may look simple on the surface, but its strength comes from the constraint of "not knowing the type."
function identity<T>(x: T): T {
return x;
}
Because the code must work without knowing what T is, unconstrained parametric polymorphism actually imposes a strong restriction.
In pure parametric polymorphism with no constraints at all,
you cannot create new values of type T,
you cannot read fields of T,
and you cannot call concrete methods on T.
What you can do is typically:
store the received value,
return it as-is,
put it in a container,
or pass it to another function.
Adding generic constraints changes the picture. Instead of knowing nothing about the type, you can say the type has certain capabilities.
// C#: explicitly declares the capability of being instantiable
T Make<T>() where T : new() => new T();
// Rust: Explicitly Marking `Default` Capability
fn make<T: Default>() -> T {
T::default()
}
// TypeScript: explicitly specifies the structure of having a `name` field
function readName<T extends { name: string }>(x: T): string {
return x.name;
}
"I know nothing about T" applies only to the unconstrained case. Once capabilities are specified, you can perform construction, field access, and method calls within those capabilities.
There is a point where this constraint actually helps: because the code must behave the same way regardless of the type, it becomes difficult for the code to quietly perform type-specific special behavior. The constraint may seem frustrating, but that frustration is precisely what protects the abstraction. It is like why, during an economic downturn in Korea, people prefer civil service jobs: the constraints are many, but the position is ironclad.
The Girard-Reynolds polymorphic lambda calculus, commonly known as System F, is an important theoretical foundation for treating this kind of parametric polymorphism formally.1213 System F is often described as having been discovered independently by Girard in 1972 from the logic side and by Reynolds in 1974 from the programming languages side.13
For a stronger perspective captured by the term "parametricity," Reynolds's 1983 paper Types, Abstraction and Parametric Polymorphism is worth reading alongside.14 The key intuition there is that a type parameter is not merely a syntactic device for "being usable across multiple types"; it forces the implementation to be unable to inspect the concrete structure of the type, thereby enforcing an abstraction theorem. The sense of free theorems that Wadler later popularized in "Theorems for free!" also comes from this lineage.
10. Implementing Parametric Polymorphism: Erasure, Monomorphization, and Reification
Even implementing the same List<T> varies by language. Everything may look like "generics" on the surface, but what the compiler and runtime actually do is quite different. This is where Java, Rust/C++, and .NET diverge.
10.1 Type Erasure: Java
Java generics are implemented via type erasure. According to Oracle's documentation, the compiler replaces type parameters with their bounds or Object, and inserts necessary casts and bridge methods.1516
The rough idea looks something like this.
List<String> names;After compilation, the object representation at runtime is generally treated as a List object without type arguments. Even with erasure, traces of generic information do not entirely disappear from the class file; generic signature metadata can remain, and reflection can reveal some of the declaration information. However, the JVM's object representation at runtime does not treat List<String> and List<Integer> as separate reified runtime types. Saying "it's all erased" overstates things, and so does saying "it's fully alive at runtime."
Advantages:
It maintains good compatibility with existing JVM/bytecode.
No new classes are created per type.
It avoids separate runtime representations or code generation for each type argument.
Disadvantages:
Things like
T.classfeel unnatural.Primitive specialization issues arise.
It is difficult to fully distinguish
List<String>fromList<Integer>at runtime.Complexity arises from bridge methods, wildcards, and wildcard capture.
Java generics chose to perform as much type checking as possible at compile time while keeping the runtime representation compatible with the past. This is an understandable choice given the need for compatibility with the long-established JVM ecosystem, but the trade-off is a real cost in primitive specialization and runtime type discrimination.
10.2 Monomorphization: C++ templates, Rust generics
Monomorphization, by contrast, stamps out code for each type. Instead of erasing type information, it generates code in a shape the machine prefers for each concrete type.
The Rust compiler dev guide explains that Rust monomorphizes generic types, meaning it creates a separate copy of the generic function or code for each concrete type that is needed.17
fn id<T>(x: T) -> T { x }
id::<i32>(1);
id::<String>("a".to_string());
The compiler can internally produce separate code for i32 and for String. At runtime, there is less need to query the type again, and the optimizer can work more aggressively.
Advantages:
It is well-suited for optimization because it requires no runtime type checks or indirect calls.
Inlining and specialization work well.
It can get close to zero-cost abstraction.
Disadvantages:
Compile times can increase.
Binary size can grow.
Heavy use of generic code puts the compiler under stress, and that stress is usually expressed as laptop fan noise.
This growth in binary size is called code bloat. Stamping out per-type code can speed up the runtime, but it can also produce multiple copies of machine code with the same structure. When templates or generics are deeply nested and cross multiple crate or library boundaries in particular, both compile time and binary size can increase noticeably.
Modern compilers do not take this lying down. LLVM-based toolchains can reduce duplicate instances through optimizations such as Link-Time Optimization (LTO), identical code folding, and dead code elimination. The cost of monomorphization is real, but it would be a mistake to assume the final binary always grows proportionally to the number of source-level instantiations.
C++ templates belong to the same broad family. A C++ template can accept not only type parameters but also non-type parameters and template template parameters; the key point here, though, is that it produces a concrete instantiation per argument combination at compile time.18
10.3 Reified generics: .NET CLR
.NET/C# generics differ from Java-style erasure. Microsoft's documentation states that C# generics retain runtime type information and have no type erasure.19
CLR generic types and methods carry type parameter information in their metadata, and the runtime itself is directly aware of generics.20
.NET does not unconditionally generate separate native code for every type argument combination the way C++ or Rust does. For value-type arguments, per-type native code specialization does occur, while for reference-type arguments the runtime can share a single object-reference-based implementation. For this reason, it is more accurate to think of .NET generics as "reified metadata + runtime/JIT support + partial code sharing."
Advantages:
It is easy to work with generic type information at runtime.
Value type generics like
List<int>can be handled efficiently without boxing.It integrates well with reflection, metadata, and JIT specialization.
Disadvantages:
Runtime and VM design become more complex.
The platform must natively support generics.
Because of this difference, even though both Java and C# claim to have "generics," their implementation feel diverges in meaningful ways. That's why the claim "if you know Java, you know C#" deserves a smirk. Just because the surface syntax looks similar doesn't mean the runtime choices are the same.
11. Type class implementation: dictionary passing
Haskell type classes are typically explained through dictionary passing. It sounds grandiose at first, but the intuition is relatively straightforward: think of it as passing, alongside the function, a proof that the type satisfies the required constraints.
class Eq a where
(==) :: a -> a -> Bool
The compiler can treat Eq a as something like "a dictionary containing the == implementation for type a."
member :: Eq a => a -> [a] -> Bool
We can think of this function as being translated internally into roughly the following shape.
member :: EqDictionary a -> a -> [a] -> Bool
A type class constraint becomes the evidence of constraint satisfaction, passed as a hidden argument. As Oleg Kiselyov explains, if you translate the double arrow => of a type class constraint into an ordinary function arrow ->, the evidence of constraint satisfaction can be seen as flowing through as a dictionary argument.21
Of course, this is a translation model intended to explain the semantics. Real compilers can eliminate or reduce the cost of dictionary passing through specialization and inlining. Treating dictionary passing as always incurring a runtime cost of following a dictionary pointer is therefore too narrow a reading. Conflating the explanatory model with the final machine code leads to confusion.
This approach looks similar to OOP interface dispatch, but the focus is different.
OOP:
Data and method implementations are attached inside the object.
obj.method()
Type class:
The data and the implementation dictionary can be separated.
method(dict, value)
One could say that Haskell type classes are "the same as OOP interfaces," though they are not entirely identical.
The similarities are clear: both express a contract stating that a type provides a certain set of operations.
However, while OOP interfaces are largely a receiver-centric message dispatch model, Haskell type classes are closer to a model where the compiler selects and passes instances (i.e., evidence/dictionaries) for a given type.
Common ground:
Both express a contract stating that a type supports certain operations.
The same function name can be used across multiple types.
Differences:
OOP is typically centered on the receiver object.
Type classes are centered on external evidence/dictionaries for a type.
Because the type class family allows data definitions and operation implementations to be separated, attaching an operation contract after the fact is more natural than with the OOP receiver-centric model in some cases.
Of course, this is not an unlimited open door. Haskell's orphan instances must be handled carefully due to the risk of conflicts, and Rust restricts where trait implementations can be placed via coherence/orphan rules. "Being able to attach later" is not the same as "attaching anywhere, however you like." Even a type system needs some order in the neighborhood.
12. Rust traits: between type classes and interfaces
Rust traits occupy an interesting middle ground: they can be used as constraints like type classes, and they can also be used as trait objects like interfaces,
trait Draw {
fn draw(&self);
}
and Rust can support both approaches.
Static dispatch
fn render<T: Draw>(x: T) {
x.draw();
}
This typically works through monomorphization, generating code for each concrete type. Because the compiler knows the types at compile time, inlining and optimization become straightforward.
Dynamic dispatch
fn render(x: &dyn Draw) {
x.draw();
}
This uses runtime dynamic dispatch via trait objects. The Rust Book explains that when using trait objects, the compiler cannot know all concrete types, so at runtime it uses a pointer to look up which method to call, and this lookup carries a runtime cost.22
There is one more thing to keep in mind: not every trait can become a dyn Trait. This was formerly known primarily as object safety, and current Rust documentation uses the name dyn compatibility. The name has changed, but the conditions remain the same: to be usable as a trait object, a trait must be callable through a runtime vtable.
Rust makes developers make this choice explicitly.
When performance, inlining, and static verification matter:
generic T: Trait
When you need to store heterogeneous types in a single collection or require runtime substitution:
dyn Trait
Honestly, I think this is excessive. The syntax forces you to confront the fact that polymorphism is not a single thing, and that is exactly why Rust has such a steep learning curve. If you blur the distinction between impl Trait and dyn Trait and just say "use a trait and you get polymorphism," you cannot claim to truly know Rust.
Personal note: Rust demands so much prerequisite knowledge, and because most people do not know what choice they are actually making, Rust is designed in a way that lets seven out of ten developers stumble. The reason is that a genuine understanding of polymorphism is something we rarely explore or explain unless we actively seek it out. So what ends up happening? People copy code written by a small elite, and those choices stop being options — they become fixed conventions. Most programming is like that to some degree, but Rust is especially bad. Personally, I tend to describe it as a language prone to idol worship.
On top of that, impl Trait also behaves differently depending on its position. fn f(x: impl Draw) in argument position reads like an anonymous generic bound, while fn f() -> impl Draw in return position acts as an opaque return type that hides the concrete type. Both are a different kind of choice from going through a runtime vtable like dyn Trait does.
13. Structural Typing and Row Polymorphism
Code like TypeScript's T extends { name: string } moves in a different direction from Haskell type classes or Rust trait bounds. In TypeScript, the core question is not "what is this type named" but "does it have the required structure." In practice, thinking in terms of structure rather than a single type makes program design easier, so this is a highly pragmatic approach. The official documentation also states that TypeScript's type compatibility is based on structural subtyping.23
function readName<T extends { name: string }>(x: T): string {
return x.name;
}
This code does not ask whether Nameable as a nominal type has been implemented; if a value has the shape name: string, it is accepted. In that sense, it looks somewhat like a static version of duck typing. That said, TypeScript has to work hand-in-hand with the JavaScript ecosystem, so it opts heavily for practical compatibility over complete soundness. Treating it as a contract with the same strength as Haskell-style parametricity or Rust-style trait bounds is another easy mistake to make.
Row polymorphism takes this one step further toward theory. It is a way of treating, at the type level, what fields a record or variant has and what the remaining open row looks like. Wand's 1987 line of work on record/object type inference is frequently cited, leading into later work on extensible records and variants.24
It is a stretch to call TypeScript's structural typing row polymorphism outright, but placing them side by side is genuinely helpful when building the intuition that structure matters more than names.
14. Multiple Dispatch and Multimethods
The polymorphism that OOP beginners learn is usually single dispatch.
Method selection based on a single runtime type of the receiver
However, some languages select an implementation by inspecting the runtime types of multiple arguments.
collide(Ship, Asteroid)
collide(Laser, Shield)
collide(Player, Item)
This falls under the multiple dispatch or multimethod family. Common Lisp Object System (CLOS), Dylan, and Julia are the most frequently cited examples.
Multiple dispatch breaks the OOP-style question of "does an operation belong to one specific object?" In particular, when the combination of two values is itself what creates meaning, picking a single receiver often feels awkward.
Writing a.collide(b) attaches the implementation to the a side, yet a collision is fundamentally about the combination of a and b together. In such cases, multiple dispatch is arguably more natural.
The tradeoff is that method resolution becomes more complex, introducing issues around priority, ambiguity, performance, and caching.
Multiple dispatch selects a method based on the runtime types of multiple arguments. Implementations like Julia lean heavily on JIT specialization and method caches. So while describing it as "a slow runtime lookup on every call" is technically true in spirit, it misses an important nuance.
Personal note: there is no free lunch in programming languages, and when something looks like one, the compiler is usually packing the lunchbox behind the scenes.
15. Summary by Implementation Style
Category | Representative Syntax | Resolution Point | Implementation Mechanism | Primary Cost |
|---|---|---|---|---|
Overloading |
| Compile time | Overload resolution | Ambiguity, rule complexity |
Coercion |
| Compile time / Runtime | Implicit conversion insertion | Unpredictability, precision loss |
Subtype polymorphism |
| Runtime | vtable, method table, interface dispatch | Indirect calls, difficulty inlining |
Parametric polymorphism - erasure | Java | Compile-time check, runtime type erasure | erased representation, cast, bridge method | Lack of runtime type information |
Parametric polymorphism - monomorphization | C++ template, Rust generics | Compile time | Code generation per type | Compile time, binary size |
Parametric polymorphism - reified | C#/.NET generics | Compile-time + runtime metadata | CLR metadata/JIT support, some code sharing/specialization | Runtime/VM complexity |
Type class | Haskell | Compile-time resolution, runtime dictionary possible | Dictionary passing | Constraint inference complexity |
Trait object | Rust | Runtime | Fat pointer + vtable | Indirect call, |
Structural constraint | TypeScript | Compile-time | Structural compatibility check | Soundness relaxation, risk of over-permissiveness |
Multiple dispatch | Julia/CLOS multimethod | Semantically at runtime | Method table, dispatch cache, JIT specialization possible | Ambiguity, dispatch cost |
DOP-style handling is less a classic polymorphism category and more an execution strategy that avoids or relocates subtype polymorphism from hot paths. It expresses variation through type ids, tagged data, dense arrays, ECS queries, command buffers, and similar techniques. You could force it into the table, but doing so mixes theoretical classification with execution strategy.
Keeping them separate causes less confusion.
16. What to watch out for when explaining polymorphism
When talking to beginners, I usually explain it like this.
Polymorphism is the ability where, given the same request,
different behavior is executed depending on the actual target.
This explanation is useful as an introduction, but it tilts toward OOP-style subtype polymorphism. Then again, most conversations end there anyway.
That said, if you push the concept a bit further, it goes like this (and most discussions settle at this level).
Polymorphism is a language mechanism by which a single piece of code, name, interface, or operation
can be applied meaningfully across multiple types.
And whenever a debate breaks out in a programming community, there is one question you absolutely must raise to show that you actually know what you're talking about.
This polymorphism:
Is it resolved at compile time?
Is it resolved at runtime?
Is separate code generated per type?
Is a single code path shared?
Is it interface dispatch?
Is it a type class/dictionary?
Is it simple overloading?
Without this question, the polymorphism discussion quickly becomes a matter of taste: one person talks about interfaces, another talks about generics, another brings up Rust traits, another mentions Haskell type classes, and most conversations end with everyone deciding they were all right. They are all talking about something near polymorphism, differing only in cost structure and where the contract lives, so if you actually want to win the argument, you need to be precise.
Conclusion
Strachey categorized polymorphism into ad hoc and parametric; Cardelli and Wegner arranged parametric, inclusion, overloading, and coercion into a unified taxonomy; and Wadler and Blott then made ad hoc polymorphism less arbitrary through the mechanism of type classes.
If this much constitutes the conceptual map, then the subsequent history of language implementations is largely a repetition of the same questions answered in different ways.
How much of polymorphism should the type system be responsible for?
At what point should it be handed off to runtime dispatch?
Should code be duplicated, types erased, or metadata retained?
Should the cost of abstraction be paid at runtime or at compile time?
Following this thread makes it hard to think of polymorphism as a single feature. It is better understood as a collection of different approaches a language takes to handle the desire to "work with multiple types using the same code."
Inheritance and override are one piece of that. They form an important axis, but they cannot stand in for the whole. In FP, type parameters and type classes come into sharper focus, and in DOP, the cost of polymorphism resurfaces as a matter of memory layout and processing passes.
When explaining polymorphism, there are questions that must be asked first: When is the decision made? What contract exists? Does type information survive? Where does the cost go? Strip away those four questions and what remains is usually a half-true maxim like "interfaces are slow." Maxims are easy to memorize, which makes them easy to turn into dogma.
See also:
Functional Programming
Composition
Programming Idioms
Algebraic Data Type
Data-Oriented Programming
Footnotes
- Christopher Strachey. "Fundamental Concepts in Programming Languages". Higher-Order and Symbolic Computation, 2000 reprint. Original lecture delivered at the 1967 Copenhagen International Summer School. ↩
- PLS Lab. "Fundamental Concepts in Programming Languages". Explanation of Strachey's lecture notes and key concepts. ↩
- Luca Cardelli; Peter Wegner. "On Understanding Types, Data Abstraction, and Polymorphism". ACM Computing Surveys, 17(4), 1985. ↩
- Philip Wadler; Stephen Blott. "How to Make Ad-Hoc Polymorphism Less Ad Hoc". POPL '89, pp. 60–76. ↩
- University of Edinburgh Research Explorer. "How to make ad-hoc polymorphism less ad hoc". Abstract and publication information for the paper. ↩
- Philip Wadler. "The Expression Problem". 1998. ↩
- Ole-Johan Dahl; Bjørn Myhrhaug; Kristen Nygaard. "SIMULA 67 Common Base Language". Norwegian Computing Center, 1968/1970. ↩
- Kristen Nygaard; Ole-Johan Dahl. "The development of the SIMULA languages". History of Programming Languages, 1978. ↩
- Ole Lehrmann Madsen; Birger Møller-Pedersen. "Virtual classes: a powerful mechanism in object-oriented programming". Focuses on virtual classes in BETA, with Simula's virtual procedure lineage as background. ↩
- Two-Bit History. "OOP Before OOP with Simula". An overview of Simula 67's classes, hierarchy, and virtual methods. ↩
- Adele Goldberg; David Robson. Smalltalk-80: The Language and its Implementation. Addison-Wesley, 1983. The definitive reference for Smalltalk message sending and method lookup. ↩
- John C. Reynolds. "Towards a Theory of Type Structure". 1974. ↩
- Jean-Yves Girard. "Interprétation fonctionnelle et élimination des coupures de l'arithmétique d'ordre supérieur". Université Paris 7, 1972. ↩
- John C. Reynolds. "Types, Abstraction and Parametric Polymorphism". Information Processing 83, pp. 513–523, 1983. ↩
- Oracle Java Tutorials. "Type Erasure". ↩
- Oracle Java Tutorials. "Effects of Type Erasure and Bridge Methods". ↩
- Rust Compiler Development Guide. "Monomorphization". ↩
- cppreference. "Templates". ↩
- Microsoft Learn. "Generic types and methods". ↩
- Microsoft Learn. "Generics in the runtime". ↩
- Oleg Kiselyov. "Implementing, and Understanding Type Classes". ↩
- The Rust Programming Language. "Using Trait Objects to Abstract over Shared Behavior". ↩
- TypeScript Handbook. "Type Compatibility". TypeScript type compatibility and structural subtyping. ↩
- Mitchell Wand. "Complete Type Inference for Simple Objects". LICS 1987. ↩