A Deep Dive into C# Polymorphism#
In C#, polymorphism is often summed up as “the right code is executed for the right object”. But for a seasoned developer, it is crucial to understand the internal mechanics: how does the CLR (Common Language Runtime) decide which method to call?
This article breaks down the difference between a static call and a dynamic call via Vtables.
1. The setup: The code#
Let’s take a base class Animal and two implementations: Dog and Cat.
We have two types of methods:
SayHello(): A classic (non-virtual) method that we will hide withnewin the children.Speak(): A polymorphic method (virtual/override).
using System;
using System.Collections.Generic;
public class Animal
{
// NON-VIRTUAL method: the address is resolved at compile time
public void SayHello()
{
Console.WriteLine("The animal greets you (base method).");
}
// VIRTUAL method: the address is resolved at runtime (vtable)
public virtual void Speak()
{
Console.WriteLine("...");
}
}
public class Dog : Animal
{
// Shadowing: this method exists but does not override the parent's
public new void SayHello()
{
Console.WriteLine("The dog greets you.");
}
public override void Speak()
{
Console.WriteLine("Woof!");
}
}
public class Cat : Animal
{
// Shadowing
public new void SayHello()
{
Console.WriteLine("The cat greets you.");
}
public override void Speak()
{
Console.WriteLine("Meow!");
}
}
class Program
{
static void Main()
{
// We store children in an array of parents
// The declared type of the array is Animal[]
Animal[] myAnimals = [ new Dog(), new Cat() ];
// The objects in myAnimals are of type Animal (Dog or Cat)
foreach (Animal animal in myAnimals)
{
Console.WriteLine($"--- Actual instance: {animal.GetType().Name} ---");
// Case 1: Static Binding (Non-Virtual Call)
// The compiler looks at the type of the variable 'animal'
animal.SayHello();
// Case 2: Dynamic Binding (Virtual Call)
// The runtime looks at the type of the object in memory
animal.Speak();
Console.WriteLine();
}
}
}2. Case 1: Non-Virtual Call (SayHello)#
When the compiler encounters the line animal.SayHello(), it analyzes the type of the variable animal.
- The variable is of type
Animal. - The
SayHellomethod is not virtual. - The compiler’s conclusion: “I know exactly which method to call. It’s
Animal.SayHello. No matter what is actually in memory, the version of the code to execute is the one from theAnimalclass.”
It doesn’t matter that the Cat and Dog classes define a new version of the SayHello method (with new); it is indeed the base class Animal’s one that is called, because the destination address is set in stone at compile time.
Step-by-step execution (Static Binding)#
- Compilation: The C# compiler generates an IL instruction that explicitly designates the
Animal.SayHellomethod. When the JIT translates it into machine code, this instruction is turned into a direct jump to the memory address of the code, without going through any table. - Execution: The processor jumps directly to that address.
- Result: Even if the object is a
Dog, it is theAnimal’s method that runs. TheDog.SayHellomethod is completely ignored.
3. Case 2: Virtual Call (Speak) and the Vtable#
When the compiler encounters animal.Speak(), it detects the virtual keyword. It then understands that it cannot freeze the method’s address at compile time, because the variable animal could point to any derived instance (a Cat, a Dog, and so on) at execution time.
Instead of writing a direct jump to a fixed code address (as for SayHello), the compiler sets up a dynamic resolution mechanism. It asks the runtime to go and find the right method based on the actual object in memory. This is where the Vtable comes into play.
Memory layout of a .NET object#
Every object on the Heap has a hidden header containing a TypeHandle. This pointer leads to the MethodTable (the class’s identity card). This table contains the Vtable (Virtual Method Table): an array of pointers to the methods.
flowchart LR
%% Style definitions to distinguish the zones
classDef heapObject fill:#e3f2fd,stroke:#1565c0,stroke-width:2px,color:black;
classDef metaData fill:#fff3e0,stroke:#ef6c00,stroke-width:2px,color:black;
classDef invisible fill:none,stroke:none;
%% ZONE 1: THE HEAP (where instances live)
subgraph HEAP ["<b>GC Heap (Managed Heap)</b>"]
direction TB
%% Instance 1
dog1["<b>Instance: Dog #1</b><br/>-----------------------<br/>Header: <b>TypeHandle</b> ⏺<br/>Data: Age = 5"]:::heapObject
%% Instance 2
dog2["<b>Instance: Dog #2</b><br/>-----------------------<br/>Header: <b>TypeHandle</b> ⏺<br/>Data: Age = 3"]:::heapObject
end
%% ZONE 2: THE LOADER HEAP (where types live)
subgraph LOADER ["<b>Loader Heap (Metadata)</b>"]
direction TB
%% The single MethodTable
mtDog["<b>MethodTable (Dog)</b><br/>-----------------------<br/>GC info <br/>Implemented interfaces<br/>Instance size<br/>...<br/><b>VTABLE (Method list)</b><br/><i>[Slot 1] Animal.ToString</i><br/><i>[Slot 2] Dog.Speak</i>"]:::metaData
end
%% RELATIONS (the pointers)
%% Arrows go from the objects to the shared table
dog1 -.-> mtDog
dog2 -.-> mtDog
%% Explanatory legend on the link
linkStyle 0,1 stroke:#1565c0,stroke-width:2px,dasharray: 5 5;Step-by-step execution (Dynamic Binding)#
Let’s take the first loop iteration, where the object is a Dog.
- Dereferencing: The runtime follows the
animalreference to find the object on the Heap. - Inspection (TypeHandle): It reads the object’s header and understands: “This is an instance of
Dog”. It goes and consultsDog’s MethodTable. - Lookup in the Vtable:
- The
Speakmethod occupies a fixed index (say slot #4) in theAnimalhierarchy. - The runtime looks at slot #4 of
Dog’s table. - Since
Dogperformed anoverride, the address stored in that slot is that ofDog.Speak(and notAnimal.Speak).
- The
- Jump (Indirection): The runtime retrieves that address (for example
0x3000) and executes the code.
How it works (diagram)#
The diagram below illustrates the fundamental difference. Notice how Dog.SayHello (Address D) does exist, but is bypassed by the red arrow.
flowchart TD
%% --- STACK ---
subgraph STACK ["Stack"]
direction TB
varRef["var animal<br/>(Type: Animal)"]
end
%% --- HEAP ---
subgraph HEAP ["Heap"]
direction TB
objDog["Instance: Dog<br/>Header: Ptr to Dog VTable"]
end
%% --- METADATA ---
subgraph METADATA ["Vtables (Metadata)"]
direction TB
mtAnimal["<b>Animal VTable</b><br/>SayHello: @Addr_A<br/>Speak: @Addr_B"]
mtDog["<b>Dog VTable</b><br/>(Inherits from Animal)<br/>SayHello: @Addr_A<br/>SayHello (New): @Addr_D<br/>Speak (Override): @Addr_C"]
end
%% --- CODE ---
subgraph CODE ["Methods"]
direction TB
codeAnimal["@Addr_A<br/>Animal.SayHello()<br/>'The animal greets...'"]
codeDogNew["@Addr_D<br/>Dog.SayHello()<br/>'The dog greets...'"]
codeAnimalBase["@Addr_B<br/>Animal.Speak()<br/>'...'"]
codeDog["@Addr_C<br/>Dog.Speak()<br/>'Woof!'"]
end
%% --- RELATIONS ---
%% The declaration order defines the indices for linkStyle (0, 1, 2...)
%% 0. Link var -> object
varRef -- "Points to" --> objDog
%% 1. Link object -> vtable
objDog -. "TypeHandle" .-> mtDog
%% 2. RED CASE (Index 2)
varRef -- "(1) Static Call<br/>SayHello()" --> codeAnimal
%% 3. BLUE CASE PART 1 (Index 3)
varRef -- "(2) Virtual Call<br/>Speak()" --> mtDog
%% 4. BLUE CASE PART 2 (Index 4)
mtDog -- "(3) Vtable Lookup<br/>Finds @Addr_C" --> codeDog
%% Structural links (grey)
mtDog -.-> codeDogNew
mtAnimal -.-> codeAnimal
mtAnimal -.-> codeAnimalBase
%% STYLES
%% Red for the static call (Index 2)
linkStyle 2 stroke:red,stroke-width:3px,color:red;
%% Blue for the dynamic path (Index 3 and 4)
linkStyle 3,4 stroke:blue,stroke-width:3px,color:blue;Diagram legend
- Red Arrow (Static Binding): The path is direct. The compiler saw the variable of type
Animaland wired the call straight to theAnimal.SayHellocode (@Addr_A). It completely ignores the fact that the object is a Dog and that theDog.SayHellomethod (@Addr_D) exists. - Blue Arrow (Dynamic Binding): The path goes through the Vtable. We start from the object -> we go and look at its Vtable (that of the
Dogclass) -> we retrieve the specific address in theSpeakslot -> we execute theDog.Speakcode (@Addr_C).
Summary#
- Non-virtual (
newkeyword): The method is determined by the type of the variable. It is fast, rigid, and it can lead to calling the parent’s method even if the child has a “new” one. - Virtual (
overridekeyword): The method is determined by the type of the object in memory through an indirection (Vtable). It is flexible and guarantees polymorphic behavior.
