網路上關於 C# virtual/override/new 的語法討論眾多,卻少從 CLR 執行層面說明。本文以 SOS 除錯器、IL 反組譯、MethodTable 結構為證,揭示:所有實例方法呼叫均為 callvirt,唯基類呼叫用 call;override 本質是子類 MethodTable 覆寫虛方法槽位;介面呼叫透過全域介面映射表分派。
// C#
A a = new A(); a.Foo(); a.Boo();
A b = new B(); b.Foo(); b.Boo();
// IL:皆為 callvirt
IL_0007: callvirt instance void A::Foo()
IL_000d: callvirt instance void A::Boo()
IL_0019: callvirt instance void A::Foo()
IL_001f: callvirt instance void A::Boo()
// B.Foo() 覆寫並呼叫 base.Foo()
IL_000b: call instance void A::Foo() // 唯一 call,防無限遞迴
結論:編譯器只看**靜態型別**(變數宣告型別)產生 callvirt,JIT 時才依物件真實 MethodTable 解析槽位位址。非虛方法同樣用 callvirt,JIT 內部轉為直接 call 並插入 null 檢查(cmp dword ptr [ecx], ecx)。
物件前 4 bytes 指向 MethodTable。!dumpmt -md 顯示:
// A 的 MethodTable
...
00973148 009730d8 JIT A.Foo() // 第 5 槽位
00973138 009730d0 JIT A.Boo()
// B 的 MethodTable
...
009731d0 00973178 NONE B.Foo() // 同樣第 5 槽位,覆蓋 A.Foo
009731e0 00973180 JIT B..ctor()
override 只是將父類虛方法槽位的位址替換為子類實作。若子類用 new virtual,則在父類虛方法之後**新增**槽位。
a.Foo(): mov ecx, edi // this
mov eax, [ecx] // MethodTable
call [eax+38h] // 偏移 0x38 = 第 5 槽位 * 8
b.Foo(): mov ecx, ebx
mov eax, [ecx]
call [eax+38h] // 相同偏移,但指向 B.Foo()
非虛方法 Boo():
mov ecx, edi
cmp [ecx], ecx // null 檢查
call 0xFF9430C8 // 直接呼叫固定位址
介面方法不在類別 MethodTable 固定槽位,CLR 維護 **Global Interface Map Table**,依 (介面型別, 實作類別) 查找實際 MethodDesc。範例:
interface IC { void Test(); }
class C : IC { public void Test() { ... } } // IL: virtual final
class D : C { void Test() { ... } } // new slot, non-virtual
IC ic = new D(); ic.Test(); // 輸出 "C::Test()"
因 C.Test 為 final,D.Test 不進介面映射表。若 D 也實作 IC 或 C.Test 移除 final,則 D.Test 會登入映射表並被呼叫。
callvirt + MethodTable 槽位 = 虛分派核心;override = 槽位覆寫;new = 新增槽位;base.Foo() 必須用 call 避免遞迴。