在前一篇深入了解.NET中继承和多态(上) 中我们已经知道了对象在内存中的布局结构,这一篇我们讲主要研究继承和多态。主要是通过列子来看问题。其中会涉及到使用SOS进行扩展调试和查看IL代码。
我们知道在.NET中一共有三种方法:实例方法,静态方法和虚方法。当程序被编译成IL代码时,我们可以看到有两个调用方法的IL指令,分别是call和callvirt。我们首先看下下面的列子:
class Cpu { public Cpu() { Console.WriteLine("初始化Cpu"); } public void fun() { Console.WriteLine("Cpu的方法/n"); } public static void fun2(){ } public virtual void fun3(){ } public override string ToString() { return base.ToString(); } } class Program { static void Main(string[] args) { Cpu c1 = new Cpu(); c1.fun(); //调用实例方法 Cpu.fun2(); //调用静态方法 c1.fun3(); //调用虚方法 c1.ToString(); //调用重写基类的虚方法 } }
以下是Main方法的IL代码
.method private hidebysig static void Main(string[] args) cil managed { .entrypoint // 代码大小 41 (0x29) .maxstack 1 .locals init ([0] class 'virtual'.Cpu c1) IL_0000: nop IL_0001: newobj instance void 'virtual'.Cpu::.ctor() IL_0006: stloc.0 IL_0007: ldloc.0 IL_0008: callvirt instance void 'virtual'.Cpu::fun() //调用实例方法 IL_000d: nop IL_000e: ldloc.0 IL_000f: callvirt instance void 'virtual'.Cpu::fun3() //调用虚方法 IL_0014: nop IL_0015: call void 'virtual'.Cpu::fun2() //调用静态方法 IL_001a: nop IL_001b: ldloc.0 IL_001c: callvirt instance string [mscorlib]System.Object::ToString() //调用重写基类的虚方法 IL_0021: pop IL_0022: call valuetype [mscorlib]System.ConsoleKeyInfo [mscorlib]System.Console::ReadKey() IL_0027: pop IL_0028: ret } // end of method Program::Main
从IL代码我们看到了,call指令只用来调用了静态方法,而callvirt指令调用了虚方法和实例方法。但是我们在看一个特列,就是重写的ToString()方法中,调用的base.ToString()方法时,是用什么指令:
.method public hidebysig virtual instance string ToString() cil managed { // 代码大小 12 (0xc) .maxstack 1 .locals init ([0] string CS$1$0000) IL_0000: nop IL_0001: ldarg.0 IL_0002: call instance string [mscorlib]System.Object::ToString() //这里用call调用了虚方法 IL_0007: stloc.0 IL_0008: br.s IL_000a IL_000a: ldloc.0 IL_000b: ret } // end of method Cpu::ToString
我们发现,代码中竟然是用call指令调用的虚方法。到底什么情况用什么指令呢?我们先来了解下这两个指令对引用类型方法调用的情况:
通过上表,我们可以看到call和callvirt各有个的作用,call不需要知道变量的实际对象类型,直接使用变量类型来调用方法,所以用它来调用实例方法是没有问题的。而callvirt需要检查变量所指对象的实际类型,根据实际类型来调用方法,而不是根据变量类型,这正好适合多态,实现了通过父类变量来调用子类方法(如何调用后面介绍)。但问题就在于,为什么有的虚方法使用call,有的使用callvirt呢?
public override string ToString() { return base.ToString(); } IL_0002: call instance string [mscorlib]System.Object::ToString() //这里用call调用了虚方法
而这种情况中,用call调用了虚方法,我们知道call是以变量类型来调用方法的,而不是根据变量指向对象的实际类型,所以用Call指定调用虚方法是无法实现多态的。那为什么要这么做呢?因为如果使用callvirtal调用Object.Tostring()方法时,调用会递归执行导致堆栈溢出。在调用实例方法和虚方法时,无论使用那个指令,这些方法通常都会接受一个隐藏的this参数作为方法的第一个参数,this参数引用要进行操作的对象。
Cpu c1 = null; c1.fun(); //未处理的“System.NullReferenceException”类型的异常出现在 virtual.exe 中。 //其他信息: 未将对象引用设置到对象的实例。
我们如果把代码改成如上的的样子,编译器如果使用call指令来调用实例方法的话是没有仍和问题的。但是在C#中运行时是会跑抛出一个异常的,提醒你未将对象引用到对象实例。实际使用那个指令调用实例方法是编译器决定,就C#而言大家都看到了是使用callvirt指令来调用实例方法。而某些语言的编译器则可能使用call来调用。前面我们讨论的是引用类型,当对于值类型的方法,C#总是使用call指令调用的,因为值类型是密封的,不存在多态。对于未装箱的值类型总是分配在栈上的,所以只需知道变量类型,使用call指令加快处理速度,也就永远不会抛出null的异常。如果是用callvirt调用值类型的虚方法会导致装箱,造成性能损失。
可以说,在前面我们已经做完了所有深入了解继承和多态的准备工作了,我们首先来总结下前面的内容,了解多态和继承的调用方法。从方法槽表我们可以知道,子类只会继承父类的虚方法到自己的方法表槽,在初始化程序的时候,每个类型都有一个自己的方法表(对象类型),存储在默认程序域的加载堆中。这个时候就已经明确了某个类型可以调用那些方法(从元数据中获得的)。然后程序开始由JIT进行编译,结果如下:
Cpu c1 = new Cpu(); 00000027 mov ecx,0A630B8h 0000002c call FFB11FAC 00000031 mov edi,eax 00000033 mov ecx,edi 00000035 call FFB2C070 0000003a mov esi,edi c1.fun(); 0000003c mov ecx,esi 0000003e cmp dword ptr [ecx],ecx 00000040 call FFB2C050 //调用实例方法fun() 00000045 nop Cpu.fun2(); 00000046 call FFB2C060 //调用静态方法fun2() 0000004b nop c1.fun3(); 0000004c mov ecx,esi 0000004e mov eax,dword ptr [ecx] 00000050 call dword ptr [eax+38h] //调用虚方法fun3(); 00000053 nop c1.ToString(); 00000054 mov ecx,esi 00000056 mov eax,dword ptr [ecx] 00000058 call dword ptr [eax+28h] //调用重写基类的虚方法 0000005b nop
我们看到的是经过JIT编译后的汇编代码,我汇编都忘记的差不多了,就大概说下了。我们主要看方法调用后有的代码,主要使用了2个指令mov和call,注意这里的call是汇编代码调用方法的指令,而不是IL代码中的Call。实际对于汇编语言,他是不区分那你调用的是虚方法还是。我们看上面可以发现,call后面的地址有两种,一种是{call+地址},另一种是{call+【地址+偏移】}。
0000004c mov ecx,esi //esi就是指向当前类型对象的this指针,存入exc 0000004e mov eax,dword ptr [ecx] //通过this指针获得类型对象(方法表)的地址,存入eax 00000050 call dword ptr [eax+38h] //这里通过eax方法表的地址和38h槽偏移动态获得虚方法地址
而对于虚方法来说,JIT编译时我们只知道变量的类型,前面也说过了callvirt指令需要知道对象的实际类型,而实际类型对象是需要在运行是才能知道的,所以对于虚方法,JIT编译时无法确定方法的地址,就采用的第2种地址方式,地址+偏移。这里地址就是方法表的地址,而后面的偏移是方法槽的偏移。运行时不同的对象的this指针不同,所以最后的方法表地址(eax)也是不同的,不同的方法表就实现了的不同的方法,而这种间接的寻址方式正是多态的奥秘所在。
前面已经解释了.NET中是如何实现多态的,就是在运行时确定类型对象,从而确定要调用方法的方法表地址和槽偏移。在前一篇文章中介绍了方法槽表,其中每一个方法占用一个槽,而每个方法的地址,都是相对与方法表有一个偏移地址,也就是【方法表地址+槽偏移】来确定的。其实前面由一个遗留的问题,不知道大家发现了没有。对于虚方法,我们运行时只获得了对象的方法表地址,那么是如何获得槽偏移的呢?
class Program { static void Main(string[] args) { Cpu c1 = new Cpu(); c1.fun(); Cpu c2 = new IntelCpu(); c2.fun(); } } class Cpu { public virtual void fun() { Console.WriteLine("Cpu的方法/n"); } } class IntelCpu : Cpu { public override void fun() { Console.WriteLine("IntelCpu的方法/n"); } }
我们看这个例子,对于c1对象,在编译时系统发现fun()方法是虚方法(元数据中有virtual标识),JIT编译的时候无法确定调用方法的实际类型,对于c1变量来说,只看的到Cpu::fun()方法, 所以这个时候可以获得Cpu::fun()方法在Cpu类型对象中的槽偏移量;然后系统运行时,使用callvirt指令调用虚方法,调用虚方法时,传递一个this指针,系统发现就是变量类型,然后获得方法表地址,之前获得了槽偏移,这个时候就可以定位方法了。
而对于c2变量,在JIT编译时同样获得了Cpu::fun()方法在Cpu类型对象中的槽偏移量,在运行时发现实际类型是IntelCpu而不是Cpu,所以传递的this指针是指向的IntelCpu对象类型,这个时候地址是IntelCpu的方法表地址,槽偏移确是Cpu方法表中的偏移,是如何访问到IntelCpu的方法的呢?这里就有个很重要的规则,就是子类继承父类时,虚方法的方法布局层次结构是不变的。所以fun方法,在Cpu类型对象和IntelCpu类型对象中,相对于方法表的偏移量是完全相同的。所以我们在JIT编译时确定槽偏移,运行时确定方法表地址,最终实现了多态。子类型只会继承父类型的虚方法槽到自己的方法槽表中,这也就是为什么每个类型的方法槽表前4个方法是Object对象的4个虚方法,因为保持了布局一致。最后要说的一点是,槽中存的是方法的实现地址,而不是方法的实现
终于明白了多态是如何实现的,但谈到.NET中的多态就不能不提到这两个关键字。override是‘重写’,也就是子类重写父类的虚方法,增强父类中的此方法。而new在这里是‘隐藏’的意思,也就是说这个方法和父类虚方法没有仍和关系,是子类中一个新的方法,为什么叫隐藏我们往下看。
对于虚方法的继承,CLR处理如下。首先编译Cpu类型,产生Cpu的方法表,其中包括了Cpu::fun()的槽,它指向了自己的实现地址。然后编译IntelCpu类型时发现他继承于Cpu类型,然后Cpu类型的整个虚方法槽布局被复制到IntelCpu类型的方法表中,当然槽的内同也是一样,也就是说,这个时候IntelCpu::fun()方法和Cpu::fun()指向同一个实现;见图-1
图-1
然后系统发现方法使用了Override关键字,也是就把IntelCpu::fun()中的实现地址该为自己的实现地址,当调用时,就不会调用Cpu::fun()的实现了,从而实现了多态;见图-2
图-2
而对于new关键字,系统采用的方法是重新建立一个方法槽,名字也为IntelCpu::fun()。这个时候方法表中存在了2个fun()方法了。到底调用那一个方法呢,这就要根据方法类型了,见图-3(这里情况有很多种,在下一篇会详细说明)
图-3
在最后一篇中,将会通过实际的例子和查看方法表来了解各种不同的情况下的继承和多态的表现。