深入了解.NET中继承和多态(下)

很久都没写BLGO了,关于多态的第3篇文章一晃就1年了才写。有时比较迷茫,感觉太多东西都要学,什么都想学,却找不清方向了。呵呵,看着好多牛人的BLOG觉得自己水平实在是太差了。呵呵。有时甚至觉得自己写的东西太低级了。呵呵,或许是自己抱怨太多了,还是静下心来慢慢学习吧。以后一定多写一些东西,自己经常看看还是挺有帮助的。

如果大家对多态的机制还不了解,可以先查看上面这2篇文章。本篇本打算使用一些例子说话,但是实际大家明白了方法表的布局结构。其实是根本不需要任何实例去讲解了。所以这一篇主要算是查缺补漏吧。

 

 

一 多态的例子

 

class Program
    {
        static void Main(string[] args)
        {
            Cpu c1 = new Cpu();
            c1.fun();
            Cpu c2 = new IntelCpu();
            c2.fun();
            Cpu c3_1 = new NewCpu();
            c3_1.fun();
            NewCpu c3_2 = new NewCpu();
            c3_2.fun();
            Console.ReadKey();
        }
    }
    class Cpu
    {
        public virtual void fun()
        {
            Console.WriteLine("This is Cpu");
        }
    }
    class IntelCpu : Cpu
    {
        public override void fun()
        {
            Console.WriteLine("This is IntelCpu");
        }
    }
    class NewCpu : Cpu
    {
        public new void fun()
        {
            Console.WriteLine("This is NewCpu");
        }
    }

这个例子,主要是为了解释上一篇中遗留的new关键字时候的问题。我们主要从两个方面来讲解这个列子:

1:变量类型同对象类型不同的场合

 

C2和C3_1这两个对象的变量类型都是CPU类型,分配在栈上:

在编译前:他们都只能使用变量类型所具有的方法,也就是CPU::FUN() 方法。

在编译时:编译器发现CPU::FUN()是一个虚方法,然后便获得了FUN的方法槽偏移量为【28H】 ,C2和C3_1对象调用Fun()方法的地址是【对象方法表地址】+【28H】

在运行时:因为虚方法是通过callvirt 指令调用 ,需要知道具体的对象类型,这个时候C2对象是IntelCpu类型,而C3_1对象是NewCpu类型。于是访问C2.FUN()时地址是【IntelCpu类型地址】+【28H】;而C3_1.FUN()的地址是【NewCpu】+【28H】

上面就是编译后的内存方法表的布局情况。IntelCpu类型使用了override关键字,所以方法槽偏移量为【28H】不再指向CPU对象的方法地址,而NewCpu类型对象使用了new关键字,所以继承的方法槽偏移量为【28H】的地址仍旧指向CPU对象的方法地址,只是在下一个方法槽创建了一个新的fun方法。更具上面的图就很清楚的看出了运行时的结果。

 

 

2 变量类型同对象类型相同的场合

 

C1和C3_2这2个对象的变量类型与类型对象是相同的。

在编译前:C1变量是Cpu类型,所以能见的是CPU::FUN()方法;而C3_2变量是NewCpu类型,能见的是NewCpu::Fun()方法(因为用New关键字覆盖了)

在编译时:发现C1的fun方法是虚方法,所以才C1.fun访问地址是【对象方法表地址】+【28H】 ;而C3_2的fun方法不是虚方法,所以编译器可以直接确定此方法的地址【0x0001】。

在运行时:同样是使用callvirt 指令调用,因为他们变量类型与类型对象是相同的,所以不会表现出多态。

上面就是这种情况的方法表布局。可以看到NewCpu类型对象有两个fun方法,一个是继承于Cpu类型对象的虚方法,一个是自己新建的方法。C3_1对象同C3_2对象区别就在于他们栈上的变量类型不同。C3_1在编译时是Cpu对象,可见的fun是虚方法,所以获得了继承的Fun方法的方法槽偏移量,而C3_2在编译时NewCpu对象,可见的fun方法是非虚方法,所以直接得到了自己的fun方法的地址。

另外要补充的就是对于new override的情况,这个是和new一样的,不同的只是自己新建的这个方法是一个虚方法。而如果直接使用override方法,被重写的方法仍旧是虚方法,可以被自己的子类继续重写,一层一层。

 

 

 

二 更进一步的例子

 

 

对于override和new的情况,应该说是应该比较清楚了,那么接着看下面的列子吧。

