关闭

解析.net中继承的实质

发表于:2008-9-06 15:33

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:傅晗    来源:CSDN blog

#
.net

  在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方法将会被加入到全局接口映射表中。

《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号