GDKB

Polymorphism

Intermediate25 Minutes readUpdated July 21, 20263 related concepts

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.

On this page

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 foreach over List<Enemy> calling Attack() doesn't need to change when a new Enemy subclass 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/override members, 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)

text
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."); }
csharp
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)

text
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) { /* ... */ } }
csharp
void OnExplosion(IDamageable[] hitTargets, int damage)
{
    foreach (var target in hitTargets)
        target.TakeDamage(damage); // Works for enemies and crates identically.
}

C++ (Unreal)

cpp
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.

csharp
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);
}
csharp
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/switch on type) where polymorphism should be used instead. A long if (obj is TypeA) ... else if (obj is TypeB) ... chain acting on a shared base type is almost always a sign a virtual/override method 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 — if SpecialArrow needs to run as part of attacking, it belongs inside Skeleton.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.

text
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);
}
csharp
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

  1. Take three unrelated classes (Torch, Landmine, HealingAura) and give them all an IInteractable interface with a single Interact() method, then write one loop that calls Interact() on a mixed list of all three.
  2. Find (or write) a piece of code with an if (obj is TypeA) ... else if (obj is TypeB) ... chain, and refactor it into a polymorphic virtual/override design — describe what changed at the call site.
  3. You're deciding whether Item.Use() should be based on an abstract base class (shared inventory-slot logic) or a pure IUsable interface (items already inherit from unrelated base types for their own category). Argue for one approach and justify the trade-off.
  4. 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.
  • 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

Related Concepts