Polymorphism — Writing Code That Works with Many Types

Polymorphism — “many forms” — means that a variable of a base type can hold objects of any derived type, and calling a virtual method through that variable invokes the derived type’s actual implementation. It is what allows you to write a loop over a List<Notification> and call SendAsync() on each element, even though some are EmailNotification and some are SmsNotification. The runtime dispatches to the correct implementation at runtime based on the object’s actual type, not the declared variable type.

Polymorphic Dispatch

// A single loop handles all notification types polymorphically
public class NotificationDispatcher
{
    private readonly List<NotificationSender> _senders;

    public NotificationDispatcher(IEnumerable<NotificationSender> senders)
        => _senders = senders.ToList();

    // Works with ANY NotificationSender — present or future
    public async Task DispatchAsync(string recipient, string subject, string body)
    {
        foreach (NotificationSender sender in _senders)
        {
            // Virtual dispatch — runtime decides which SendAsync to call
            var result = await sender.SendAsync(recipient, subject, body);
            if (!result.IsSuccess)
                Console.WriteLine($"Warning: {result.Message}");
        }
    }
}

// Building a heterogeneous list of derived objects stored as base type
var senders = new List<NotificationSender>
{
    new EmailSender(smtpClient),
    new SmsSender(smsGateway),
    new PushSender(pushService),
};

var dispatcher = new NotificationDispatcher(senders);
await dispatcher.DispatchAsync("user123", "Order Shipped", "Your order #42 is on the way!");

// Three different SendAsync implementations run — caller never changed
Note: Polymorphism operates through the virtual dispatch table (vtable). When you declare a method as virtual, the compiler adds an entry in the class’s vtable for that method. Each override in a derived class updates the vtable entry for that type. When you call a virtual method through a base-type reference, the runtime looks up the actual object’s vtable and calls the correct implementation. This is slightly slower than a direct method call (one extra indirection) but the performance cost is negligible for application code.
Tip: Design for polymorphism from the start by coding to base types and interfaces, not concrete types. In ASP.NET Core, service registrations inject interfaces or abstract base types, never concrete implementations directly. This is the Dependency Inversion Principle — high-level modules should not depend on low-level modules; both should depend on abstractions. When you inject INotificationService instead of EmailNotificationService, you can swap implementations without touching the calling code.
Warning: Polymorphism does not work across value types. struct types cannot be inherited and cannot participate in virtual dispatch. If you store a struct in a base-type variable, it is boxed (wrapped in a heap object) and the struct’s copy semantics no longer apply as expected. Use classes for types that participate in inheritance hierarchies. Structs are for small, standalone value types like Point, Color, and DateTime that do not need polymorphic behaviour.

Polymorphism with Collections

// Storing many types in a list through a common base type
public abstract class Shape
{
    public abstract double Area();
    public abstract double Perimeter();
    public override string ToString() => $"{GetType().Name}: Area={Area():F2}";
}

public class Circle : Shape
{
    public double Radius { get; }
    public Circle(double radius) => Radius = radius;
    public override double Area()      => Math.PI * Radius * Radius;
    public override double Perimeter() => 2 * Math.PI * Radius;
}

public class Rectangle : Shape
{
    public double Width { get; }
    public double Height { get; }
    public Rectangle(double w, double h) { Width = w; Height = h; }
    public override double Area()      => Width * Height;
    public override double Perimeter() => 2 * (Width + Height);
}

// All shapes in one list — polymorphism in action
var shapes = new List<Shape>
{
    new Circle(5),
    new Rectangle(4, 6),
    new Circle(3),
    new Rectangle(10, 2),
};

// Each call to Area() dispatches to the correct override
double totalArea = shapes.Sum(s => s.Area());
Console.WriteLine($"Total area: {totalArea:F2}");  // correct for each shape type

foreach (var shape in shapes)
    Console.WriteLine(shape);   // uses Shape's ToString which calls virtual Area()

Common Mistakes

Mistake 1 — Checking the type and casting instead of using polymorphism

❌ Wrong — type-switching breaks polymorphism and requires modification for new types:

foreach (var s in shapes)
{
    if (s is Circle c) Console.WriteLine($"Circle area: {Math.PI * c.Radius * c.Radius}");
    else if (s is Rectangle r) Console.WriteLine($"Rect area: {r.Width * r.Height}");
    // Must add else if for EVERY new shape — open/closed principle violation
}

✅ Correct — let polymorphism do the dispatch:

foreach (var s in shapes) Console.WriteLine($"Area: {s.Area():F2}");  // ✓ extensible

Mistake 2 — Storing derived types in base variables when you need derived-specific members

If you need to call Circle.Radius through a Shape variable, you must cast first. Design to avoid this by putting what you need in the base type, or use pattern matching (Lesson 5).

🧠 Test Yourself

A new Triangle class is added that extends Shape. How much code in NotificationDispatcher or the shapes.Sum(s => s.Area()) loop needs to change?