在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() 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() ---------------------B类型方法表内容--------------------- 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方法将会被加入到全局接口映射表中。