class Program
    {
        static void Main(string[] args)
        {
            NewCpu c1 = new NewCpu();
            c1.fun1();
            c1.fun2();
            c1.fun3();
            Console.ReadKey();
        }
    }
    class Cpu
    {
        public virtual void fun1()
        {
            Console.WriteLine("This is Cpu fun1");
        }
        public void fun2()
        {
            Console.WriteLine("This is Cpu fun2");
        }
        public virtual void fun3()
        {
            Console.WriteLine("This is Cpu fun3");
        }
    }
    class NewCpu : Cpu
    {
        public new void fun1()
        {
            Console.WriteLine("This is NewCpu");
        }
    }

上面的列子中在父类中有2个虚方法,一个非虚方法。而子类中,只是覆盖了一个虚方法。而我们要关注的也就是这个子类的调用情况。因为父类没有任何方法被重写,所以准确的说,这里并不能算是一个多态的例子。但是有了虚方法,有了new,总是容易和多态混淆。还是那句话,弄清楚了方法表布局,一切都不在是问题。

再次提醒一次,NewCpu方法表只会继承基类的虚方法到自己的方法槽表中,并且保持相同的布局; 所以此时的内存方法表应该如上图所示。fun1和fun3两个虚方法继承于父类,并且保持了相同的布局。而fun2是非虚方法,所以没有被继承。

 

调用fun1方法,和前一个列子是相同的,因为被覆盖,并且是非虚方法,所以编译时确定了地址,

调用fun2方法,因为fun2是父类的一个非虚方法,所以也是编译时确定了地址

调用fun3方法,因为fun3方法是父类的一个虚方法,所以编译时只能确定方法槽的偏移量,而要在运行时确定运行地址。

           NewCpu c1 = new NewCpu();
00000035  mov         ecx,0AB311Ch 
0000003a  call        FFB41FAC 
0000003f  mov         dword ptr [ebp-44h],eax 
00000042  mov         ecx,dword ptr [ebp-44h] 
00000045  call        FFB5C000 
0000004a  mov         eax,dword ptr [ebp-44h] 
0000004d  mov         dword ptr [ebp-40h],eax 
            c1.fun1();
00000050  mov         ecx,dword ptr [ebp-40h] 
00000053  cmp         dword ptr [ecx],ecx 
00000055  call        FFB5BFF8 
0000005a  nop              
            c1.fun2();
0000005b  mov         ecx,dword ptr [ebp-40h] 
0000005e  cmp         dword ptr [ecx],ecx 
00000060  call        FFB5BFC8 
00000065  nop              
            c1.fun3();
00000066  mov         ecx,dword ptr [ebp-40h] 
00000069  mov         eax,dword ptr [ecx] 
0000006b  call        dword ptr [eax+3Ch] 
0000006e  nop              

上面是编译后的汇编代码,大家也可以到call指令后的地址形式。只有fun3是间接寻址,而其他是直接寻址。这里唯一的问题就是对fun2方法的调用。fun2没有被继承下来,那么NewCpu对象是如何去Cpu对象中得到他的地址的呢?


TypeDef #4 (02000005)
-------------------------------------------------------
	TypDefName: virtual.NewCpu  (02000005)
	Flags     : [NotPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100000)
	Extends   : 02000004 [TypeDef] virtual.Cpu
	Method #1 (06000009) 
	-------------------------------------------------------
		MethodName: fun1 (06000009)
		Flags     : [Public] [HideBySig] [ReuseSlot]  (00000086)
		RVA       : 0x000020e8
		ImplFlags : [IL] [Managed]  (00000000)
		CallCnvntn: [DEFAULT]
		hasThis 
		ReturnType: Void
		No arguments.
		Signature : 20 00 01 

上面是NewCpu生成的IL代码,我们可以发现Extends项目中只是了它的父类是Cpu类,这样在编译时,虽然在自身类中找不到fun2方法,但是系统会去他的父类中找到此方法并确定方法的地址,而在我们编译前,智能感知中能找到fun2,也是依靠元数据来实现的。而在子类实例化之前,调用父类构造函数,应该也是同一个道理。

 

 

 

三 终极武器

 

 

光凭空YY,是解决不了问题的,这个时候我就要要用sos.dll来调试代码; 看看内存布局到底是个啥样子。

为了看的清楚,在上面代码中加入了Cpu的对象c,来分别调用3个方法:

 

