📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is polymorphism in C#? Explain compile-time (overloading, generics) and run-time (virtual dispatch, interfaces) polymorphism, how virtual dispatch works under the hood, and how pattern matching relates to it.

Polymorphism in C#: the kinds, how virtual and interface dispatch work, and when to use polymorphism versus pattern matching.

Mid-Level Theory Intermediate interfaces oop pattern-matching polymorphism virtual-dispatch

🎯 Key points

  • Polymorphism lets code use a base type or interface while each object behaves by its actual type
  • Run-time polymorphism uses virtual/override and interfaces; compile-time uses overloading and generics
  • Virtual calls go through method table slots; interface calls use cached dispatch stubs
  • The JIT devirtualises calls for sealed types, known exact types and with dynamic PGO
  • Overload resolution is compile time and uses static types
  • Generics with `static abstract` members (C# 11) enable compile-time polymorphism such as generic maths
  • Virtual methods make adding types easy; pattern matching makes adding operations easy
  • Repeated type-switch chains usually mean behaviour should move into the types

⏱ 30-second answer

Polymorphism means one interface, many forms: code written against a base type or interface works with any implementation, and each object behaves according to its actual type. Run-time (subtype) polymorphism uses virtual/override and interfaces; the call is dispatched through the object's method table (a vtable slot for virtual methods, an interface dispatch stub for interfaces), so shape.Area() runs Circle.Area or Square.Area. Compile-time polymorphism is chosen by the compiler: method overloading, generics (and static abstract interface members for generic maths). Pattern matching on types (switch on shape) is the alternative: good for closed sets of data types and operations added often; virtual methods are better when new types are added often.

🗣 Full answer (senior)

How I would answer this in an interview

Polymorphism means one piece of code works with many types. C# has two broad kinds: compile-time (the compiler picks the implementation — overloading, generics) and run-time (the object's actual type picks the implementation — virtual methods and interfaces). I'd explain both, how virtual dispatch actually works, and where pattern matching fits as an alternative.

1. Compile-time (static) polymorphism

Overloading — same name, different parameters; chosen by static types:

public static decimal Gst(decimal amount) => amount * 0.1m;
public static decimal Gst(Invoice invoice) => invoice.Lines.Sum(l => Gst(l.Amount));

Generics — one algorithm, many types, type-checked at compile time:

public static T Max<T>(ReadOnlySpan<T> items) where T : IComparable<T> { ... }
public static T Sum<T>(ReadOnlySpan<T> items) where T : INumber<T> { ... }   // generic math (static abstract members)

For value types the JIT generates specialised code per type, so generic polymorphism has zero dispatch cost. Operator overloading is another static form.

2. Run-time (dynamic) polymorphism

Virtual methods / abstract classes:

public abstract class Shape { public abstract double Area(); }
public sealed class Circle(double r) : Shape { public override double Area() => Math.PI * r * r; }
public sealed class Rect(double w, double h) : Shape { public override double Area() => w * h; }

IEnumerable<Shape> shapes = [new Circle(1), new Rect(2, 3)];
var total = shapes.Sum(s => s.Area());   // each call dispatches on the runtime type

Interfaces — unrelated types implementing the same contract (IPaymentGateway implemented by Stripe and Adyen adapters). This is how DI-based applications vary behaviour: the caller depends on the interface, the container provides an implementation.

3. How virtual dispatch works under the hood

  • Every object has a pointer to its type's MethodTable, which contains a vtable of method pointers. Overrides replace the inherited slot.
  • A virtual call: load MethodTable from the object → load the slot at a fixed offset → indirect call. A few instructions, but the indirect call prevents inlining.
  • Interface calls can't use a fixed slot (a class implements many interfaces), so CoreCLR uses virtual stub dispatch: each call site starts with a lookup stub, then a monomorphic cache ("if MethodTable == X, jump to X's implementation"), escalating to a polymorphic resolve stub if many types are seen.
  • The JIT devirtualises when it can prove the type: sealed classes, exact types from new, value types in generics, and with dynamic PGO (default since .NET 8) it inserts guarded devirtualisation ("if it's usually Circle, call Circle.Area directly and inline it").

4. Pattern matching: the other way to vary behaviour

double Area(Shape s) => s switch
{
    Circle c => Math.PI * c.R * c.R,
    Rect r   => r.W * r.H,
    _        => throw new UnreachableException()
};

This is the functional approach: behaviour lives in one function over a closed set of types. Trade-off (the "expression problem"):

Virtual methods Pattern matching
Adding a new type Easy: new subclass Hard: update every switch
Adding a new operation Hard: add to every subclass Easy: new function
Best for Open hierarchies, plug-ins Closed sets: result types, messages, ASTs

I use virtual/interface polymorphism for extensibility points and pattern matching for closed domain variants (e.g. PaymentResult subtypes mapped to HTTP responses).

5. A production example

A tax calculation module had a 300-line switch on a Country string scattered across three services; adding New Zealand meant editing all three and one was missed. We introduced ITaxPolicy with implementations per jurisdiction resolved from DI by key (.NET 8 keyed services), so a new country was one class plus a registration. Conversely, our API response mapping used a switch over a sealed OrderResult hierarchy, because the set of outcomes was closed and having the mapping in one place was clearer than virtual ToHttpResult() methods on domain types.

🔍 What the interviewer is checking

Checks understanding of a core OOP mechanism and the ability to pick between OOP and functional styles.

  • Green flags: explains run-time and compile-time polymorphism, method table dispatch, devirtualisation, interfaces versus base classes, and the expression problem trade-off with pattern matching.
  • Red flags: equates polymorphism only with overloading, uses type-check chains, or cannot explain how a virtual call is resolved.

📚 Learn in depth

1. Vtable dispatch in detail

obj --> [header][MethodTable*][fields]
                   |
                   v
        MethodTable: [..., parent*, ..., vtable slot 0 (ToString), slot 1 (Equals), slot 2 (GetHashCode), slot 3 (Finalize), slot 4 (Area) ...]

A virtual call s.Area() compiles (x64, roughly) to:

mov rax, [rcx]          ; MethodTable
mov rax, [rax + 0x40]   ; vtable chunk
call [rax + 0x20]       ; slot for Area

The cost is small (a couple of dependent loads + indirect branch, usually well predicted). The bigger cost is lost inlining: an inlined Area might be folded into the loop and vectorised; a virtual one can't.

2. Virtual stub dispatch for interfaces

Interface slots differ per implementing type, so call sites use stubs:

  1. Lookup stub: on first call, resolve the target via the type's interface map, then patch the call site.
  2. Dispatch stub (monomorphic): compare the object's MethodTable with a cached one; on match jump directly.
  3. Resolve stub (polymorphic): hash-table lookup when many types appear at the site.

So interface calls at monomorphic sites are about as fast as virtual calls; megamorphic sites are slower. (.NET has been moving some scenarios to "cached interface dispatch" similar to NativeAOT.)

3. Devirtualisation

Situation Devirtualised?
Receiver type is sealed Yes
Receiver created with new in the same method Yes (exact type known)
Value type in a generic method with constraints (T : IComparable<T>) Yes — specialised code, direct call
Reference type in shared generic code Usually no (shared __Canon code)
Dynamic PGO observes one dominant type Guarded devirtualisation: if (mt == Circle) inline Circle.Area else virtual call
EqualityComparer<T>.Default Special-cased intrinsic

This is why sealing classes (CA1852) and using generic constraints instead of interface parameters on hot paths have real performance benefits.

4. Ad-hoc, parametric and subtype polymorphism

In type theory:

  • Ad-hoc: overloading and operator overloading — different implementations per type, chosen statically.
  • Parametric: generics — one implementation for all types.
  • Subtype: inheritance/interfaces — a value of a subtype used where the supertype is expected.
  • C# 11's static abstract interface members add a form of type-class polymorphism (generic math): T.Zero, T.Parse, operators on T.

5. Double dispatch

Virtual calls dispatch on one object (the receiver). To dispatch on two runtime types (e.g. collision between Shape pairs), options are the visitor pattern (two virtual calls), a pattern-matching switch on a tuple (a, b) switch { (Circle, Rect) => ... }, or dynamic (slow, runtime-checked).

6. Pattern matching performance

The compiler turns type patterns into a sequence of isinst checks (or a decision DAG). For a handful of types it's comparable to virtual dispatch; for many types, ordering matters. Sealed records in a closed hierarchy make the switch both fast and exhaustive-looking (still requires _ until C# gets closed hierarchies / unions).

Try it yourself

  1. Benchmark summing areas via virtual calls with a sealed vs non-sealed class, and with mixed types.
  2. Use DOTNET_JitDisasm to see guarded devirtualisation with PGO enabled.
  3. Implement the same feature with virtual methods and with a switch expression; add a new shape and a new operation to each and compare the edits.
  4. Write a generic Sum<T> with INumber<T> and call it with int and decimal.

💻 Code

// Run-time polymorphism via an interface
public interface ICarrier
{
    string Code { get; }
    decimal Quote(Parcel parcel);
}

public sealed class AusPostCarrier : ICarrier
{
    public string Code => "AUSPOST";
    public decimal Quote(Parcel p) => 9.95m + p.WeightKg * 1.20m;
}

public sealed class CourierCarrier : ICarrier
{
    public string Code => "COURIER";
    public decimal Quote(Parcel p) => p.Express ? 25m : 15m;
}

public sealed record Parcel(decimal WeightKg, bool Express);

public sealed class QuoteService(IEnumerable<ICarrier> carriers)
{
    public IReadOnlyList<(string Carrier, decimal Price)> QuoteAll(Parcel parcel) =>
        carriers.Select(c => (c.Code, c.Quote(parcel))).OrderBy(q => q.Item2).ToList();
}

// Compile-time polymorphism: generics with static abstract members (generic maths)
static T Sum<T>(IEnumerable<T> values) where T : INumber<T>
{
    T total = T.Zero;
    foreach (var v in values) total += v;
    return total;
}

int a = Sum([1, 2, 3]);            // 6
decimal b = Sum([1.5m, 2.5m]);     // 4.0

// External polymorphism: closed hierarchy + pattern matching
public abstract record Shape;
public sealed record Circle(double R) : Shape;
public sealed record Rect(double W, double H) : Shape;

static double Area(Shape s) => s switch
{
    Circle c => Math.PI * c.R * c.R,
    Rect r   => r.W * r.H,
    _        => throw new ArgumentOutOfRangeException(nameof(s))
};

⚠ Common mistakes

1. Expecting overloading to dispatch on runtime type

What happens: Handle(object o) and Handle(Order o) overloads, called with an object variable holding an Order. Why it is wrong: overload resolution is compile-time; Handle(object) runs. Do instead: use virtual methods, interfaces or pattern matching for runtime decisions.

2. Long type switches in many places

What happens: the same switch on type or a string discriminator is duplicated across services. Why it is wrong: adding a type means finding every switch; one is always missed. Do instead: use polymorphism (interfaces/strategies) for open sets, or centralise one switch for closed sets.

3. Forcing polymorphism onto closed sets

What happens: domain result types each get virtual ToHttpResponse() methods. Why it is wrong: mixes presentation concerns into domain types. Do instead: use a single pattern-matching switch in the presentation layer.

4. Hiding with new and calling it polymorphism

What happens: derived methods declared with new. Why it is wrong: dispatch is static; base references call the base method. Do instead: use virtual/override.

5. Ignoring the cost of virtual calls in hot loops

What happens: tight numeric loops call virtual/interface methods per element. Why it is wrong: prevents inlining and vectorisation. Do instead: seal classes, use generic constraints with structs, or batch work.

6. Interface parameters for structs

What happens: passing a struct to a method taking IComparable. Why it is wrong: boxing on every call plus interface dispatch. Do instead: use generic methods constrained to the interface.

7. Using dynamic for runtime dispatch

What happens: Handle((dynamic)message) to pick overloads. Why it is wrong: slow, fails at run time when an overload is missing, and doesn't work with trimming/AOT. Do instead: use pattern matching or a handler registry.

8. Defining polymorphism only as inheritance

What happens: candidates describe polymorphism purely as virtual methods. Why it is wrong: misses generics, overloading, interfaces and static abstract members. Do instead: describe compile-time and run-time forms and their trade-offs.

🏛 Senior insight: trade-offs

Seniors choose the form of polymorphism by asking what changes more often: the set of types or the set of operations.

Situation Choice
Plugins, carriers, providers added over time interface + DI registration
Closed set of message or result types records + exhaustive switch
Algorithms over many numeric types generics with INumber<T>
Small behaviour variation delegate or strategy
Hot path with one dominant type sealed types, let PGO devirtualise

They also mention that polymorphism moves decisions to where the knowledge lives, which reduces coupling; the goal is not class hierarchies but fewer places that need to change.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate uses a base class example Why: Checks inheritance mechanics and LSP
    How to answer

    Inheritance lets a derived class reuse and specialise a base class: class SavingsAccount : Account. C# allows one base class but many interfaces. The derived class inherits all members except constructors and finalisers (private ones exist but are not accessible), can call base members with base., and can override virtual/abstract members. Declaring a member with the same name without override hides it (new), and which one runs then depends on the variable's static type, a common source of bugs. Use inheritance only for a real is-a relationship where a derived object can be used anywhere the base is expected (Liskov substitution). Deep hierarchies, inheriting just to reuse code, and fragile base classes are the main problems; composition is often better.

  2. When: Candidate mentions overloading Why: Probes overload resolution rules
    How to answer

    A method is a named block of code with a signature: its name plus the number, types, order and ref/out/in kinds of its parameters (the return type is not part of it, so you cannot overload by return type alone). Instance methods run on an object and can use this; static methods belong to the type and cannot. Overloading means several methods with the same name and different signatures; the compiler picks the best match at compile time, preferring exact types over conversions and normal forms over params. Expression-bodied methods (=> expr) are short forms for one expression. Extension methods are static methods with a this parameter that look like instance methods; an instance method always wins if one fits. C# 14 adds extension blocks, which also allow extension properties and static extension members.

🛠 Real-world problems: how you handle them

Carrier switch statements repeated in five places caused missed cases
Situation
A shipping service handled carriers with a `switch` on a `Carrier` enum in quoting, labelling, tracking, cancellation and reporting code.
Signals
Each new carrier took [weeks]; twice a case was missed and orders for the new carrier failed at label creation in production.
Investigation
Searched for `switch (carrier)` and found five large switches with slightly different defaults. The default branch silently returned zero or null for unknown carriers.
Resolution
Introduced an `ICarrier` interface with quoting, labelling and tracking members, one class per carrier, all registered in DI. Services resolve carriers by code and call them polymorphically.
Prevention
A contract test suite runs against every `ICarrier` implementation, and an analyzer rule flags new switches on the old enum.

How to tell it (STAR)

**Situation:** At [company] adding a shipping carrier took weeks, and twice a missed switch case broke label creation in production. **Task:** I proposed and led a redesign so carriers could be added safely. **Action:** I found the same enum switch in five places with different defaults. I moved each carrier's behaviour into a class implementing `ICarrier`, registered them in DI, and replaced the switches with polymorphic calls. I wrote a shared contract test that every carrier must pass. **Result:** The next carrier was added as one new class in [three days], with no changes to existing services, and we have had no missed-case incidents since.

↩ Usually asked after

More in Core OOP

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me