Модификаторы virtual и override в C# и как это устроено внутри CLR

Идея статьи, как и в случае с многими другими, возникла после очередного вопроса на форумах MSDN. Хотя в сети, чаще всего, данный вопрос звучит так: "Разница между virtual и override в C#?", что не совсем правильно. А что делают эти два ключевых слова? Просто дают возможность реализации элементов принципа полиморфизма, одного из важнейших понятий ООП, в языке C#. Не будем вдаваться в детали данного принципа, хотя это очень важно, ибо тогда не ограничиться одной статьей. Для получения общего представления можно почитать тут (реальное же понимание сути приходит только со временем и опытом). Перейдём к рассмотрению конкретного примера. Есть следующий код:
using System;

namespace Test
{
  class Program
  {
    static void Main(string[] args)
    {
      ClassA objectA = new ClassB();
      objectA.Function();
    }
  }
  public class ClassA
  {
    public virtual void Function()
    {
      Console.WriteLine("Метод класса A");
    }
  }
  public class ClassB : ClassA
  {
    public void Function()
    {
      Console.WriteLine("Метод класса B");
    }
  }
}
А вопрос получается примерно такой: почему при вызове метода Function( ) для объекта objectA, происходит вызов метода класса ClassA, но не того же метода класса ClassB? Но, если подумать, назревает другой вопрос: а почему это должно было быть так, ведь мы имеем дело именно с объектом типа ClassA? Совершенно верный контраргумент, на первый вопрос, но с некоторой оговоркой. Ведь иначе, бы нарушились основные принципы ООП. Поэтому именно метод класса ClassA и должен вызываться. А в чём оговорка? В том, что у "медали две стороны". Т.е. от того, как бы мы хотели, чтобы вёл себя объект objectA, как ClassA или ClassB, так он и будет себя вести. Отсюда и вытекает основное понятие полиморфизма: возможность одного и того же объекта трактоваться по разному. Но мы пока не ответили на наш вопрос: почему вызывается метод класса ClassA, а не ClassB? Полиморфизм реализован не полностью. Да, мы создали объект типа ClassB, присвоив его ссылке типа ClassA:
A a = new B();
но не нужно забывать, что он так же является и ClassA (принцип наследования). Да, мы определили метод Function() класса ClassA, как виртуальный (virtual), но вот метод класса ClassB мы не переопределили, не применив к нему модификатор override. В этом и заключается не полная реализация. Т.е. сам по себе модификатор virtual особой роли не играет, без модификатора override, переопределяющего метод производного класса и тем самым меняя поведение объекта. Это значит, что даже если его не поставить, поведение объекта не изменится, при условии что метод производного класса не переопределён. Естественно, если метод не виртуальный, то ни о каком переопределении его в производном классе и речи не может быть. Теперь посмотрим как всё это устоено  внутри CLR. Для тех, кто хочет получить большее представление о платформе .NET и самой CLR, рекомендую книгу: "Common Intermediate Language и системное программирование в Microsoft .NET". И так, для вызова метода в языке CIL среды CLR, есть три инструкции: call, calli и callvirt. Последняя инструкция реализует принцип позднего связывания. То есть, говоря простым языком, решение о вызове определённого метода принимается во время выполнения, а не компиляции. Тем самым реализуется идея полиморфизма. Отсюда вывод, что инструкция callvirt применяется только для вызова методов экземпляра (которые могут быть или не быть виртуальными), но не методов типа. Посмотрим это на следующем примере кода:
using System;