1:查看实际的内存方法表布局

 

.load sos
已加载扩展 C:/WINDOWS/Microsoft.NET/Framework/v2.0.50727/sos.dll
!clrstack -a   //查看堆栈信息
PDB symbol for mscorwks.dll not loaded
OS Thread Id: 0xbf8 (3064)
ESP       EIP     
0013f418 00f6011c virtual.Program.Main(System.String[])
    PARAMETERS:
        args = 0x01432f58
    LOCALS:  //我们堆栈上的两个对象 
        0x0013f430 = 0x01432f68   // 对象c
        0x0013f42c = 0x01452184   // 对象c1
0013f68c 79e71b4c [GCFrame: 0013f68c] 
//查看对象c的类型对象
!dumpobj 0x01432f68
Name: virtual.Cpu
MethodTable: 00ab30a4
EEClass: 00ab1368
Size: 12(0xc) bytes
 (E:/program/CSharp/Practice/virtual/virtual/bin/Debug/virtual.exe)
Fields:
None
// 查看CPU类型的方法表
!dumpmt -md 00ab30a4   
EEClass: 00ab1368
Module: 00ab2c5c
Name: virtual.Cpu
mdToken: 02000003  (E:/program/CSharp/Practice/virtual/virtual/bin/Debug/virtual.exe)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 8
--------------------------------------
MethodDesc Table  // 这里就是她的方法表了
   Entry MethodDesc      JIT Name
035f6aa0   03474924   PreJIT System.Object.ToString()
035f6ac0   0347492c   PreJIT System.Object.Equals(System.Object)
035f6b30   0347495c   PreJIT System.Object.GetHashCode()
03667410   03474980   PreJIT System.Object.Finalize()
00abc030   00ab3070      JIT virtual.Cpu.fun1()
00abc040   00ab308c      JIT virtual.Cpu.fun3()
00abc048   00ab3098      JIT virtual.Cpu..ctor()
00abc038   00ab307c      JIT virtual.Cpu.fun2()

从上面我们清楚的看到Cpu类型对象的方法表中有我们自己定义的3个方法,Entry就是方法槽偏移量,也是递增的。接下来看看NewCpu对象的方法表:

!dumpmt -md 00ab311c
EEClass: 00ab13cc
Module: 00ab2c5c
Name: virtual.NewCpu
mdToken: 02000004  (E:/program/CSharp/Practice/virtual/virtual/bin/Debug/virtual.exe)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 8
--------------------------------------
MethodDesc Table
   Entry MethodDesc      JIT Name
035f6aa0   03474924   PreJIT System.Object.ToString()
035f6ac0   0347492c   PreJIT System.Object.Equals(System.Object)
035f6b30   0347495c   PreJIT System.Object.GetHashCode()
03667410   03474980   PreJIT System.Object.Finalize()
00abc030   00ab3070      JIT virtual.Cpu.fun1()
00abc040   00ab308c      JIT virtual.Cpu.fun3()
00abc070   00ab3110      JIT virtual.NewCpu..ctor()
00abc068   00ab3100      JIT virtual.NewCpu.fun1()

如何,看明白了吧,继承下来的fun1和fun3的地址是一样的,而方法表中确实没有fun2的身影。平时我们说的继承,子类会继承父类的所有方法(包括私有方法,只是不能访问,但不包括构造方法),这个实际是逻辑上的继承,而真正物理上的集成只对虚方法有效。而且对于非虚方法也不需要去继承到子类中,因为这就是代码重用吗。哈哈!

 

 

2:MethodDesc

 

在看看上面的MethodDesc这个字段,方法描述(MethodDesc)是CLR知道的方法实现的一个封装。方法描述在类加载过程中产生,初始化为指向IL。每个方法描述带有一个预编译代理(PreJitStub),负责触发JIT编译。下图显示了一个典型的布局,方法表的槽实际上指向代理,而不是实际的方法描述数据结构。对于实际的方法描述,这是-5字节的偏移,是每个方法的8个附加字节的一部分。这5个字节包含了调用预编译代理程序的指令。5字节的偏移可以从SOS的DumpMT输出从看到,因为方法描述总是方法槽表指向的位置后面的5个字节。在第一次调用时,会调用JIT编译程序。在编译完成后,包含调用指令的5个字节会被跳转到JIT编译后的x86代码的无条件跳转指令覆盖。(转)

 

 

