← Open in the full interactive course (progress tracking, search & more)

Calling Speak() on an Animal was the demo. Holding a thousand different shapes in one list and trusting every one of them to behave — that's the real thing.

You've seen the classic polymorphism demo: an Animal reference holding a Dog, calling Speak(), getting "Woof!" That's correct, but it's a single object. Real polymorphism usually shows up as a collection of many different concrete types, all handled by one loop that has no idea what's actually in it:

List<Shape> shapes =
[
    new Circle(Radius: 4),
    new Rectangle(Width: 3, Height: 5),
    new Triangle(Base: 6, Height: 2),
    // ...could be a mix of 40 different Shape subtypes by the time this ships
];

decimal totalArea = shapes.Sum(s => s.Area());   // one line, works for every current AND future shape

That single line, shapes.Sum(s => s.Area()), doesn't know or care whether it's summing three shapes or three thousand, or whether someone adds a Pentagon class next month. That's polymorphism doing real work — but it also raises questions a beginner demo never forces you to confront: what happens if one of those subtypes doesn't really behave like the others expect? What's actually happening at the machine level on every one of those calls? And when should you reach for switch pattern matching instead of polymorphism entirely?

In this lesson: polymorphic collections in practice, the Liskov Substitution Principle as the rule that keeps polymorphism safe, polymorphism vs. pattern matching as two competing designs, and a look under the hood at what virtual dispatch actually costs.

Polymorphic Collections

A List<Shape> holding a mix of Circle, Rectangle, and Triangle is the everyday, working form of polymorphism. Every element is stored and referenced as a Shape, but every method call resolves to the actual, concrete type's implementation at runtime. This applies whether the common type is a base class or, just as often in modern C#, an interface — List<IExporter>, IEnumerable<IValidationRule>, and similar patterns are everywhere in production code (and connect directly to what you saw in lesson 073).

The entire value of a polymorphic collection is that the code iterating it — the loop, the LINQ call, the caller — is written once and never has to change as new concrete types are added, exactly like composition let a container class stay unchanged as new strategies were added.

Why the Liskov Substitution Principle Matters Here

Polymorphic code makes one massive, load-bearing assumption: any subtype can stand in for the base type without the caller needing to know or care which one it actually got. The Liskov Substitution Principle (the "L" in SOLID) is exactly that assumption, made explicit as a design rule: if S is a subtype of T, objects of type T should be replaceable with objects of type S without altering the correctness of the program.

Here's what happens when a subtype quietly violates that assumption — the code compiles perfectly, and breaks at runtime, exactly where the polymorphic loop assumed every element would behave consistently:

public abstract class Shape
{
    public abstract double Area();
}

public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public override double Area() => Width * Height;
}

// LSP VIOLATION: looks like a legitimate "is-a" relationship, isn't.
public class UninitializedPlaceholderShape : Shape
{
    public override double Area() => throw new NotSupportedException("Not yet configured.");
}

List<Shape> shapes = [new Rectangle { Width = 3, Height = 4 }, new UninitializedPlaceholderShape()];

decimal totalArea = (decimal)shapes.Sum(s => s.Area());
//  Crashes at runtime — not because the LOOP is wrong, but because one subtype
// broke the promise every Shape is supposed to keep: "I can compute my Area()."

The loop is exactly the same correct code you'd write for any polymorphic collection. The bug is entirely in UninitializedPlaceholderShape — a class that technically is a Shape in the type system, but does not honor the behavioral contract every other Shape honors. LSP gives you the vocabulary to say precisely what went wrong: this subtype cannot safely substitute for its base type.

Big Picture — Two Designs for the Same Problem

Once you know both tools, most "which type is this and what should I do with it" problems can be solved either polymorphically or with pattern matching. They are not the same thing wearing different clothes — they trade off in opposite directions.

Polymorphism (virtual dispatch)

Pattern matching (switch expressions)

This is the classic "expression problem" trade-off in disguise: polymorphism makes adding types cheap and adding operations expensive; pattern matching makes adding operations cheap and adding types expensive (or at least, harder to miss, if the hierarchy is sealed).

The Same Problem, Solved Both Ways

// ─── Approach 1: Polymorphism ───
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;

public abstract record ShapeBase
{
    public abstract double Area();
}
public sealed record PolyCircle(double Radius) : ShapeBase
{
    public override double Area() => Math.PI * Radius * Radius;
}
public sealed record PolyRectangle(double Width, double Height) : ShapeBase
{
    public override double Area() => Width * Height;
}
// New shape? Write one new record with its own Area(). Nothing else changes.
// New OPERATION (e.g. Perimeter())? Must add an abstract member and implement it EVERYWHERE.