namespace ConsoleApplication
{
  class Program
  {
    static void Main(string[] args)
    {
      ClassA objectA = new ClassB();
      objectA.Function();
      objectA = new ClassC();
      objectA.Function();
      ClassB objectB = (ClassB)objectA;
      objectB.Function();
    }
    public class ClassA
    {
      public virtual void Function()
      {
        Console.WriteLine("A");
      }
    }
    public class ClassB : ClassA
    {
      public virtual void Function()
      {
        Console.WriteLine("B");
      }
    }
    public class ClassC : ClassB
    {
      public override void Function()
      {
        Console.WriteLine("C");
      }
    }
  }
}
Рассмотрим код CIL метода Main. Для этого воспользуемся дизасемблером IL DASM.
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       42 (0x2a)
  .maxstack  1
  .locals init ([0] class ConsoleApplication.Program/ClassA objectA,
           [1] class ConsoleApplication.Program/ClassB objectB)
  IL_0000:  nop
  IL_0001:  newobj     instance void ConsoleApplication.Program/ClassB::.ctor()
  IL_0006:  stloc.0
  IL_0007:  ldloc.0
  IL_0008:  callvirt   instance void ConsoleApplication.Program/ClassA::Function()
  IL_000d:  nop
  IL_000e:  newobj     instance void ConsoleApplication.Program/ClassC::.ctor()
  IL_0013:  stloc.0
  IL_0014:  ldloc.0
  IL_0015:  callvirt   instance void ConsoleApplication.Program/ClassA::Function()
  IL_001a:  nop
  IL_001b:  ldloc.0
  IL_001c:  castclass  ConsoleApplication.Program/ClassB
  IL_0021:  stloc.1
  IL_0022:  ldloc.1
  IL_0023:  callvirt   instance void ConsoleApplication.Program/ClassB::Function()
  IL_0028:  nop
  IL_0029:  ret
} // end of method Program::Main
Для вызова
ClassA objectA = new ClassB();
objectA.Function();
как и для вызова
objectA = new ClassC();
objectA.Function();
используется одна и та же инструкция - callvirt. Но почему тогда в первом случае вызывается Function() класса ClassA, а во втором Function() ClassC? Всё дело в метаданных которые содержатся в оределении методов. Вот какие метаданные у данных типов (чтобы увидеть метаданные типов, и не только их, надо нажать комбинацию клавиш Ctrl+M или Menu->View->MetaInfo->Show!):
TypDefName: ClassA  (02000003)
	Flags     : [NestedPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100002)
	Extends   : 01000001 [TypeRef] System.Object
	EnclosingClass : ConsoleApplication.Program (02000002)
	Method #1 (06000003) 
	-------------------------------------------------------
		MethodName: Function (06000003)
		Flags     : [Public] [Virtual] [HideBySig] [NewSlot]  (000001c6)
		RVA       : 0x0000208e
		ImplFlags : [IL] [Managed]  (00000000)
		CallCnvntn: [DEFAULT]
		hasThis 
		ReturnType: Void
		No arguments.

TypDefName: ClassB  (02000004)
	Flags     : [NestedPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100002)
	Extends   : 02000003 [TypeDef] ClassA
	EnclosingClass : ConsoleApplication.Program (02000002)
	Method #1 (06000005) 
	-------------------------------------------------------
		MethodName: Function (06000005)
		Flags     : [Public] [Virtual] [HideBySig] [NewSlot]  (000001c6)
		RVA       : 0x000020a4
		ImplFlags : [IL] [Managed]  (00000000)
		CallCnvntn: [DEFAULT]
		hasThis 
		ReturnType: Void
		No arguments.

TypDefName: ClassC  (02000005)
	Flags     : [NestedPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100002)
	Extends   : 02000004 [TypeDef] ClassB
	EnclosingClass : ConsoleApplication.Program (02000002)
	Method #1 (06000007) 
	-------------------------------------------------------
		MethodName: Function (06000007)
		Flags     : [Public] [Virtual] [HideBySig] [ReuseSlot]  (000000c6)
		RVA       : 0x000020ba
		ImplFlags : [IL] [Managed]  (00000000)
		CallCnvntn: [DEFAULT]
		hasThis 
		ReturnType: Void
		No arguments.
