iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Polymorphism

Polymorphism lets a single call do the right thing depending on the actual runtime type. In C# that’s virtual/override for inheritance, interface dispatch, generic constraints, and pattern matching — modern code uses sealed types + pattern matching as the default.

virtual, interfaces, generics, switch

EXAMPLE
// 1) Classic virtual / override
public class Animal
{
    public string Name { get; }
    public Animal(string name) => Name = name;
    public virtual string Sound() => "...";
    public override string ToString() => $"{GetType().Name} {Name}";
}

public class Dog : Animal
{
    public Dog(string name) : base(name) { }
    public override string Sound() => "woof";
}

public class Puppy : Dog
{
    public Puppy(string name) : base(name) { }
    public override string Sound() => $"{base.Sound()} (small)";
}

Animal a = new Puppy("Rex");
Console.WriteLine(a.Sound());                 // "woof (small)" — dispatched on runtime type

// 2) abstract members — subclasses MUST implement
public abstract class Shape
{
    public abstract double Area();
    public virtual string Describe() => $"{GetType().Name} area={Area():F2}";
}

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

// 3) Interface polymorphism — preferred over deep class hierarchies
public interface IPaymentMethod
{
    Task<Receipt> ChargeAsync(decimal amount);
}

public class CreditCard : IPaymentMethod { public async Task<Receipt> ChargeAsync(decimal amount) { /* … */ } }
public class StripeBalance : IPaymentMethod { public async Task<Receipt> ChargeAsync(decimal amount) { /* … */ } }
public class StoreCredit : IPaymentMethod { public async Task<Receipt> ChargeAsync(decimal amount) { /* … */ } }

public class Checkout
{
    public Task<Receipt> Pay(IPaymentMethod method, decimal amount) => method.ChargeAsync(amount);
}
// Checkout doesn't know or care WHICH payment type.
// Adding a fourth method is one new class + one DI registration.

// 4) Pattern matching — exhaustive polymorphism without if-chains
public abstract record Shape;
public sealed record Circle2(double Radius) : Shape;
public sealed record Square (double Side)  : Shape;
public sealed record Rect   (double W, double H) : Shape;

static double Area(Shape s) => s switch
{
    Circle2 c    => Math.PI * c.Radius * c.Radius,
    Square sq    => sq.Side * sq.Side,
    Rect r       => r.W * r.H,
    _            => 0,
};

// 5) Sealed hierarchies — make pattern matching exhaustive
// Use 'sealed' on records/classes to signal a closed family.
// In .NET 8+, the compiler warns if your switch misses a case.

public abstract record PaymentResult;
public sealed record Approved(string TxnId)  : PaymentResult;
public sealed record Declined(string Reason) : PaymentResult;
public sealed record Pending (string Token)  : PaymentResult;

string Describe(PaymentResult r) => r switch
{
    Approved a => $"approved: {a.TxnId}",
    Declined d => $"declined: {d.Reason}",
    Pending  p => $"pending: {p.Token}",
    // no default needed if all cases handled
};

// 6) Covariant return types (C# 9+)
public class Animal3 { public virtual Animal3 Clone() => new Animal3(); }
public class Dog3 : Animal3 { public override Dog3 Clone() => new Dog3(); }
// Dog3.Clone() returns Dog3, not Animal3 — strongly typed override

// 7) Generic polymorphism + constraints
public interface ICacheable { string CacheKey { get; } }

public class Repository<T> where T : class, ICacheable
{
    public T? Get(string id) { /* … */ return default; }
    public string KeyFor(T t) => t.CacheKey;
}

// 8) Default interface implementations (C# 8+)
public interface ILogger
{
    void Log(LogLevel level, string msg);
    void Info(string msg)  => Log(LogLevel.Info, msg);     // default
    void Error(string msg) => Log(LogLevel.Error, msg);
}
// Implementers only need to provide Log; Info/Error come for free unless overridden.

// 9) Composition + polymorphism via DI
builder.Services.AddSingleton<IPaymentMethod, CreditCard>();
builder.Services.AddSingleton<IPaymentMethod, StripeBalance>();
builder.Services.AddSingleton<IPaymentMethod, StoreCredit>();

public class CheckoutEndpoint(IEnumerable<IPaymentMethod> methods)
{
    [HttpPost]
    public async Task<IActionResult> Pay(string kind, decimal amount)
    {
        var method = methods.OfType<CreditCard>().First();    // or a strategy lookup
        var receipt = await method.ChargeAsync(amount);
        return Ok(receipt);
    }
}

// 10) Liskov red flags
//   • Override that throws NotSupportedException — caller can't substitute safely
//   • Subclass tightens preconditions, weakens postconditions — LSP violation
//   • Square : Rectangle that allows setting width / height separately — classic LSP break
//   • Need 'if (obj is X) …' after a polymorphic call — polymorphism failed

// 11) Switch + records — best for closed families
public record HttpResponse(int Status, string Body);
string Describe(HttpResponse r) => r switch
{
    { Status: < 200 } or { Status: >= 500 } => "server error",
    { Status: >= 200 and < 300 }            => "success",
    { Status: 401 or 403 }                  => "auth issue",
    _                                         => "other",
};

// 12) Performance — sealed + virtual + JIT inlining
// • A method on a 'sealed' class can sometimes be inlined where a virtual one can't
// • For hot paths, mark leaf classes 'sealed' to give the JIT more freedom
// • Pattern matching on sealed hierarchies compiles to tight jump tables

// 13) Where polymorphism breaks down
//   • You're branching on type AND on state — the branch belongs on the state, not the type
//   • Adding a new subtype requires editing N existing files — visitor pattern (or pattern matching) is your friend
//   • Dynamic dispatch in hot loops — measure, consider 'sealed' or struct-based generics

// 14) Common bugs
//   • Using 'new' instead of 'override' — hides the base method, breaks polymorphism silently
//   • Calling virtual method in a constructor — subclass code runs on a half-built object
//   • Adding a case to a switch but not the pattern match in another file — modern C# warns; embrace sealed types
//   • Mixing concrete dependency with interface dependency in one constructor — refactor to all interfaces
//   • Polymorphism via 'object' + downcast — usually a missing interface
//   • Multiple inheritance via two interfaces with default methods of the same name — resolve explicitly
//   • Treating IEnumerable<T> like a fast collection — it's polymorphic; enumeration cost depends on the runtime type

Why it matters

C# polymorphism in 2025 means “sealed records + pattern matching” for closed families, plus interfaces for open extension points injected via DI. Reserve virtual/override for abstract bases with shared concrete behaviour, and let the compiler check exhaustiveness on your switch expressions — that’s the safety net traditional inheritance never had.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
Animal a = new Dog();
Console.WriteLine(a.Speak());   // "woof"
Try it Yourself »

Discussion

Loading…