AlgoMaster Logo

Runtime Polymorphism

Last Updated: May 17, 2026

10 min read

Runtime polymorphism is the version of polymorphism where the method that actually runs is decided when the program is executing, not when it's compiled. A variable typed as Notification can hold an EmailNotification today and a PushNotification tomorrow, and calling .Send() on it does the right thing for whichever object is actually in there. This chapter walks through what "runtime" dispatch really means, the mechanism that makes it work, and the patterns it unlocks for real e-commerce code.

What "Runtime" Actually Means

Every C# variable has two types attached to it. The static type (also called the declared type or compile-time type) is whatever you wrote on the left side of the declaration. The dynamic type (also called the runtime type or actual type) is the type of the object the variable currently points at on the heap. They're often the same, but they don't have to be.

n is declared as Notification, so the compiler treats it as a Notification everywhere in the source. The object it points to is actually an EmailNotification. When n.Send() runs, the runtime looks at the dynamic type, finds EmailNotification.Send, and calls that body. The compiler never had to know which subclass would show up. That is runtime polymorphism in one example.

The terms you'll hear for this behavior:

TermMeaning
Runtime polymorphismMethod is chosen while the program runs
Dynamic dispatchSame idea, focused on the call mechanism
Late bindingThe binding from "this call site" to "this method body" happens late, at execution time
Virtual dispatchThe C# specific name, because the mechanism uses virtual methods

All four phrases refer to the same thing. They get used interchangeably in books, docs, and interviews.

The opposite is static or early binding: the compiler decides the target method before the program runs. Non-virtual method calls work that way. Method overloading works that way. Method hiding with new works that way. Runtime polymorphism is specifically the case where the compiler emits a "look it up later" instruction and lets the runtime pick.

The Two-Keyword Recap: virtual and override

The _Method Overriding (virtual/override)_ lesson covered the syntax in detail, so this is the short version. A base method declared virtual opts into being overridable. A derived method declared override replaces the inherited body. The runtime dispatches calls to the most-derived override that matches the object's dynamic type.

The base method is virtual, the derived method is override, and that's the whole contract you need for polymorphism. The _virtual, override & new_ lesson compares virtual, override, and new side by side and unpacks why mixing them produces different dispatch behavior. For everything in this lesson, assume the standard virtual + override pair.

How Dispatch Actually Works

When the C# compiler sees a virtual call, it doesn't bake in a destination address for the method body. Instead, it emits the callvirt IL opcode, which tells the runtime: "look up the right body for this method on whatever object is on the stack, then call it."

The lookup uses a method table (sometimes called a vtable in other languages). Every type that participates in inheritance gets one. It's a small array of function pointers, one slot per virtual method. When a class overrides a method, its method table slot for that method points to the override's body. When a class doesn't override, its slot inherits the pointer from the parent.

A simplified diagram of how this looks for a small notification hierarchy:

The diagram shows the four steps the runtime takes for every virtual call. The first three are pure pointer arithmetic, fast but not free. The last is the actual jump into the chosen body. The whole thing happens in nanoseconds, but it is more work than a direct call, which is why virtual carries a small cost.

You don't have to remember the exact mechanism to use polymorphism well. What matters is the mental model: the object carries its own dispatch table, and callvirt always asks the object which body to run. The compiler is out of the picture once the call site is emitted.

Abstract Methods: Forcing Polymorphism

virtual provides a default body that derived classes may replace. `abstract` declares a method with no body that derived classes must implement. An abstract method can only live on an abstract class, which is a class that can't be instantiated directly.

A few things to notice. Notification has no body for Send; it declares the signature and leaves the implementation to subclasses. Trying to do new Notification(...) is a compile error (CS0144), which is exactly the point: there's no such thing as a "generic notification" you can send, only specific kinds. The constructor is protected instead of public because only derived classes ever call it, never outside code.

Abstract classes are how you enforce runtime polymorphism. With virtual, a derived class can forget to override a method and inherit the default behavior, which might or might not be appropriate. With abstract, the compiler refuses to compile a derived class that doesn't provide a body. If you have ten kinds of notification, every one of them is guaranteed to have its own Send.

KeywordHas body on the base?Derived class must override?Base class can be instantiated?
virtualYesNo (override is optional)Yes
abstractNoYes (or the derived class must also be abstract)No

An abstract class can mix abstract and non-abstract members. It's common to put shared logic in concrete methods on the base and leave only the genuinely variable parts as abstract.

Subclasses inherit LogApplied for free and only have to write Apply. This is the common shape of polymorphic designs: a base that owns the shared scaffolding, and a small abstract surface for the parts that vary.

Polymorphic Collections

The most useful pattern that runtime polymorphism enables is the polymorphic collection: one collection that holds objects of several related types, treated uniformly through a shared base. The notification system above is the canonical example.

Look at the loop. There's one line of code that calls channel.Send(), and four different method bodies run, picked by the runtime based on each object's dynamic type. The loop doesn't know which channels are in the list. It doesn't have a switch. It doesn't have any if statements checking types. The list could grow to ten different channels and the loop wouldn't change.

This is the payoff of polymorphism. The dispatch happens in one place (the callvirt instruction), and the variation lives in each subclass's Send body. Adding a new channel is a matter of writing a new class, not modifying the loop.

The pattern shows up everywhere in e-commerce code:

DomainBase typeSubclasses
Payment processingPaymentMethodCreditCardPayment, WalletPayment, GiftCardPayment
Discount calculationDiscountStrategyPercentageDiscount, FixedAmountDiscount, BuyOneGetOneDiscount
Order exportOrderExporterJsonOrderExporter, CsvOrderExporter, PdfInvoiceExporter
Shipping costShippingCalculatorFlatRateShipping, WeightBasedShipping, FreeShipping
Product displayProductCardBookCard, ClothingCard, ElectronicsCard