если посмотреть внимательно, то можно заметить, что разница у них во флагах (для экономии места показаны только нужные куски). Методы помеченные модификатором virtual, при компиляции в CIL компилятором C#, помечаются флагом NewSlot, а методы которые переопределяются флагом - ReuseSlot (о флагах чуть позже).  В данном случае это методы Function() классов: ClassA и ClassB. Т.е. получается, что повторно применяя модификатор virtual к классу-наследнику, коим является ClassB, мы скрываем метод базового класса. Поэтому, при выполнении кода метода Main
static void Main(string[] args)
{
  ClassA objectA = new ClassB();
  objectA.Function();
  objectA = new ClassC();
  objectA.Function();
  ClassB objectB = (ClassB)objectA;
  objectB.Function();
}
получим следующую последовательность вызовов:
Метод класса A
Метод класса A
Метод класса C
Немножко странная последовательность вызовов получилась на первый взгляд, правда. Но если подумать, то всё именно так и должно быть. При первом вызове, вызывается метод класса ClassA на объекте типа ClassB, так как метод последнего мы явно скрыли применив модификатор virtual  к нему повторно. Второй вызов вроде должен был быть метод класса ClassС, но нет. Дело в том, что в ClassC мы переопределяем метод класса ClassB. И вот поэтому, последний вызов и есть вызов метода класса ClassC. Теперь о наших флагах. Что это за флаги? Они используются средой CLR для создания таблицы методов объекта при создании последних. Таблица методов - эта некая структура данных, которая создаётся при загрузке класса в домене приложения, перед созданием первого экземпляра объекта этого типа. Чтобы увидеть таблицу методов нужно использовать расширения отладки SOS (их использование тема отдельной статьи). Запускаем код в режиме отладки выполнив до инструкции выхода из метода Main.



Вот наша таблица методов для объекта objectA = new ClassC() (это часть содержимого окна итерпрететора, привёл для большей наглядности, и естественно адреса в памяти при каждом запуске программы будут разными)
!dumpheap -type ClassC
 Address       MT     Size
026744c0 00283904       12     

Statistics:
      MT    Count    TotalSize Class Name
00283904        1           12 ConsoleApplication.Program+ClassC
Total 1 objects

!DumpMT -MD 00283904
EEClass:         002813a4
Module:          00282e94
Name:            ConsoleApplication.Program+ClassC
mdToken:         02000005
File:            C:\Users\Yatajga\Documents\Visual Studio 2012\Projects
\ConsoleApplication\ConsoleApplication\bin\Debug\ConsoleApplication.exe
BaseSize:        0xc
ComponentSize:   0x0
Slots in VTable: 7
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
   Entry MethodDe    JIT Name
5FBA4960 5F8A6728 PreJIT System.Object.ToString()
5FB98790 5F8A6730 PreJIT System.Object.Equals(System.Object)
5FB98360 5F8A6750 PreJIT System.Object.GetHashCode()
5FB916F0 5F8A6764 PreJIT System.Object.Finalize()
0028C030 00283808    JIT ConsoleApplication.Program+ClassA.Function()
0028C070 002838EC    JIT ConsoleApplication.Program+ClassC.Function()
0028C078 002838F8    JIT ConsoleApplication.Program+ClassC..ctor()
Как видно по таблице дескрипторов методов (MethodDesc Table), которая отображается на слоты таблицы методов, слот с определением метода
ConsoleApplication.Program+ClassB.Function() 
класаа ClassB отсуствует. Поэтому ничего удивительно в порядке высшепоказанных вызовов нет. В частности, при вызове objectB.Function() вызывается метод  ClassС, ввиду отсутствия его в таблице методов. Так в итоге получаем следующее: переопределенный метод в C#, ключевым словом oveerride, после компиляции в CIL код помечается атрибутом ReuseSlot. Во время выполнения на основании этих метаданных, строится таблица методов объекта. Если метод базового класса переопределён, для него не выделяется новый слот, а замещается методом производного класса, в результате будет вызван метод производного класса. А если никакого переопределения нет, то все методы будут на месте (в таблице методов) и в случае вызова метода с той же сигнатурой и именем будет вызываться именно тот, который соответствует текущему типу объекта.  На самом деле, этот механиз намного сложнен, просто я постарался описать его как можно проще и понятнее. Надеюсь, что получилось.