Polymorphism is the ability of different types to be treated uniformly through a common interface. It allows objects of different classes to be used interchangeably when they share a common contract.

Core Concept

Polymorphism means “many forms” - one interface, multiple implementations.

Key Insight: Code can work with abstractions without knowing the concrete types at compile time.

// Polymorphic code - works with any IShape
public decimal CalculateTotalArea(IEnumerable<IShape> shapes)
{
    return shapes.Sum(shape => shape.Area()); // Don't need to know concrete type
}
 
// Works with circles, rectangles, triangles, etc.
var shapes = new List<IShape>
{
    new Circle(5),
    new Rectangle(4, 6),
    new Triangle(3, 4, 5)
};
var total = CalculateTotalArea(shapes);

Types of Polymorphism

1. Subtype Polymorphism (Runtime Polymorphism)

Objects of derived classes can be used where base type is expected:

public abstract class Animal
{
    public abstract string MakeSound();
}
 
public class Dog : Animal
{
    public override string MakeSound() => "Woof!";
}
 
public class Cat : Animal
{
    public override string MakeSound() => "Meow!";
}
 
// Polymorphic usage
Animal animal1 = new Dog();
Animal animal2 = new Cat();
 
Console.WriteLine(animal1.MakeSound()); // "Woof!"
Console.WriteLine(animal2.MakeSound()); // "Meow!"

2. Interface Polymorphism

Different classes implement the same interface:

public interface IPaymentMethod
{
    void ProcessPayment(decimal amount);
}
 
public class CreditCardPayment : IPaymentMethod
{
    public void ProcessPayment(decimal amount)
    {
        // Credit card processing logic
    }
}
 
public class PayPalPayment : IPaymentMethod
{
    public void ProcessPayment(decimal amount)
    {
        // PayPal processing logic
    }
}
 
// Polymorphic usage
public class PaymentProcessor
{
    public void Process(IPaymentMethod method, decimal amount)
    {
        method.ProcessPayment(amount); // Works with any payment method
    }
}

3. Parametric Polymorphism (Generics)

Code works with type parameters:

// Generic class works with any type
public class Repository<T>
{
    private List<T> _items = new();
 
    public void Add(T item) => _items.Add(item);
    public T Get(int id) => _items[id];
}
 
// Can be used with any type
var userRepo = new Repository<User>();
var orderRepo = new Repository<Order>();

4. Ad-hoc Polymorphism (Method Overloading)

Same method name, different parameter types:

public class Printer
{
    public void Print(int value) => Console.WriteLine($"Integer: {value}");
    public void Print(string value) => Console.WriteLine($"String: {value}");
    public void Print(DateTime value) => Console.WriteLine($"Date: {value:yyyy-MM-dd}");
}

Benefits of Polymorphism

Open/Closed Principle

Closed Principle - extend behavior without modification:

// BAD: Not polymorphic - must modify to add shapes
public class AreaCalculator
{
    public decimal Calculate(object shape)
    {
        if (shape is Circle circle)
            return Math.PI * circle.Radius * circle.Radius;
        else if (shape is Rectangle rect)
            return rect.Width * rect.Height;
        // Must add new else-if for each shape!
    }
}
 
// GOOD: Polymorphic - extend by adding new types
public interface IShape
{
    decimal Area();
}
 
public class Circle : IShape
{
    public decimal Radius { get; }
    public decimal Area() => (decimal)Math.PI * Radius * Radius;
}
 
public class Rectangle : IShape
{
    public decimal Width { get; }
    public decimal Height { get; }
    public decimal Area() => Width * Height;
}
 
// No modification needed to add new shapes
public class AreaCalculator
{
    public decimal Calculate(IShape shape) => shape.Area();
}

Dependency Inversion

Dependency Inversion - depend on abstractions:

// BAD: Depends on concrete class
public class OrderService
{
    private readonly SqlDatabase _database = new SqlDatabase();
 
    public void Save(Order order)
    {
        _database.Save(order);
    }
}
 
// GOOD: Depends on abstraction (polymorphic)
public class OrderService
{
    private readonly IDatabase _database;
 
    public OrderService(IDatabase database)
    {
        _database = database;
    }
 
    public void Save(Order order)
    {
        _database.Save(order); // Works with any IDatabase implementation
    }
}

Testability

Polymorphism enables testing through test doubles:

// Production interface
public interface IEmailService
{
    void SendEmail(string to, string subject, string body);
}
 
// Production implementation
public class SmtpEmailService : IEmailService
{
    public void SendEmail(string to, string subject, string body)
    {
        // Real SMTP logic
    }
}
 
// Test double
public class FakeEmailService : IEmailService
{
    public List<string> SentEmails { get; } = new();
 
    public void SendEmail(string to, string subject, string body)
    {
        SentEmails.Add($"{to}: {subject}");
    }
}
 
// Test
[Test]
public void OrderCompletion_SendsConfirmationEmail()
{
    var fakeEmail = new FakeEmailService();
    var orderService = new OrderService(fakeEmail);
 
    orderService.CompleteOrder(order);
 
    Assert.That(fakeEmail.SentEmails, Has.Count.EqualTo(1));
}

