在csdn論壇、博客園裡都有很多帖子討論c#中繼承語法的問題,大家樂此不疲的解釋 virtual,override,new,final,接口,類中的繼承。各種各樣的例子讓新手頭暈腦脹 ,這其中還一些地方以訛傳訛。
比如這篇文章裡面竟然說“編譯器會順著繼承鏈往下找,一直找到合適的那個方法體 ”,在回復裡還有人說“這個特征特現了C#編譯器對裡氏代換原則的支持。也就是:凡是 基類適用的地方子類一定適用。
比如class Base {},class Sub:base {} 如果:Base a=new Sub();那麼實際上編譯 器已經能夠確定a所指向的對象的類型。所以方法的地址確實是在編譯期就確定了的。” 。我真是無語了,override的實現本質上是非常簡單的,但由於每次都是從語法的角度討 論問題所以總是不得要領。
所以我這篇帖子的將從底層實現的角度來向你說明override的實現。首先讓我們明確2 個概念:
1.類實例的方法調用都是虛調用(callvirt)只有在調用基類方法時是實調用(call )。請看代碼:
1. class A
2. {
3. public void Boo()
4. {
5. Console.WriteLine("A::Boo().");
6. }
7. public virtual void Foo()
8. {
9. Console.WriteLine("A::Foo().");
10. }
11. }
12. class B : A
13. {
14. public override void Foo()
15. {
16. Console.WriteLine("B::Foo().");
17. base.Foo();
18. }
19. }
20. class Class1
21. {
22. public static void Main()
23. {
24. A a = new A();
25. a.Foo();
26. a.Boo();
27. A b = new B();
28. a.Foo();
29. a.Boo();
30. }
31. }
讓我看一下對應的IL代碼片段
1. //main函數,調用實例函數的IL
2. //a.Foo();
3. IL_0007: callvirt instance void test_console.A::Foo()
4. //a.Boo();
5. IL_000d: callvirt instance void test_console.A::Boo()
6. //b.Foo();
7. IL_0019: callvirt instance void test_console.A::Foo()
8. //b.Boo ();
9. IL_001f: callvirt instance void test_console.A::Boo()
10.
11. //B類中Foo函數
12. IL_0000: ldstr "B::Foo()."
13. IL_0005: call void [mscorlib]System.Console::WriteLine(string)
14. IL_000a: ldarg.0
15. //base.Foo();
16. IL_000b: call instance void test_console.A::Foo()
17. IL_0010: ret
從上面的IL中我們可以清楚的知道引用對象在調用虛函數和非虛函數時是都使用 callvirt指令,而只有在調用基類函數中才會使用call指令(防止無限遞歸調用)。而且 我們還能看出callvirt後面的函數簽名只和聲明的類型有關而不是實際對象的類型(對於 A b = new B();編譯器只關心前面的類型A而不關心後面的類型B)。
2.引用類型的實例在托管堆上的開頭4字節內容指向該類型的方法表(MethodTable) ,而值類型實例不包含方法表地址(關於值類型的談論請看這裡)。下面我使用SOS.dll 來看一下方法表裡的內容(關於SOS.dll和方法表的內容請查詢博客園和msdn):
(1) 得到a,b對象的地址,並查看gc對上內容(通過clrstack -a命令)
<CLR reg> = 0x012e1d68 //a
009730e8 00000000 //開 頭4字節為methodtable
<CLR reg> = 0x012f93fc //b
00973188 00000000
(2) 查看方法表內容(通過dumpmt -md 指令)
---------------------A類型方法表內容---------------------
79354bec 7913bd48 PreJIT System.Object.ToString()
--------------- ------B類型方法表內容---------------------
793539c0 7913bd50 PreJIT System.Object.Equals(System.Object)
793539b0 7913bd68 PreJIT System.Object.GetHashCode()
7934a4c0 7913bd70 PreJIT System.Object.Finalize()
00973148 009730d8 JIT test_console.A.Foo() //虛方法Foo
00973138 009730d0 JIT test_console.A.Boo()
00973158 009730e0 JIT test_console.A..ctor()
79354bec 7913bd48 PreJIT System.Object.ToString()
793539c0 7913bd50 PreJIT System.Object.Equals(System.Object)
793539b0 7913bd68 PreJIT System.Object.GetHashCode()
7934a4c0 7913bd70 PreJIT System.Object.Finalize()
009731d0 00973178 NONE test_console.B.Foo() //被B覆寫的Foo
009731e0 00973180 JIT test_console.B..ctor()
我們發現虛方法Foo無論在A中還是B中都是放在第5位置上,只不過因為B覆寫了Foo所 以將這個位置上原本的A.Foo覆蓋為B.Foo了。說到這你是不是已經明白了,所謂override 只是在子類的方法表中父類虛方法的地址替換為子類相應覆寫方法的地址罷了,這個概念 叫做虛分派。所以每次調用 A::Foo()的時候只要得到Foo的實際地址就可以了,如果子類 覆寫了Foo那麼這個地址就是子類Foo的,如果沒有就還是父類中Foo的地址。如果子類中 使用new或者new virutal方式編寫了Foo那麼方法表就會在A所有虛方法的下面記錄一條 B.Foo的地址。如果還沒有看明白的話我們可以查看一下反匯編來驗證這個結論:
1. a.Foo();
2. 0000003b mov ecx,edi //esi是對象地址
3. 0000003d mov eax,dword ptr [ecx] //[ecx]得到 方法表地址因為頭4字節是方法表地址
4. 0000003f call dword ptr [eax+38h] //調用偏移0x38位置上的方法
5. 00000042 nop
6.
7. b.Foo();
8. 00000062 mov ecx,ebx
9. 00000064 mov eax,dword ptr [ecx]
10. 00000066 call dword ptr [eax+38h] //同樣調 用偏移0x38位置上的方法
11. 00000069 nop
12.
所以對於編譯器來講它並不知道什麼繼承、虛函數,只是IL的callvirt指令在調用虛 函數時被Jit出來的匯編代碼每次執行時都要獲取一下函數調用地址。那為什麼調用非虛 函數也同樣使用callvirt指令呢?讓我們看看調用Boo方法時的匯編指令
1. a.Boo();
2. 00000043 mov ecx,edi
3. 00000045 cmp dword ptr [ecx],ecx
4. 00000047 call FF9430C8
5. 0000004c nop
6. b.Boo();
7. 0000006a mov ecx,ebx
8. 0000006c cmp dword ptr [ecx],ecx
9. 0000006e call FF9430C8
10. 00000073 nop
其中dword ptr [ecx],ecx 是為了檢測this指針是否為空,而後面的call FF9430C8指 令會直接調用Boo方法。所以callvirt調用一個非虛方法時只是檢測一下罷了,實際上還 是使用call指令來直接調用方法(靜態函數的調用方式)。這時候你也應該明白為什麼調 用基類函數時(base.Foo)要用要用call指令了吧。
說了這麼一大堆都是關於類繼承的方式,對於接口來說實現方式略有不同。CLR會將每 個類對接口方法的實現(隱式,顯示)地址放入一個全局接口映射表中(global interface map table),然後在接口調用時查詢這個表來定位具體調用的函數,用這種 方式來區分接口調用和類調用。在論壇上曾經有人提過這個問題:
1. interface IC
2. {
3. void Test();
4. }
5. class C : IC
6. {
7. public void Test()
8. {
9. Console.WriteLine("C::Test().");
10. }
11. }
12. class D : C
13. {
14. void Test()
15. {
16. Console.WriteLine("D::Test().");
17. }
18. }
19.
20. IC ic = new D();
21. ic.Test(); //為什麼結果是C::Test().
看IL我們會發現C中的Test雖然是虛函數不過是final的
.method public hidebysig newslot virtual final instance void Test() cil managed
所以D雖然繼承自C但是從類繼承的角度B中的Test是new的,如果我們讓D同樣繼承自IC 接口或者將C中的Test方法改為virtual,那麼D中 Test方法將會被加入到全局接口映射表 中。因.net2.0中Jit後調用接口方法的方式有所改變,所以我接口繼承的細節還沒有全部 弄明白,如果你知道請告訴我。