3:EEClass

 

EEClass在方法表创建前开始生存,它和方法表结合起来,是类型声明的CLR版本。实际上,EEClass和方法表逻辑上是一个数据结构(它们一起表示一个类型),只不过因为使用频度的不同而被分开。经常使用的域放在方法表,而不经常使用的域在EEClass中。这样,需要被JIT编译函数使用的信息(如名字,域和偏移)在EEClass中,但是运行时需要的信息(如虚表槽和GC信息)在方法表中。

对每一个类型会加载一个EEClass到应用程序域中,包括接口,类,抽象类,数组和结构。每个EEClass是一个被执行引擎跟踪的树的节点。CLR使用这个网络在EEClass结构中浏览,其目的包括类加载,方法表布局,类型验证和类型转换。EEClass的子-父关系基于继承层次建立,而父-子关系基于接口层次和类加载顺序的结合。在执行托管代码的过程中,新的EEClass节点被加入,节点的关系被补充,新的关系被建立。在网络中,相邻的EEClass还有一个水平的关系。EEClass有三个域用于管理被加载类型的节点关系:父类(Parent Class),相邻链(sibling chain)和子链(children chain)。

我们通过使用!DumpClass可以查看EEClass

!DumpClass 00ab13cc
Class Name: virtual.NewCpu
mdToken: 02000004 (E:/program/CSharp/Practice/virtual/virtual/bin/Debug/virtual.exe)
Parent Class: 00ab1368
Module: 00ab2c5c
Method Table: 00ab311c
Vtable Slots: 6
Total Method Slots: 7
Class Attributes: 100000  
NumInstanceFields: 0
NumStaticFields: 0
!DumpClass 00ab1368
Class Name: virtual.Cpu
mdToken: 02000003 (E:/program/CSharp/Practice/virtual/virtual/bin/Debug/virtual.exe)
Parent Class: 03433ef0
Module: 00ab2c5c
Method Table: 00ab30a4
Vtable Slots: 6
Total Method Slots: 7
Class Attributes: 100000  
NumInstanceFields: 0
NumStaticFields: 0
!DumpClass 03433ef0
Class Name: System.Object
mdToken: 02000002 (C:/WINDOWS/assembly/GAC_32/mscorlib/2.0.0.0__b77a5c561934e089/mscorlib.dll)
Parent Class: 00000000
Module: 03431000
Method Table: 036a061c
Vtable Slots: 4
Total Method Slots: a
Class Attributes: 102001  
NumInstanceFields: 0
NumStaticFields: 0

上面的代码中我们从NewCpu对象开始查看,可以看到她Parent Class的地址,这个就是当前父对象的类型EEClass地址。我们继续看Cpu对象的父对象发现是Object,而她的父对象地址是 00000000。而这个结构中,还包括了类中定义的静态字段和实例字段数;Vtable Slots是虚方法数量和实现的接口方法,而父类的借口方法也会被继承下来,因为接口方法也是虚方法,虽然被继承但不能被重写,因为IL代码中有final关键字 ,而Total Method  Slots是类中总的的方法。由此可见,在NewCpu方法中,虚方法有6个,4个继承与Object,2个继承与Cpu;而总的方法有7个,6个虚方法,和一个继承与Cup的非虚方法。

关于使用sos调试的命令可以参见:http://msdn.microsoft.com/zh-cn/library/bb190764.aspx

 

 

 

四 抽象方法、虚方法和接口方法

 

 class Program
    {
        static void Main(string[] args)
        {
            Console.ReadKey();
        }
    }
    interface ITest
    {
        void test();
    }
    abstract class BaseCpu
    {
        public abstract void fun1();
        public virtual void fun2()
        {
            Console.WriteLine("This is BaseCpu fun2");
        }
    }
    class Cpu : BaseCpu , ITest
    {
        public override void fun1()
        {
            Console.WriteLine("This is Cpu fun1");
        }
        public override void fun2()
        {
            Console.WriteLine("This is Cpu fun2");
        }
        public void test()
        {
            throw new Exception("The method or operation is not implemented.");
        }
    }
    class NewCpu : Cpu
    {
        public override void fun1()
        {
            Console.WriteLine("This is NewCpu fun1");
        }
        public override void fun2()
        {
            Console.WriteLine("This is NewCpu fun2");
        }
    }

