C# Polymorphism

Polymorphism means “many forms”: the same C# operation can work with objects of different concrete types. It matters because you can write code against a base class or interface, then let each object provide its own behavior. Good polymorphism reduces duplicated conditionals and makes programs easier to extend.

Overview: How Polymorphism Works

In C#, the most common kind of polymorphism is subtype polymorphism. A variable declared as a base type can hold an object of a derived type. For example, an Animal variable can refer to a Dog object, and an IPayable variable can refer to an Invoice or an Employee. Code using the base type does not need to know every concrete class.

Polymorphism is closely related to inheritance, but they are not the same thing. Inheritance creates the relationship between types. Polymorphism is the ability to use an object through a more general type and still get behavior appropriate to the actual runtime object. In C#, this usually happens through virtual, abstract, and override members, or through interface implementations.

When you call a non-virtual instance method, the compiler can bind the call to the method known from the variable’s compile-time type. When you call a virtual or abstract method, the CLR uses the object’s runtime type to choose the most specific override. This is called dynamic dispatch or late binding. The object still sits in memory as one concrete object; only the reference type used to access it changes what members the compiler lets you call.

Interfaces provide another major form of polymorphism. An interface says what operations a type supports, without requiring a shared base class. This is useful when classes are not naturally in the same inheritance family but share a capability, such as IDisposable, IComparable<T>, or an application-specific INotificationSender.

C# also has compile-time polymorphism through method overloading and generics. Overloading chooses among methods with the same name at compile time based on parameter lists. Generics let one type or method work with many type arguments. This lesson focuses on runtime object polymorphism, because it is central to object-oriented design.

Syntax

abstract class BaseType
{
    public abstract string RequiredBehavior();

    public virtual string OptionalBehavior()
    {
        return "base behavior";
    }
}

class DerivedType : BaseType
{
    public override string RequiredBehavior()
    {
        return "derived required behavior";
    }

    public override string OptionalBehavior()
    {
        return "derived optional behavior";
    }
}
Part Meaning
abstract class A base class that may contain incomplete members and cannot be instantiated directly.
abstract member A required member with no body in the base class; derived concrete classes must override it.
virtual member A member with a base implementation that derived classes may replace.
override Supplies the derived implementation used by runtime dispatch.
Base-type variable A variable such as BaseType item can hold any object derived from BaseType.
Interface variable A variable such as IRenderable item can hold any object that implements that interface.

Examples

Example 1: Virtual Methods Through a Base Class

using System;

class Animal
{
    public Animal(string name)
    {
        Name = name;
    }

    public string Name { get; }

    public virtual string Speak()
    {
        return "unknown sound";
    }
}

class Dog : Animal
{
    public Dog(string name) : base(name)
    {
    }

    public override string Speak()
    {
        return "woof";
    }
}

class Cat : Animal
{
    public Cat(string name) : base(name)
    {
    }

    public override string Speak()
    {
        return "meow";
    }
}

class Program
{
    static void Main()
    {
        Animal[] animals = { new Dog("Rex"), new Cat("Milo"), new Animal("Mystery") };

        foreach (Animal animal in animals)
        {
            Console.WriteLine($"{animal.Name}: {animal.Speak()}");
        }
    }
}

Output:

Rex: woof
Milo: meow
Mystery: unknown sound

The array is declared as Animal[], but it stores different runtime types. The call animal.Speak() is virtual, so the CLR checks whether the actual object is a Dog, Cat, or plain Animal. The loop does not need an if statement for each animal type.

Example 2: Abstract Classes Force Derived Behavior

using System;
using System.Globalization;

abstract class ShippingMethod
{
    public ShippingMethod(string name)
    {
        Name = name;
    }

    public string Name { get; }

    public abstract decimal CalculateCost(decimal orderTotal);
}

class StandardShipping : ShippingMethod
{
    public StandardShipping() : base("Standard")
    {
    }

    public override decimal CalculateCost(decimal orderTotal)
    {
        return orderTotal >= 50m ? 0m : 5.99m;
    }
}

class ExpressShipping : ShippingMethod
{
    public ExpressShipping() : base("Express")
    {
    }

    public override decimal CalculateCost(decimal orderTotal)
    {
        return 14.99m;
    }
}

class Program
{
    static void Main()
    {
        ShippingMethod[] methods = { new StandardShipping(), new ExpressShipping() };
        decimal orderTotal = 42.50m;

        foreach (ShippingMethod method in methods)
        {
            string cost = method.CalculateCost(orderTotal).ToString("0.00", CultureInfo.InvariantCulture);
            Console.WriteLine($"{method.Name}: ${cost}");
        }
    }
}

Output:

Standard: $5.99
Express: $14.99

ShippingMethod is not useful by itself, so it is abstract. It defines a common Name property and requires every concrete shipping method to implement CalculateCost. This is a good fit when derived classes share identity or state but must provide different behavior.

Example 3: Interface Polymorphism Without Shared Inheritance

using System;
using System.Collections.Generic;

interface IReportItem
{
    string RenderLine();
}

class SalesTotal : IReportItem
{
    public SalesTotal(decimal amount)
    {
        Amount = amount;
    }

    public decimal Amount { get; }