Liskov Substitution Principle

Liskov Substitution Principle - subtypes must be substitutable:

// BAD: Violates LSP - Square can't substitute Rectangle
public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int Area() => Width * Height;
}
 
public class Square : Rectangle
{
    public override int Width
    {
        get => base.Width;
        set { base.Width = value; base.Height = value; }
    }
    public override int Height
    {
        get => base.Height;
        set { base.Width = value; base.Height = value; }
    }
}
 
// This breaks!
void ResizeRectangle(Rectangle rect)
{
    rect.Width = 5;
    rect.Height = 4;
    Assert.That(rect.Area(), Is.EqualTo(20)); // Fails for Square!
}
 
// GOOD: Proper abstraction
public interface IShape
{
    int Area();
}
 
public class Rectangle : IShape
{
    public int Width { get; set; }
    public int Height { get; set; }
    public int Area() => Width * Height;
}
 
public class Square : IShape
{
    public int SideLength { get; set; }
    public int Area() => SideLength * SideLength;
}

Design Patterns Using Polymorphism

Strategy Pattern

Strategy Pattern - encapsulate algorithms:

public interface ISortStrategy
{
    void Sort(int[] array);
}
 
public class QuickSort : ISortStrategy
{
    public void Sort(int[] array) { /* QuickSort logic */ }
}
 
public class MergeSort : ISortStrategy
{
    public void Sort(int[] array) { /* MergeSort logic */ }
}
 
public class Sorter
{
    public void Sort(int[] array, ISortStrategy strategy)
    {
        strategy.Sort(array); // Polymorphic call
    }
}

Template Method Pattern

Define algorithm structure, let subclasses override steps:

public abstract class DataProcessor
{
    public void Process()
    {
        LoadData();
        ProcessData();
        SaveData();
    }
 
    protected abstract void LoadData();
    protected abstract void ProcessData();
    protected abstract void SaveData();
}
 
public class CsvDataProcessor : DataProcessor
{
    protected override void LoadData() { /* Load CSV */ }
    protected override void ProcessData() { /* Process */ }
    protected override void SaveData() { /* Save CSV */ }
}

Factory Pattern

Create objects polymorphically:

public interface INotification
{
    void Send(string message);
}
 
public class NotificationFactory
{
    public INotification Create(string type)
    {
        return type switch
        {
            "email" => new EmailNotification(),
            "sms" => new SmsNotification(),
            "push" => new PushNotification(),
            _ => throw new ArgumentException("Unknown type")
        };
    }
}

Common Pitfalls

Inappropriate Inheritance

Using inheritance when composition is better:

// BAD: Inheritance for code reuse
public class Stack : List<int>
{
    public void Push(int value) => Add(value);
    public int Pop()
    {
        int value = this[Count - 1];
        RemoveAt(Count - 1);
        return value;
    }
}
// Problem: Exposes all List methods (Clear, Sort, etc.)
 
// GOOD: Composition
public class Stack
{
    private List<int> _items = new();
 
    public void Push(int value) => _items.Add(value);
    public int Pop()
    {
        int value = _items[Count - 1];
        _items.RemoveAt(Count - 1);
        return value;
    }
}

Violating LSP

Creating subtypes that break contracts:

// BAD: Derived class violates expectations
public class Bird
{
    public virtual void Fly() { /* Flying logic */ }
}
 
public class Penguin : Bird
{
    public override void Fly()
    {
        throw new NotSupportedException("Penguins can't fly");
    }
}
 
// GOOD: Better abstraction
public interface IBird { }
public interface IFlyingBird : IBird
{
    void Fly();
}
 
public class Eagle : IFlyingBird
{
    public void Fly() { /* Flying logic */ }
}
 
public class Penguin : IBird
{
    public void Swim() { /* Swimming logic */ }
}

Over-Abstraction

Creating unnecessary abstractions:

// TOO MUCH: Over-engineered
public interface IInteger
{
    int GetValue();
}
 
public class Integer : IInteger
{
    private readonly int _value;
    public int GetValue() => _value;
}
 
// JUST RIGHT: Use primitives when appropriate
int value = 42;

Duck Typing (Dynamic Languages)

In dynamic languages, polymorphism through duck typing:

# Python example - no explicit interface needed
class Dog:
    def speak(self):
        return "Woof!"
 
class Cat:
    def speak(self):
        return "Meow!"
 
def make_animal_speak(animal):
    print(animal.speak())  # Works if animal has speak()
 
make_animal_speak(Dog())  # "Woof!"
make_animal_speak(Cat())  # "Meow!"

When to Use Polymorphism

Use polymorphism when:

  • Multiple implementations of the same behavior exist
  • Behavior needs to be selected at runtime
  • You want to extend behavior without modifying existing code
  • Testing requires substituting dependencies

Don’t use polymorphism when:

  • There’s only one implementation and none expected
  • Behavior is static and known at compile time
  • The abstraction doesn’t have a clear, stable contract
  • Simplicity is more important than flexibility

Resources

See Also