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

📚 Interview question

What are virtual methods in C#? Explain why members are non-virtual by default, how a virtual call is dispatched at run time, what it costs, when the JIT can devirtualise it, and what can and cannot be virtual.

Virtual members in C#: opt-in virtuality, method tables and slots, callvirt, performance cost, devirtualisation with sealed and PGO, and the rules on what can be virtual.

Mid-Level Theory Intermediate csharp devirtualisation dispatch jit oop performance virtual

🎯 Key points

  • A virtual member is resolved by the object's run-time type, not the variable's declared type
  • C# members are non-virtual by default because each virtual member is a contract with subclasses
  • Dispatch loads the object's MethodTable and calls through the method's slot
  • The compiler emits callvirt even for non-virtual instance calls, to get a null check
  • The main cost is lost inlining; the JIT devirtualises for sealed types, exact types and PGO-guarded calls
  • Seal classes by default in application code for clarity and performance
  • Private members, constructors, fields and static class members cannot be virtual; interfaces allow static virtual (C# 11)
  • Prefer a non-virtual public method calling a protected virtual hook

⏱ 30-second answer

A virtual member can be overridden by a derived class, and a call to it is resolved by the run-time type of the object, not the variable's type. In C#, members are non-virtual by default (unlike Java), because every virtual member is a promise to subclasses and is harder to change. At run time the call loads the object's method table, reads the method's slot and calls through it, which prevents inlining unless the JIT can devirtualise (exact type known, sealed class or method, or dynamic PGO's guarded devirtualisation). Methods, properties, indexers and events can be virtual; static (outside interfaces), private and constructors cannot.

🗣 Full answer (senior)

How I would answer this in an interview

A virtual method can be overridden in derived classes, and calls to it are dispatched at run time based on the object's actual type. In C#, members are non-virtual by default — a deliberate design choice. I'd explain why, how a virtual call works, what it costs, when the JIT removes that cost, and what can and can't be virtual.

1. Why non-virtual by default

C# (unlike Java, where methods are virtual by default) requires you to opt in with virtual. Anders Hejlsberg's reasons:

  • Versioning: every virtual method is a contract with unknown subclasses. If a base class calls a virtual method internally, changing that call later can break overrides (the fragile base class problem). Non-virtual by default limits that surface.
  • Performance: non-virtual calls can be inlined and are cheaper.
  • Intent: a class should be designed for extension deliberately, with documented extension points.

The override keyword is also explicit, and new is required to hide, so accidental overriding can't happen.

2. How a virtual call is dispatched

public class Shipping { public virtual decimal Cost(Order o) => 10m; }
public sealed class ExpressShipping : Shipping { public override decimal Cost(Order o) => 25m; }

Shipping s = new ExpressShipping();
s.Cost(order);   // 25 - chosen by the runtime type
  • Each type's MethodTable has a vtable (array of method pointers). An override puts the derived method's address in the same slot the base method had.
  • The call compiles to callvirt; the JIT emits: load the object's MethodTable → load the slot → indirect call.
  • C# uses callvirt even for non-virtual instance methods, because it null-checks the receiver.

3. What it costs

  • The dispatch itself: a couple of dependent memory loads and an indirect branch — typically ~1–2 ns, and well-predicted by modern CPUs when the type is consistent.
  • The bigger cost: no inlining. A small non-virtual method can be inlined and optimised with its caller (constant folding, loop vectorisation); a virtual one usually can't.
  • In tight loops over many elements with small methods (e.g. Area() on millions of shapes), that can be a noticeable difference. In typical business code (I/O-bound), it's irrelevant.

4. When the JIT devirtualises

The JIT turns a virtual call into a direct (and inlinable) call when it can prove the target:

  • The receiver's static type is sealed, or the method is a sealed override.
  • The object's exact type is known (created with new in the same method, or returned from an inlined factory).
  • Generic specialisation: value types in generic code with interface/virtual constraints.
  • Guarded devirtualisation via dynamic PGO (on by default since .NET 8): if profiling at tier 0 shows one type dominates at a call site, tier-1 code checks "is it that type?" and calls it directly (inlined), falling back to a virtual call otherwise.
  • Some well-known BCL patterns (EqualityComparer<T>.Default) are special-cased.

That's why sealing classes (analyser CA1852 for internal types) can yield free performance.

5. What can and cannot be virtual

