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.
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.
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.
C# (unlike Java, where methods are virtual by default) requires you to opt in with virtual. Anders Hejlsberg's reasons:
The override keyword is also explicit, and new is required to hide, so accidental overriding can't happen.
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
callvirt; the JIT emits: load the object's MethodTable → load the slot → indirect call.callvirt even for non-virtual instance methods, because it null-checks the receiver.Area() on millions of shapes), that can be a noticeable difference. In typical business code (I/O-bound), it's irrelevant.The JIT turns a virtual call into a direct (and inlinable) call when it can prove the target:
sealed override.new in the same method, or returned from an inlined factory).EqualityComparer<T>.Default) are special-cased.That's why sealing classes (analyser CA1852 for internal types) can yield free performance.
| 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.
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.
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.
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.
; 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.
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.
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.
static virtualstatic virtual / static abstract interface members (C# 11): resolved per generic instantiation via constrained calls — no runtime dispatch for value types; used in generic math.If you make a member virtual, you're promising:
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.
DOTNET_TieredPGO=0).virtual on a static method, a field and a struct method and read the errors....Core method.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;
}
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.
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.
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.
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.
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).
What happens: concrete classes are left unsealed. Why it is wrong: misses devirtualisation and invites unintended inheritance. Do instead: seal by default (CA1852).
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.
virtual with abstractWhat 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.
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.
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.
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.
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.
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.
Last updated 30 Oct 2026.