上面的列子包含了抽象方法,虚方法和接口方法,以及他们的继承和重写。实际上抽象方法和接口方法都是虚方法,只不过他们不需要也不能显示的使用virtual关键字。我们通过ILDASM来查看他们的IL有什么区别。

.method public hidebysig newslot abstract virtual 
        instance void  fun1() cil managed
{
} // end of method BaseCpu::fun1
.method public hidebysig newslot virtual 
        instance void  fun2() cil managed
{
  // 代码大小       13 (0xd)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldstr      "This is BaseCpu fun2"
  IL_0006:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_000b:  nop
  IL_000c:  ret
} // end of method BaseCpu::fun2
.method public hidebysig newslot abstract virtual 
        instance void  test() cil managed
{
} // end of method ITest::test 

可以看到3种方法的IL代码都有virtual关键字,说明他们全是虚方法。不同的是接口和抽象方法都有abstract方法,表示他们都是抽象的,所以非抽象类或非接口继承他们之后都需要被实现。

我们接着看继承他们的类的IL代码

.method public hidebysig virtual instance void 
        fun1() cil managed
{
  // 代码大小       13 (0xd)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldstr      "This is Cpu fun1"
  IL_0006:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_000b:  nop
  IL_000c:  ret
} // end of method Cpu::fun1
.method public hidebysig virtual instance void 
        fun2() cil managed
{
  // 代码大小       13 (0xd)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldstr      "This is Cpu fun2"
  IL_0006:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_000b:  nop
  IL_000c:  ret
} // end of method Cpu::fun2
.method public hidebysig newslot virtual final 
        instance void  test() cil managed
{
  // 代码大小       12 (0xc)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldstr      "The method or operation is not implemented."
  IL_0006:  newobj     instance void [mscorlib]System.Exception::.ctor(string)
  IL_000b:  throw
} // end of method Cpu::test

上面的Cpu类分别重写了3种方法。抽象方法和虚方法是相同的,而接口却多了一个final关键字,这样的话,此接口方法不能被子类重写。虽然他是虚方法。如果需要接口方法能被重写,需要显示的加上Virtual关键字。而如果希望一个虚方法不能被不能被子类重写,那么可以使用sealed关键字,而不能使用private来限制虚方法。 效果如下IL代码:

//让接口方法可被重写,使用virtual关键字
public virtual void test()
{
  throw new Exception("The method or operation is not implemented.");
}
.method public hidebysig newslot virtual 
        instance void  test() cil managed
{
  // 代码大小       12 (0xc)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldstr      "The method or operation is not implemented."
  IL_0006:  newobj     instance void [mscorlib]System.Exception::.ctor(string)
  IL_000b:  throw
} // end of method Cpu::test
//---------------------------------------------------
//让虚方法不能被重写,使用sealed 关键字
public sealed override void fun2()
{
   Console.WriteLine("This is Cpu fun2");
}
.method public hidebysig virtual final instance void 
        fun2() cil managed
{
  // 代码大小       13 (0xd)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldstr      "This is Cpu fun2"
  IL_0006:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_000b:  nop
  IL_000c:  ret
} // end of method Cpu::fun2

有意思的是,如果你吧虚方法定义为private,在编码时,只能感知会更具元数据来显示出这个方法为可重写的方法,但是编译时会报错,所以不知道这算不算一个小BUG。但是在C++中,私有虚函数是有意义的,http://topic.csdn.net/t/20040805/16/3245820.html

 

 

 

 

五 总结

 

 

.NET中的继承和多态的第3篇文章终于写完了。其实自己也是从对多态懵懵懂懂的认识开始的,在网上看了好多介绍继承和多态,但很多都是给你一些自己总结的规则,看的人云里雾里,有一些也介绍到了方法表,内存结构,但是介绍的都很浅,所以自己打算稍微深入研究一下。结果一直没写下来。感觉对于继承和多态的把握关键还是在内存模型。内存结构了解了,万变不离其中。在复杂的情况也能分析的清楚。但是鉴于本人能力有限,对于内存模型那块,也是知之甚少,难免有错误的地方。


如果本文对您有帮助,可以扫描下方二维码打赏!您的支持是我的动力!
微信打赏 支付宝打赏

4 评论

  1. 写的真是太棒了,看了那么多书,我还以为自己懂了什么是多态,原来底层的实现这么精巧而且有趣,版主牛逼,没话说

cc_net进行回复 取消回复

您的电子邮箱地址不会被公开。 必填项已用*标注