    public string RenderLine()
    {
        return $"Sales: ${Amount:0.00}";
    }
}

class WarningMessage : IReportItem
{
    public WarningMessage(string message)
    {
        Message = message;
    }

    public string Message { get; }

    public string RenderLine()
    {
        return "Warning: " + Message;
    }
}

class Program
{
    static void Main()
    {
        List<IReportItem> items = new List<IReportItem>
        {
            new SalesTotal(1250m),
            new WarningMessage("Inventory is low")
        };

        foreach (IReportItem item in items)
        {
            Console.WriteLine(item.RenderLine());
        }
    }
}

Output:

Sales: $1250.00
Warning: Inventory is low

SalesTotal and WarningMessage are not versions of the same base class. They simply share the capability of rendering one report line. An interface is the cleanest polymorphic contract here because it avoids forcing an unnatural inheritance tree.

How It Works Step by Step

  1. The compiler checks that each override matches an inherited virtual, abstract, or already overridden member with a compatible signature.
  2. When an object is created, the CLR allocates the concrete runtime type, such as Dog or ExpressShipping.
  3. A base-class or interface reference stores a reference to that same concrete object. The object is not copied or converted into the base type.
  4. At compile time, the variable type controls what members are visible. An Animal variable cannot call Dog-specific methods unless you cast or pattern match.
  5. For a virtual call, the runtime dispatch mechanism selects the implementation associated with the actual runtime type.
  6. If a derived class does not override a virtual method, the nearest inherited implementation runs.
  7. For interface calls, the runtime maps the interface method to the concrete implementation supplied by the object’s type.

This design is why polymorphic code is open to new types. If you add a DroneShipping class that derives from ShippingMethod, the loop over ShippingMethod[] does not need to change. The new class carries its behavior with it.

Common Mistakes

Mistake 1: Confusing Overloading With Overriding

using System;

class Notifier
{
    public virtual void Send(string message)
    {
        Console.WriteLine("Base: " + message);
    }
}

class EmailNotifier : Notifier
{
    public void Send(object message)
    {
        Console.WriteLine("Email: " + message);
    }
}

class Program
{
    static void Main()
    {
        Notifier notifier = new EmailNotifier();
        notifier.Send("Hello");
    }
}

Output:

Base: Hello

EmailNotifier added a new overload, Send(object). It did not override Send(string), so a call through a Notifier variable still uses the base implementation. The corrected version uses the exact same signature and the override keyword.

using System;

class Notifier
{
    public virtual void Send(string message)
    {
        Console.WriteLine("Base: " + message);
    }
}

class EmailNotifier : Notifier
{
    public override void Send(string message)
    {
        Console.WriteLine("Email: " + message);
    }
}

class Program
{
    static void Main()
    {
        Notifier notifier = new EmailNotifier();
        notifier.Send("Hello");
    }
}

Output:

Email: Hello

Mistake 2: Expecting Base Variables to Expose Derived-Only Members

Animal animal = new Dog("Rex");
animal.Fetch();

This does not compile because animal is declared as Animal, and Fetch is not part of the Animal contract. Polymorphism is strongest when callers use shared behavior. If derived-only behavior is truly needed, use pattern matching carefully.

using System;

class Animal
{
    public Animal(string name)
    {
        Name = name;
    }

    public string Name { get; }
}

class Dog : Animal
{
    public Dog(string name) : base(name)
    {
    }

    public void Fetch()
    {
        Console.WriteLine($"{Name} fetches the ball.");
    }
}

class Program
{
    static void Main()
    {
        Animal animal = new Dog("Rex");

        if (animal is Dog dog)
        {
            dog.Fetch();
        }
    }
}

Output:

Rex fetches the ball.

Best Practices

  • Program to the smallest useful abstraction: an interface when only behavior matters, an abstract base class when shared state or implementation matters.
  • Use override intentionally. If you see new on a method, make sure hiding is really what you want.
  • Keep base contracts stable. Changing a virtual or abstract member affects every derived class.
  • Avoid long if/switch chains that check concrete types just to choose behavior; move that behavior into polymorphic methods.
  • Do not make every method virtual. Virtual members are extension points and should have clear rules.
  • Prefer meaningful method names and return types in the abstraction so callers do not need casts.
  • Use pattern matching for exceptional derived-specific operations, not as the main design of an object hierarchy.
  • Consider sealing classes or overrides when they are not designed for further extension.

Practice Exercises

  1. Create an abstract PaymentMethod class with an abstract Pay method. Implement CreditCardPayment and GiftCardPayment, then store both in a PaymentMethod[].
  2. Create an INotificationSender interface with Send. Implement email and SMS senders, then loop over a list of the interface type.
  3. Rewrite a program that uses a switch on shape names into polymorphic Area methods on shape classes.

Summary

  • Polymorphism lets code use different concrete objects through a common base class or interface.
  • virtual, abstract, and override enable runtime dispatch for class hierarchies.
  • Interfaces enable polymorphism based on shared capability rather than shared ancestry.
  • The variable’s compile-time type controls what members you can call; the object’s runtime type controls which override runs.
  • Overloading is compile-time method selection; overriding is runtime implementation selection.
  • Good polymorphic design replaces fragile type checks with clear contracts and type-specific behavior.