In every case, the calling code holds a reference to the base type and lets the runtime pick the right behavior. The list of subclasses can grow without the caller knowing.

A polymorphic dispatch diagram for the notification example, showing how the same loop reaches three different method bodies:

The single call site fans out to three bodies, one per object. The loop has no idea which body runs for any given iteration; it just trusts the runtime to pick.

Replacing if-else Chains with Polymorphism

A common shape in legacy code is a long if-else or switch chain that branches on a type tag. Polymorphism is the cleaner alternative, and seeing the before/after makes the value concrete.

Before: an order's shipping cost calculated by inspecting a ShippingType enum.

The function works, but every new shipping option requires editing both the enum and the CalculateShipping function. The branching logic for "what does each option do" lives in one place, far from the data each option needs.

After: the same behavior expressed with polymorphism.

Same result. Different structure. The branching disappeared, replaced by a single virtual call that picks the right Calculate body. To add a new shipping type (express, freight, in-store pickup), you write a new class and the existing code doesn't change.

The before/after differs in three ways worth naming:

AspectEnum + if-elsePolymorphism
Adding a new optionEdit enum + edit every switch/if-elseAdd a new subclass
Logic locationCentralized in one big functionDistributed, one body per subclass
Forgetting a caseSilent fallthrough or unreachable elseCompiler error (abstract method must be implemented)
Type safetyEnum value can be invalid at runtimeReference can only be a real subclass

Neither approach is universally better. switch (especially with C# pattern matching) is fine for a small, closed set of options that rarely changes. Polymorphism wins when the set is open, when each branch has substantial logic, or when the data each branch needs lives naturally with the branch.

Static Type Controls What You Can Call

Runtime polymorphism only kicks in when the call goes through a method that's visible on the static type. The static type still controls what method names the compiler will let you write.

The variable n has static type Notification. The compiler only knows about methods on Notification. Even though the object underneath has an AttachFile method, the compiler won't let you call it through an n reference. To reach it you'd either need a variable typed as EmailNotification, or a cast at the call site.

This is the same trade-off polymorphic collections always have. You give up subclass-specific methods at the call site in exchange for the freedom to add subclasses without changing the caller. The discipline is to design the base class so that the methods you need to call are on it.

Putting It Together: A Polymorphic Discount Engine

Bring all the pieces together with an e-commerce example: a discount engine that supports several strategies and applies them to a cart.

Three different discount calculations, all called through the same Apply method on the same loop. The base class provides shared scaffolding (Code, LogApplication), and each subclass owns its specific formula. Adding a new discount type (buy-one-get-one, tiered, loyalty-points-based) is a matter of writing a new subclass that implements Apply. Nothing in Main changes.

A diagram showing the polymorphic Apply call across the three discount types:

The loop sees one Apply method. The runtime sees three bodies and picks the right one for each iteration. This is the structure of a clean polymorphic design.

Pitfalls Worth Knowing

Runtime polymorphism is mostly straightforward, but a few situations trip people up.

Constructors don't dispatch the way you'd expect. If a base-class constructor calls a virtual method, the call goes to the derived class's override, even though the derived class's constructor hasn't run yet. The derived object's fields aren't initialized at that point, which can lead to confusing null reference exceptions.

The string is empty. The base constructor ran first, and during that constructor the override executed, but the _template field hadn't been assigned yet because the derived constructor hadn't started. The Microsoft guidance is straightforward: don't call virtual methods from constructors. Static analyzers will flag this.

Casting doesn't change dispatch for virtual calls. A cast changes the static type the compiler sees, but the runtime still uses the dynamic type for virtual dispatch.

Casting up to the base type is purely a compile-time view change for the reference. The object on the heap is unchanged, and callvirt always asks the object. The only way to "force" the base behavior in a virtual call is from inside an override, using base.Method().

Performance assumptions. Virtual calls are slightly slower than non-virtual calls, but the difference is rarely visible outside tight loops in performance-sensitive code. Don't pre-emptively make methods non-virtual to "optimize"; design for clarity first, measure later. When polymorphism is the right structure, the cost is part of the deal.

Forgetting that GetType() and typeof() are different. typeof(SomeType) is a compile-time expression that returns the Type object for the named type. someObject.GetType() is a runtime call that returns the dynamic type. Confusing the two leads to subtle bugs when you're trying to inspect what an object actually is.

For runtime polymorphism in general, you rarely need either of these; the whole point is that the runtime handles dispatch without your code asking "what is this?" If you find yourself calling GetType() and branching on the result, that's usually a sign to add another virtual method to the base class and let dispatch do the work.

Summary

  • Runtime polymorphism is the language feature where the method body that runs is chosen at execution time based on the object's actual (dynamic) type, not the variable's declared (static) type.
  • The mechanism uses the callvirt IL opcode and a per-type method table; the runtime looks up the method slot on the object's dynamic type and jumps to that body.
  • virtual provides a default body that subclasses may override. abstract provides no body and forces every concrete subclass to implement the method, turning forgotten overrides into compile errors.
  • Polymorphic collections (List<Notification> holding EmailNotification, SmsNotification, PushNotification) are the practical payoff: one call site, many bodies, picked at runtime.
  • Polymorphism is a clean alternative to long if-else or switch chains that branch on a type tag, especially when the set of variants is open or each variant carries substantial logic.
  • The static type of a variable still controls which method names the compiler will accept; the dynamic type controls which body actually runs once the call is allowed.
  • Don't call virtual methods from constructors. The derived override runs against an uninitialized derived object and can behave unpredictably.
  • Casting up to a base type changes how the compiler views the reference, not the object on the heap. Virtual dispatch still uses the dynamic type, so the cast doesn't change which body runs.