Can be virtual Cannot be virtual
Instance methods Static members (but interfaces can have static virtual/static abstract members, C# 11)
Properties and indexers (accessors) Constructors
Events (accessors) Fields
Members of structs (structs can't be inherited; they can override object's virtuals)
Private members (must be accessible to derived classes)
Members of sealed classes (no point; compiler disallows declaring new virtuals there)

Also: abstract members are implicitly virtual; override members are virtual unless sealed.

6. A production example

In a rules engine evaluating millions of small Rule.Evaluate(context) calls per batch, profiling showed significant time in virtual dispatch overhead and no inlining. Sealing all concrete rule classes enabled guarded devirtualisation to work well at monomorphic sites, and restructuring the hottest path to evaluate rules grouped by type (so each loop was monomorphic) gave a ~30% throughput improvement without changing the design. Most of the codebase didn't need any of this — we only optimised the measured hot loop.

🔍 What the interviewer is checking

Checks the candidate's mental model of dynamic dispatch and its design impact.

Green flags: run-time versus static type; why C# is non-virtual by default; method table and slots; callvirt null-check detail; inlining cost; devirtualisation via sealed and PGO; knows what cannot be virtual; mentions the NVI idiom.

Red flags: "virtual calls are very slow" without nuance; virtual everything; cannot explain how the runtime chooses the method; thinks private methods can be overridden.

📚 Learn in depth

1. Vtable layout

Shipping MethodTable           ExpressShipping MethodTable
 slot 0: Object.ToString        slot 0: Object.ToString
 slot 1: Object.Equals          slot 1: Object.Equals
 slot 2: Object.GetHashCode     slot 2: Object.GetHashCode
 slot 3: Object.Finalize        slot 3: Object.Finalize
 slot 4: Shipping.Cost  ----->  slot 4: ExpressShipping.Cost   (override replaces slot)

New virtual methods in a derived class get new slots after the inherited ones. A new virtual (hiding) method also gets a new slot (newslot in IL), which is why calls through the base type still hit the base slot.

2. Machine code

; s.Cost(order) with s in rcx
mov  rax, qword ptr [rcx]          ; MethodTable*
mov  rax, qword ptr [rax+40h]      ; vtable chunk pointer
call qword ptr [rax+20h]           ; slot 4

CoreCLR splits vtables into chunks shared between parent and child where slots aren't overridden, saving memory — hence the two loads.

3. Guarded devirtualisation (GDV) example

With dynamic PGO, tier-0 code records which MethodTables appear at a call site. If one dominates, tier-1 emits:

if (obj->MethodTable == ExpressShipping_MT)
    result = 25m;                    // inlined body
else
    result = obj->vtable[4](obj, o); // fallback virtual call

The check is a single compare; the common path becomes inlined code. For megamorphic sites (many types), GDV may not apply. You can observe this with DOTNET_JitDisasm on tier-1 code, or BenchmarkDotNet's disassembler.

4. Virtual calls in constructors and the order of initialisation

Because the object's MethodTable is the most-derived type from the moment of allocation, virtual calls from a base constructor dispatch to derived overrides before the derived constructor runs. CA2214 warns about this; it's a correctness issue, not a performance one.

5. Interface methods, abstract methods and static virtual

  • Abstract methods: vtable slots without implementation in the base.
  • Interface methods: dispatched via virtual stub dispatch (per-call-site caches), not fixed vtable slots.
  • static virtual / static abstract interface members (C# 11): resolved per generic instantiation via constrained calls — no runtime dispatch for value types; used in generic math.

6. Designing virtual members

If you make a member virtual, you're promising:

  • which other members call it (self-use) and when,
  • what state it may rely on,
  • whether overrides must call base.

The Framework Design Guidelines advise: avoid virtual members unless there's a clear scenario; prefer protected virtual hooks called from non-virtual public methods (template method / "non-virtual interface" idiom), so the base keeps control of preconditions and postconditions.

Try it yourself

  1. Benchmark virtual vs non-virtual vs sealed calls of a tiny method in a loop.
  2. Inspect tier-1 disassembly for a call site that always sees one type, with dynamic PGO on and off (DOTNET_TieredPGO=0).
  3. Try declaring virtual on a static method, a field and a struct method and read the errors.
  4. Implement the non-virtual-interface idiom: public non-virtual method calling a protected virtual ...Core method.

💻 Code

public class Account
{
    public decimal Balance { get; protected set; }

    // Non-virtual public method, virtual protected hook (NVI idiom)
    public void ApplyMonthlyFee()
    {
        var fee = CalculateFee();
        if (fee < 0) throw new InvalidOperationException("Negative fee");
        Balance -= fee;
    }

    protected virtual decimal CalculateFee() => 5m;
    public override string ToString() => $"{GetType().Name}: {Balance:C}";
}

public sealed class StudentAccount : Account          // sealed: calls can devirtualise
{
    protected override decimal CalculateFee() => 0m;
}

public class PremiumAccount : Account
{
    protected override decimal CalculateFee() =>
        Balance > 10_000m ? 0m : base.CalculateFee() * 2;  // base call is non-virtual
}

// Rules the compiler enforces
public class Rules
{
    // private virtual void A() {}       // CS0621
    // public static virtual void B() {} // error: static virtual only in interfaces
    public virtual int Count { get; set; } // properties can be virtual
    public virtual event EventHandler? Changed;
}

⚠ Common mistakes

1. Making everything virtual "for flexibility"

What happens: all public methods are declared virtual. Why it is wrong: every virtual member becomes a fragile contract; subclasses can break invariants, and performance suffers. Do instead: make members virtual only for designed extension points.

2. Making members virtual just for mocking

What happens: methods are made virtual so Moq can override them. Why it is wrong: distorts the design for test tooling. Do instead: depend on interfaces at boundaries, or test real objects.

3. Expecting static members to be polymorphic

What happens: calling Derived.Create() expecting the base's static to dispatch. Why it is wrong: static members aren't virtual (except static abstract interface members via generics). Do instead: use instance members, factories, or static abstract members with generics.

4. Public virtual methods without a template

What happens: overrides skip validation or logging the base method did. Why it is wrong: cross-cutting behaviour is lost. Do instead: use non-virtual public methods calling protected virtual hooks.

5. Micro-optimising virtual calls everywhere

What happens: rewriting designs to avoid virtual calls in I/O-bound code. Why it is wrong: negligible gain, real design cost. Do instead: measure; optimise only hot, CPU-bound loops (sealing is the easy win).

6. Not sealing leaf classes

What happens: concrete classes are left unsealed. Why it is wrong: misses devirtualisation and invites unintended inheritance. Do instead: seal by default (CA1852).

7. Calling virtual members from constructors

What happens: base constructors call overridable members. Why it is wrong: overrides run before derived initialisation. Do instead: avoid (CA2214); use factories or explicit initialisation.

8. Confusing virtual with abstract

What happens: virtual methods with empty or throwing bodies to "force" overrides. Why it is wrong: subclasses can forget to override and get silent no-ops or runtime exceptions. Do instead: use abstract when every subclass must provide an implementation.

🏛 Senior insight: trade-offs

A senior engineer treats virtual as an API commitment and a performance detail at the same time.

Decision Senior default
New application class sealed unless designed for inheritance
Extension point protected virtual hook behind a non-virtual public method
Library base class Document call order and state for each virtual
Hot path with virtual calls Measure; seal leaf types; rely on PGO before rewriting
Need per-type static behaviour static abstract/static virtual in an interface plus generics

They can also explain the Java/C# difference and why C#'s choice reduces the fragile base class problem: fewer virtual members means fewer places where a base class change can break a subclass.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains virtual dispatch Why: Moves to override versus new and hiding bugs
    How to answer

    override replaces a base virtual, abstract or override member in the same vtable slot, so the derived version runs whatever the variable's type. new hides the base member with an unrelated one: which runs depends on the compile-time type, which is a common bug source; without either keyword you get warning CS0108 or CS0114. sealed override stops further overriding. base.Method() calls the parent version directly. Since C# 9 an override may return a more derived type (covariant returns). An override must keep the same name, parameters and accessibility. Default parameter values come from the static type, so differing defaults between base and override cause surprises.

  2. When: Candidate mentions devirtualisation Why: Checks sealing as a design and performance tool
    How to answer

    A sealed class cannot be inherited, and a sealed override cannot be overridden further. Sealing says "this type was not designed for extension", which protects its invariants. It also helps performance: the JIT can devirtualise and inline calls on sealed types, and type checks and casts to a sealed class are a single method-table comparison. Structs and static classes are already sealed. Analyser CA1852 suggests sealing internal types with no subtypes. The trade-off is that classic mocking libraries cannot mock sealed classes, which is a hint to depend on an interface at that seam. Unsealing later is non-breaking; sealing a public class later is breaking.

  3. When: Candidate warns about virtual calls in constructors Why: Probes initialisation order
    How to answer

    A constructor runs when an object is created and should leave it valid. If you declare none, the compiler adds a public parameterless one. Constructors can chain: : this(...) calls another constructor of the same class, : base(...) calls the base class's. For new Derived(), the order is: derived field initialisers, base field initialisers, base constructor body, derived constructor body. A static constructor runs once, before the type is first used, and is thread-safe. Primary constructors (C# 12) put parameters on the class declaration; they are captured as hidden fields, not properties, and are mutable. required members (C# 11) force callers to set them in an object initialiser. Calling a virtual method from a constructor runs the derived override before the derived constructor body has run, so it can see uninitialised state.

🛠 Real-world problems: how you handle them

Pricing engine speed-up by sealing leaf rule classes
Situation
A pricing engine applied [40] rule classes to [5 million] order lines in a nightly batch. The batch was getting close to its [two-hour] window.
Signals
A dotTrace profile showed many small `Rule.Apply` and `Rule.IsApplicable` frames and no inlining of trivial getters.
Investigation
Checked the hierarchy: all [38] concrete rules were leaves with no subclasses. None were sealed. Checked that tiered PGO was on (it is by default on .NET 10).
Resolution
Sealed every leaf rule and made `IsApplicable` a non-virtual check on data passed to the base constructor where possible. The JIT devirtualised and inlined the hot calls.
Prevention
Enabled analyser CA1852 (seal internal types) as a warning and added a BenchmarkDotNet test for the rule loop to the performance suite.

How to tell it (STAR)

**Situation:** our nightly pricing batch over [5 million] lines was close to its window. **Task:** find cheap wins without changing pricing logic. **Action:** profiling showed virtual call overhead in tiny rule methods. All concrete rules were leaves, so I sealed them and enabled CA1852 to keep them sealed. I measured before and after with BenchmarkDotNet. **Result:** the rule loop ran about [15%] faster, the batch gained [about 15 minutes] of margin, and there was zero behaviour change.

↩ Usually asked after

More in C# OOP Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me