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.
Discussion
Loading…