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

 在前一篇深入了解.NET中继承和多态(上) 中我们已经知道了对象在内存中的布局结构,这一篇我们讲主要研究继承和多态。主要是通过列子来看问题。其中会涉及到使用SOS进行扩展调试和查看IL代码。

 

一 调用方法的IL指令

我们知道在.NET中一共有三种方法:实例方法,静态方法和虚方法。当程序被编译成IL代码时,我们可以看到有两个调用方法的IL指令,分别是callcallvirt我们首先看下下面的列子:

 以下是Main方法的IL代码

  

 

从IL代码我们看到了,call指令只用来调用了静态方法,而callvirt指令调用了虚方法和实例方法。但是我们在看一个特列,就是重写的ToString()方法中,调用的base.ToString()方法时,是用什么指令:

 

我们发现,代码中竟然是用call指令调用的虚方法。到底什么情况用什么指令呢?我们先来了解下这两个指令对引用类型方法调用的情况:

 

通过上表,我们可以看到call和callvirt各有个的作用,call不需要知道变量的实际对象类型,直接使用变量类型来调用方法,所以用它来调用实例方法是没有问题的。而callvirt需要检查变量所指对象的实际类型,根据实际类型来调用方法,而不是根据变量类型,这正好适合多态,实现了通过父类变量来调用子类方法(如何调用后面介绍)。但问题就在于,为什么有的虚方法使用call,有的使用callvirt呢?

 

 

 而这种情况中,用call调用了虚方法,我们知道call是以变量类型来调用方法的,而不是根据变量指向对象的实际类型,所以用Call指定调用虚方法是无法实现多态的。那为什么要这么做呢?因为如果使用callvirtal调用Object.Tostring()方法时,调用会递归执行导致堆栈溢出。在调用实例方法和虚方法时,无论使用那个指令,这些方法通常都会接受一个隐藏的this参数作为方法的第一个参数,this参数引用要进行操作的对象。

我们如果把代码改成如上的的样子,编译器如果使用call指令来调用实例方法的话是没有仍和问题的。但是在C#中运行时是会跑抛出一个异常的,提醒你未将对象引用到对象实例。实际使用那个指令调用实例方法是编译器决定,就C#而言大家都看到了是使用callvirt指令来调用实例方法。而某些语言的编译器则可能使用call来调用。前面我们讨论的是引用类型,当对于值类型的方法,C#总是使用call指令调用的,因为值类型是密封的,不存在多态。对于未装箱的值类型总是分配在栈上的,所以只需知道变量类型,使用call指令加快处理速度,也就永远不会抛出null的异常。如果是用callvirt调用值类型的虚方法会导致装箱,造成性能损失。

 

 

二 多态的本质

1: 确定方法的内存地址

可以说,在前面我们已经做完了所有深入了解继承和多态的准备工作了,我们首先来总结下前面的内容,了解多态和继承的调用方法。从方法槽表我们可以知道,子类只会继承父类的虚方法到自己的方法表槽,在初始化程序的时候,每个类型都有一个自己的方法表(对象类型),存储在默认程序域的加载堆中。这个时候就已经明确了某个类型可以调用那些方法(从元数据中获得的)。然后程序开始由JIT进行编译,结果如下:

     

 

 

我们看到的是经过JIT编译后的汇编代码,我汇编都忘记的差不多了,就大概说下了。我们主要看方法调用后有的代码,主要使用了2个指令mov和call,注意这里的call是汇编代码调用方法的指令,而不是IL代码中的Call。实际对于汇编语言,他是不区分那你调用的是虚方法还是。我们看上面可以发现,call后面的地址有两种,一种是{call+地址},另一种是{call+【地址+偏移】}。

 

 

而对于虚方法来说,JIT编译时我们只知道变量的类型,前面也说过了callvirt指令需要知道对象的实际类型,而实际类型对象是需要在运行是才能知道的,所以对于虚方法,JIT编译时无法确定方法的地址,就采用的第2种地址方式,地址+偏移。这里地址就是方法表的地址,而后面的偏移是方法槽的偏移。运行时不同的对象的this指针不同,所以最后的方法表地址(eax)也是不同的,不同的方法表就实现了的不同的方法,而这种间接的寻址方式正是多态的奥秘所在。

2:方法槽偏移

前面已经解释了.NET中是如何实现多态的,就是在运行时确定类型对象,从而确定要调用方法的方法表地址和槽偏移。在前一篇文章中介绍了方法槽表,其中每一个方法占用一个槽,而每个方法的地址,都是相对与方法表有一个偏移地址,也就是【方法表地址+槽偏移】来确定的。其实前面由一个遗留的问题,不知道大家发现了没有。对于虚方法,我们运行时只获得了对象的方法表地址,那么是如何获得槽偏移的呢?

我们看这个例子,对于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个虚方法,因为保持了布局一致。最后要说的一点是,槽中存的是方法的实现地址,而不是方法的实现

 

3:Override 和 new

终于明白了多态是如何实现的,但谈到.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

 

在最后一篇中,将会通过实际的例子和查看方法表来了解各种不同的情况下的继承和多态的表现。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值