C# Virtual Method Dispatch Explained
c# virtual method dispatch: Understand how C# virtual method dispatch works at runtime, its performance cost, and when to use virtual methods effectively.
When you call a virtual method in C#, the compiler does not emit a direct call to a specific method body. Instead, it emits a callvirt instruction that asks the runtime to resolve the target method based on the actual type of the object. This is the essence of C# virtual method dispatch. The decision happens at runtime, not compile time, which enables polymorphism but also introduces a small performance cost and some design constraints.
What Virtual Method Dispatch Does at Runtime
Virtual dispatch is the mechanism that lets a derived class replace a base class implementation while preserving the same method signature. When you declare a method as virtual in a base class and override it in a derived class, the runtime maintains a method table (often called a vtable) for each type. Each entry in that table points to the actual method implementation that should be called for an instance of that type.
Consider this minimal example:
public class Shape { public virtual double Area() => 0; } public class Circle : Shape { private readonly double _radius; public Circle(double radius) => _radius = radius; public override double Area() => Math.PI * _radius * _radius; }
When you call shape.Area() on a variable typed as Shape, the runtime looks at the object's actual type, finds the Area entry in that type's method table, and invokes the correct implementation. If the object is a Circle, it calls Circle.Area; if it is a Shape, it calls the base implementation.
The dispatch happens at the call site. The JIT compiler generates code that reads the method table pointer from the object, indexes into it using the slot assigned to Area, and calls the method. This indirection is what makes virtual calls slightly slower than non-virtual calls, which can be called directly.
Declaring Virtual and Override Members
The virtual keyword can be applied to methods, properties, events, and indexers. The override keyword is used in a derived class to provide a new implementation. The signatures must match exactly, and the access level cannot be more restrictive than the base method.
public class Base { public virtual void Log(string message) { Console.WriteLine($"Base: {message}"); } } public class Derived : Base { public override void Log(string message) { Console.WriteLine($"Derived: {message}"); } }
If a derived class does not override a virtual method, it inherits the base implementation. You can also declare a method as sealed override to prevent further overrides in subclasses:
public class FurtherDerived : Derived { public sealed override void Log(string message) { Console.WriteLine($"FurtherDerived: {message}"); } }
This is useful when you want to lock in a behavior and avoid accidental changes in deeper inheritance chains.
How the Compiler and Runtime Decide the Target
The C# compiler assigns a slot number to each virtual method in a type hierarchy. When a derived class overrides a method, it reuses the same slot. If a derived class introduces a new virtual method, it gets a new slot. At runtime, the object's method table is a contiguous array of function pointers. The callvirt instruction takes the object reference, loads the method table, and indexes by the slot number.
This design means that virtual dispatch is not a search by name. It is a fixed-offset lookup, which is why it is relatively fast. The cost comes from the extra indirection and the fact that the JIT compiler often cannot inline virtual calls because the target is unknown until runtime.
For interface methods, the mechanism is slightly different. Each interface method has its own slot in the implementing type's interface map. When you call an interface method, the runtime uses the interface's dispatch map to find the corresponding implementation. This adds another layer of indirection compared to a direct virtual call on a class.
Performance Considerations
Virtual dispatch is not free, but its cost is usually negligible unless the method is called in a tight loop or is extremely small. The main overhead is the indirect call and the prevention of inlining. Inlining is a JIT optimization where the method body is copied into the caller to eliminate the call overhead. For non-virtual methods, the JIT can often inline them safely. For virtual methods, it can only inline if it can prove the actual type at the call site, which is rarely possible.
Consider a scenario where a virtual property is accessed millions of times:
public abstract class Sensor { public abstract double Value { get; } } public class TemperatureSensor : Sensor { private double _value; public override double Value => _value; } // Called frequently in a loop foreach (var sensor in sensors) { total += sensor.Value; }
Each sensor.Value access performs a virtual dispatch. The JIT cannot inline Value because it does not know the concrete type. In contrast, if Value were non-virtual, the JIT could inline the property getter directly. In performance-critical code, you can sometimes avoid virtual dispatch by using generics with constraints, or by restructuring the code to use a non-virtual method that internally calls a virtual one less frequently.
Modern .NET runtimes include devirtualization heuristics. The JIT may guess the most likely target and insert a fast check before falling back to the full dispatch. This works best when a call site consistently sees the same concrete type. However, the optimization is not guaranteed, and its effectiveness depends on the runtime version and the actual usage pattern.
When to Use Virtual Methods and When to Avoid Them
Virtual methods are a core tool for polymorphism, but they are not always the right choice. Use them when you have a base class that defines a contract and you expect derived classes to provide specialized behavior. This is common in frameworks, plugins, and template method patterns.
Avoid virtual methods when:
- The method is called extremely frequently and the dispatch overhead matters.
- The class is not designed for inheritance, and you want to prevent unexpected overrides.
- You need to maintain strict control over the behavior of a method across all subclasses.
In many cases, interfaces are a better abstraction than virtual methods. Interfaces allow a type to participate in multiple contracts and are the natural fit for decoupling. However, interface method calls also involve dispatch, and they may be slightly slower than class virtual calls because of the interface map lookup.
Another alternative is the strategy pattern, where you pass behavior as a delegate or a separate strategy object. Delegates also incur an indirect call, but they are more flexible because they do not require inheritance. If you only need to override a single method, a delegate might be simpler than a full subclass.
Common Pitfalls with Virtual Dispatch
One classic mistake is calling a virtual method from a base class constructor. When the base constructor runs, the derived class constructor has not yet executed. If the virtual method is overridden in the derived class, the runtime will call the derived override, but the derived fields may not be initialized yet. This can lead to null references or default values where you expect meaningful data.
public class Base { public Base() { Initialize(); // virtual call } protected virtual void Initialize() { } } public class Derived : Base { private readonly string _name; public Derived(string name) { _name = name; } protected override void Initialize() { Console.WriteLine(_name.Length); // _name is null here! } }
When new Derived("test") is executed, the base constructor calls Initialize(), which dispatches to Derived.Initialize. At that point, _name has not been assigned because the derived constructor body has not run. The result is a NullReferenceException. The fix is to avoid virtual calls in constructors or to initialize fields before the base constructor runs, which is not possible in C#. A safer pattern is to provide an explicit Initialize method that the derived class can call after construction.
Another pitfall is assuming that sealed on a class prevents virtual dispatch. Sealing a class prevents inheritance, but if the class inherits a virtual method from a base class, that method is still virtual and will be dispatched virtually. You can only stop further overrides by marking the override as sealed override.
Virtual Dispatch with Interfaces and Abstract Classes
Abstract classes and interfaces both enable polymorphism, but they dispatch differently. An abstract class can contain virtual methods with implementations, and derived classes override them. Interfaces, on the other hand, declare method signatures that implementing types must provide. When you call an interface method, the runtime uses the type's interface map to find the implementation.
Default interface methods, introduced in C# 8, allow interfaces to provide a default implementation. When a class does not override a default interface method, the runtime uses the interface's default. This adds another layer of dispatch because the runtime must decide whether the class has its own implementation or should fall back to the interface default.
Here is an example with an interface and a class:
public interface ILogger { void Log(string message); } public class ConsoleLogger : ILogger { public void Log(string message) => Console.WriteLine(message); }
Calling ILogger.Log on a ConsoleLogger instance requires an interface dispatch. If you call the method through a concrete ConsoleLogger reference, the compiler can emit a direct call, avoiding dispatch entirely. This is a common optimization: store the object in a concrete type when you do not need polymorphic behavior.
Observing Dispatch Behavior in IL
You can inspect the IL generated for a virtual call using a tool like ildasm or the IL viewer in your IDE. A virtual call appears as the callvirt instruction, while a non-virtual call uses call. For example, the following code:
Shape shape = new Circle(5); double area = shape.Area();
produces IL that includes callvirt instance float64 Shape::Area(). If you change the variable type to Circle, the compiler emits call because the method is known to be non-virtual on Circle (unless it is overridden in a further derived class, but the compiler sees the concrete type and can emit a direct call).
Understanding this distinction helps you reason about where dispatch actually occurs. It also explains why casting to a concrete type can improve performance: it removes the need for virtual dispatch when the concrete type is known.
In practice, the JIT may still optimize a callvirt to a direct call if it can prove the target is sealed or if it performs devirtualization. But you should not rely on that behavior across all runtimes. The safest performance advice is to avoid unnecessary virtual calls in hot paths and to prefer concrete types when polymorphic behavior is not needed.