Polymorphism
Summary
Polymorphism is the ability to treat objects of different concrete types
uniformly through a shared base type or interface, while each object still
behaves according to its own specific type when called. A single line of
code — foreach (var enemy in enemies) enemy.Attack(); — runs correctly no
matter what mix of enemy subtypes the list contains, because each object
knows how to respond to Attack() in its own way. "Poly" (many) + "morph"
(form) — one call, many shapes of behavior.
Why This Matters
Code that has to distinguish between concrete types before it can act on
them grows a branch for every new type: if (enemy is Goblin) ... else if (enemy is Skeleton) ... else if (enemy is Dragon) .... Every time a new
enemy type is added, this same branch has to be found and updated
everywhere it appears — miss one, and the new enemy type silently falls
through to default (or broken) behavior. Polymorphism removes the branch
entirely: calling code depends only on the shared base type's contract, and
adding a new subtype requires touching nothing except the new subtype
itself.
Mental Model
Polymorphism is a universal remote's "power" button, not a different remote per device. Pressing "power" on a universal remote works whether it's pointed at a TV, a sound bar, or a game console — the button is the same, but each device internally does something different in response to receiving that signal. The remote (calling code) never needs a separate button, or to know which device it's facing, for the same conceptual action to work correctly everywhere.
Core Explanation
- A shared type (base class or interface) is the "shape" many concrete
types fit.
Enemy,IDamageable, or any common ancestor lets a collection or variable hold many different concrete types uniformly, as long as they all satisfy that shared shape. - The concrete object's actual type determines the behavior at
runtime.
Enemy current = enemies[i]; current.Attack();runs whichever override the actual object provides — this is the runtime dispatch mechanism introduced in [[CON-0028]], applied at scale across a heterogeneous collection. - Calling code is written once, against the shared type, and never
changes as new subtypes are added. A
foreachoverList<Enemy>callingAttack()doesn't need to change when a newEnemysubclass is introduced — this is the concrete payoff polymorphism provides over type-checking branches. - Polymorphism can be achieved through inheritance or through
interfaces. A base class with
virtual/overridemembers, or an interface implemented by otherwise-unrelated classes, both produce the same "one call site, many behaviors" result — interfaces additionally allow types with no inheritance relationship at all to be treated uniformly.
Syntax / Theory
C# (Unity — via inheritance)
public abstract class Enemy : MonoBehaviour
{
public abstract void Attack();
}
public class Goblin : Enemy { public override void Attack() => Debug.Log("Goblin stabs."); }
public class Skeleton : Enemy { public override void Attack() => Debug.Log("Skeleton shoots an arrow."); }
List<Enemy> enemies = GetAllEnemies(); // Mixed concrete types.
foreach (var enemy in enemies)
enemy.Attack(); // Correct behavior per type — no branching here at all.
C# (via interfaces — unrelated types, uniform treatment)
public interface IDamageable { void TakeDamage(int amount); }
public class Enemy : MonoBehaviour, IDamageable { public void TakeDamage(int amount) { /* ... */ } }
public class DestructibleCrate : MonoBehaviour, IDamageable { public void TakeDamage(int amount) { /* ... */ } }
void OnExplosion(IDamageable[] hitTargets, int damage)
{
foreach (var target in hitTargets)
target.TakeDamage(damage); // Works for enemies and crates identically.
}
C++ (Unreal)
TArray<AEnemy*> Enemies; // May contain AGoblin*, ASkeleton*, etc.
for (AEnemy* Enemy : Enemies)
{
Enemy->Attack(); // Virtual dispatch — same mechanism as C#.
}
Unreal Blueprint
A Blueprint array typed as the base Enemy class (or an implemented
Interface) can hold a mix of Blueprint subclasses, and calling the base
event on each element dispatches to whichever subclass override exists —
identical behavior to C#'s runtime dispatch, expressed visually.
Practical Example
A UI system renders a list of unrelated notification types uniformly.
public interface IRenderable { void Render(Canvas canvas); }
public class TextNotification : IRenderable
{
public void Render(Canvas canvas) => DrawText(canvas, Message);
}
public class IconNotification : IRenderable
{
public void Render(Canvas canvas) => DrawIcon(canvas, IconSprite);
}
List<IRenderable> queue = GetActiveNotifications();
foreach (var item in queue)
item.Render(mainCanvas); // No type-checking — every kind renders itself correctly.
Adding a ProgressBarNotification later requires only implementing
IRenderable on the new class — the rendering loop is untouched.
Common Mistakes
- Writing type-checking chains (
is/as/switchon type) where polymorphism should be used instead. A longif (obj is TypeA) ... else if (obj is TypeB) ...chain acting on a shared base type is almost always a sign avirtual/overridemethod or an interface method should replace the branching entirely. - Forcing unrelated types into a common base class just to enable polymorphism, when an interface (which doesn't require a shared ancestor or shared implementation) would fit more naturally — see [[CON-0031]] for this specific trade-off.
- Assuming polymorphism requires inheritance. Interfaces achieve the same "uniform call site, per-type behavior" result without any inheritance relationship at all — reaching for inheritance by default when only a shared method contract is actually needed adds unnecessary coupling.
- Breaking polymorphism with a downcast immediately after an upcast
call.
enemy.Attack(); if (enemy is Skeleton s) s.SpecialArrow();reintroduces exactly the type-specific branching polymorphism was supposed to eliminate — ifSpecialArrowneeds to run as part of attacking, it belongs insideSkeleton.Attack()'s override, not bolted on outside it.
Game Development Example
An inventory system processes a mixed list of item types uniformly when the player uses them.
public abstract class Item
{
public abstract void Use(Player player);
}
public class HealthPotion : Item
{
public override void Use(Player player) => player.Heal(20);
}
public class Scroll : Item
{
public override void Use(Player player) => player.CastSpell(spellId);
}
void UseItem(Item item, Player player)
{
item.Use(player); // Works identically for potions, scrolls, and every future item type.
}
The inventory UI's "use item" button calls the same one line of code regardless of which of dozens of item types the player selected.
Best Practices
- Reach for polymorphism whenever calling code finds itself branching on concrete type to decide what to do — that branch is almost always better expressed as a virtual/override method or interface implementation.
- Choose an interface over inheritance when the only thing shared across types is a method contract, not implementation or state.
- Keep the shared base type's contract focused on what calling code actually needs to call uniformly — don't grow it just because one subtype happens to need an extra method.
- Verify a new subtype needs no changes anywhere else in the codebase before considering a polymorphic design complete — that's the actual test of whether the design achieved its goal.
Exercises
- Take three unrelated classes (
Torch,Landmine,HealingAura) and give them all anIInteractableinterface with a singleInteract()method, then write one loop that callsInteract()on a mixed list of all three. - Find (or write) a piece of code with an
if (obj is TypeA) ... else if (obj is TypeB) ...chain, and refactor it into a polymorphicvirtual/overridedesign — describe what changed at the call site. - You're deciding whether
Item.Use()should be based on an abstract base class (shared inventory-slot logic) or a pureIUsableinterface (items already inherit from unrelated base types for their own category). Argue for one approach and justify the trade-off. - Research "duck typing" in dynamically-typed languages and explain how it achieves a similar practical result to interface-based polymorphism in C#, without requiring an explicit interface declaration at all.
Related Concepts
- Method Overriding — the runtime dispatch mechanism (calling the correct subclass's version through a base-typed reference) that makes inheritance-based polymorphism work at all.
- Interfaces — the primary alternative path to polymorphism when types share only a contract, not an inheritance relationship or implementation.
- Composition vs. Inheritance — choosing how to structure the types that will be treated polymorphically is a direct extension of this broader design trade-off.
Further Reading
KNOWLEDGE_SCHEMA.md— GDKB's concept maturity ladder and cross-linking rules, which this document follows.- Microsoft C# documentation — Polymorphism: https://learn.microsoft.com/dotnet/csharp/fundamentals/object-oriented/polymorphism
- Unreal Engine C++ documentation — Interfaces in Unreal Engine: https://dev.epicgames.com/documentation/en-us/unreal-engine/interfaces-in-unreal-engine