// ─── Approach 2: Pattern matching ───
public static double Area(Shape shape) => shape switch
{
    Circle c => Math.PI * c.Radius * c.Radius,
    Rectangle r => r.Width * r.Height,
    _ => throw new NotSupportedException($"Unknown shape: {shape.GetType().Name}")
};
// New shape? Every switch like this one across the codebase needs a new arm — easy to miss.
// New OPERATION (e.g. Perimeter())? Just write one new function. Existing Shape types untouched.

A closed hierarchy (using C#'s sealed and, for even stronger guarantees, an exhaustive switch over a discriminated set of record types) lets the compiler warn you when a switch doesn't handle every case — narrowing pattern matching's biggest weakness considerably, which is a large part of why records and pattern matching are so often paired in modern C#.

Real-World Example — A Reporting Engine with Multiple Exporters

Multiple export formats is a textbook case for polymorphism: the set of formats grows over time (new type), and the operation ("export this report") stays fixed.

public interface IReportExporter
{
    string FormatName { get; }
    byte[] Export(ReportData data);
}

public sealed class CsvExporter : IReportExporter
{
    public string FormatName => "CSV";
    public byte[] Export(ReportData data) => Encoding.UTF8.GetBytes(ToCsv(data));
    private static string ToCsv(ReportData data) => string.Join('\n',
        data.Rows.Select(r => string.Join(',', r.Values)));
}

public sealed class JsonExporter : IReportExporter
{
    public string FormatName => "JSON";
    public byte[] Export(ReportData data) => JsonSerializer.SerializeToUtf8Bytes(data);
}

public sealed class PdfExporter : IReportExporter
{
    public string FormatName => "PDF";
    public byte[] Export(ReportData data) => PdfRenderer.Render(data); // hypothetical helper
}

// The consuming code never grows, no matter how many exporters exist:
public sealed class ReportExportService(IEnumerable<IReportExporter> exporters)
{
    public byte[] ExportAs(string formatName, ReportData data)
    {
        var exporter = exporters.FirstOrDefault(e =>
            e.FormatName.Equals(formatName, StringComparison.OrdinalIgnoreCase))
            ?? throw new NotSupportedException($"Export format '{formatName}' is not supported.");

        return exporter.Export(data);   // polymorphic call — the service never checks "is this CsvExporter?"
    }
}

Notice this is simultaneously an example of composition (lesson 073) — ReportExportService is composed of an injected collection of IReportExporter — and polymorphism: the exporter.Export(data) call dispatches to whichever concrete exporter was actually resolved, with ReportExportService never branching on concrete type. This is exactly why these lessons build on each other: composition decides which object you're holding; polymorphism is what makes calling a method on it "just work" regardless of which one it turned out to be.

Under the Hood — What Virtual Dispatch Actually Costs

FROM A METHOD CALL TO A CPU INSTRUCTION
1. A NON-VIRTUAL CALL IS A DIRECT JUMP
2. A VIRTUAL CALL READS THE OBJECT'S METHOD TABLE FIRST
3. THE JIT CAN OFTEN DEVIRTUALIZE ANYWAY
4. IN PRACTICE, THIS RARELY MATTERS

Common Confusion

"LSP just means subclasses must override every method" — no

LSP is a behavioral rule, not a syntactic one. A subclass can compile perfectly, override every method with the correct signature, and still violate LSP — by throwing where the base type promised a result, by returning something out of the range the base type guarantees, or by strengthening preconditions (demanding more from callers than the base type does) or weakening postconditions (guaranteeing less than the base type does). The compiler cannot check any of this; it's a design discipline, not a language feature.

"Pattern matching is the modern replacement for polymorphism" — not exactly

Pattern matching in C# (especially with records and sealed hierarchies) is a legitimate, often cleaner alternative for closed sets of types where operations grow over time. But it's not a strictly "better" replacement — it inverts the trade-off rather than eliminating it. For an open-ended, plugin-style set of types (like exporters that third parties might add), polymorphism through an interface remains the right tool, because pattern matching requires knowing every case at the point of the switch, which is impossible for types that don't exist yet.

Common Mistakes

Mistake 1 — Checking concrete type inside a polymorphic loop

foreach (var shape in shapes)
{
    //  Defeats the entire purpose of polymorphism — new shapes silently fall through
    if (shape is Circle c) totalArea += Math.PI * c.Radius * c.Radius;
    else if (shape is Rectangle r) totalArea += r.Width * r.Height;
}

If you find yourself branching on concrete type this way, either push the behavior into the type (true polymorphism) or switch to a deliberate, exhaustive pattern-matching design where the compiler can warn you about missing cases.

Mistake 2 — A subclass that throws to "opt out" of the base contract

The UninitializedPlaceholderShape from earlier in this lesson — technically a Shape, but unable to honor Area(). This is one of the clearest LSP violations, and it directly connects to lesson 072's warning sign for a liability hierarchy.

If a "subtype" can't honor the base contract, it usually isn't really a subtype — model it as a separate type, or restructure the contract so the operation that can't be honored isn't part of it.

Mistake 3 — Premature devirtualization worry

Avoiding polymorphism or interfaces "for performance" in ordinary business/application code, without ever having measured that virtual dispatch is a bottleneck.

Write the clear, polymorphic design first. Reach for sealed and devirtualization concerns only in profiler-identified hot paths — not by default.

Polymorphism vs. Pattern Matching — Which One?

Mental Model

Polymorphism = "the caller doesn't know which type it has, and doesn't need to."
LSP = "and every type in this family must keep that promise honestly."

Remember:
· New type easy, new operation hard = polymorphism. New operation easy, new type hard = pattern matching.
· A subclass that throws to avoid honoring the base contract is an LSP violation, not a valid override.
· Virtual dispatch costs a memory read, usually invisible; sealed can let the JIT skip it entirely.

Key Takeaway


Check Your Understanding

Let's confirm you can reason about substitutability, not just call syntax.

1. A subclass overrides its base class's method with the correct signature, but throws NotSupportedException instead of returning a value the base type promises. What principle does this violate?

Show answer

Correct: B

Why B is correct: LSP requires that any subtype be substitutable for its base type without breaking correctness. A subclass that throws instead of honoring the base contract breaks exactly that assumption, even though it compiles fine.

Why A is incorrect: This isn't about hiding internal state — it's about whether the type honors its public behavioral contract.

Why C is incorrect: Compiling successfully only checks the signature, not the behavior — LSP violations are specifically the kind of bug the compiler cannot catch.

Why D is incorrect: The Open/Closed Principle is about extending behavior without modifying existing code — unrelated to this scenario.

Reinforcement: LSP is a behavioral promise, not something the compiler enforces for you.

2. You expect the set of concrete types (e.g. export formats) to grow frequently, possibly even from third-party plugins, while the operations performed on them stay stable. Which design fits better?

Show answer

Correct: B

Why B is correct: Polymorphism makes adding a new type cheap (implement the interface) and requires no changes to existing calling code — exactly the trade-off needed for an open-ended, plugin-friendly set of types.

Why A is incorrect: A switch expression needs to know every case up front; it can't handle types that don't exist yet, such as a future third-party plugin.

Why C is incorrect: This is a well-established, common scenario in C#, solved cleanly with an interface and polymorphic dispatch.

Why D is incorrect: This is exactly the anti-pattern polymorphism exists to avoid — manual type checks that must be updated for every new type and are easy to forget.

Reinforcement: Open-ended type growth favors polymorphism; closed type sets with growing operations favor pattern matching.

3. What does "devirtualization" mean, and what makes it possible?

Show answer

Correct: B

Why B is correct: When the JIT can determine the exact concrete type of a call target — which is easier when a class is sealed and therefore has no possible further overrides — it can skip the method-table lookup and emit a direct call instead, which is what devirtualization means.

Why A is incorrect: Devirtualization is a JIT optimization technique, not a codebase-wide removal of interfaces.

Why C is incorrect: This is unrelated to the class/struct distinction; devirtualization applies to virtual method calls regardless of that choice.

Why D is incorrect: Devirtualization is a performance optimization, not an error condition — virtual dispatch doesn't "fail."

Reinforcement: This is why sealed is a genuine performance signal, covered in depth in lesson 076.

4. Why is checking if (shape is Circle c) ... else if (shape is Rectangle r) ... inside a loop over a polymorphic List<Shape> generally considered a design smell?

Show answer

Correct: B

Why B is correct: The entire benefit of a polymorphic collection is that calling code doesn't need to know about every concrete type. Manually type-checking inside the loop reintroduces exactly the coupling polymorphism was meant to remove, and any new subtype added later will silently fall through unless someone remembers to update every such check.

Why A is incorrect: This is valid, compiling C# — the problem is architectural, not syntactic.

Why C is incorrect: Pattern matching with is remains a fully supported, modern C# feature — it's just being misapplied here.

Why D is incorrect: This is the opposite of best practice for a genuinely polymorphic design; it's exactly the mistake this lesson calls out.

Reinforcement: If you're branching on concrete type inside code meant to be polymorphic, either push the behavior into the type or switch fully to a deliberate, exhaustive pattern-matching design instead.

You now understand polymorphism as a behavioral contract, not just a syntax trick — which sets up abstract classes (075) perfectly, since they're often where that contract is defined.


dotnetmadeeasy.com — Learn C# and .NET, the right way.