关于 多态的进一步探究 总结

四年前,写过一篇 关于C#中的抽象类、抽象方法和虚方法的探究 的小片段,当然,对于面向对象学习的初学者来讲,了解这些规则其实也挺ok的。对于一些工作了几年的程序员来说,如果没有深入挖掘面向对象多态的内部实现,对此类问题也是记一些简单的规则,但是如果多层 继承柔和在一起的话,对于运行时真正调用哪个子类方法的时候,甚至会理解彻底模糊。本人工作也有好几年了,后重新巩固面向对象的特性,感觉有必要整理下多态内部的相关细节,也查询了相关的资料,做一下整理。

我们一步步展开,当然,每一步都是相当需要细品的

(1)多态的实现效果

多态:同样的调用语句有多种不同的表现形态;

(2)多态实现的三个条件

有继承、有virtual重写、有父类指针(引用)指向子类对象。

 当  Parent  p1 = new Son(); 父类引用子类对象时

当我们调用   时,调试就会发现 走的是 Son的Show()的方法,而不是Parent的方法,那么这个是怎么实现的,这是第一个问题。

当Son下的Show方法由变为了new 后,再执行发现运行又变成了调Parent的Show()方法了。这又该怎么理解,这是第二个问题。

综合来讲,你会发现父类引用子类对象,不一定就会走到子类方法,结合多态的条件,你会发现,其实第二种算不上多态,因为他没有重写父类的方法,导致运行时直接调的父类自己的方法。当然这样说也有那么点局部性,因为单单是针对这个例子来说的,如果我们中间再插入一层InValidLayer类,如下图,这个重写了父类,Parent  p1 = new Son();  调 p1.Show();这时候就 会输出 叽叽了,三个条件 在下面都能发现痕迹。现在你会发现父类引用子类对象,不一定走到最下面这个子类,甚至会选择走中间的子类的方法中 这都得具体看代码,具体的规律,我相信对多态特征有了解的,或者看完 之前写的那片文章 应该都能理解到。

 现在重点说下多态的内部实现

(3)多态的实现

先说下虚方法, 我们类可以通过  virtual关键字 定义一个虚方法,然后子类可以 override这个虚方法, 对于virtual 是一个虚方法,对于override其实本身也算是一个虚方法,因为派生类还可以继续在override。而接口 ,就是 interface修饰的,编译完后就是一个抽象类,接口定义的方法,编译完后也是一个virtual修饰的 虚方法(这个在最后部分会展示)。所以多态实现的方式 大致分为了两类,一类是基类继承的方式,一类是接口实现的方式。

你应该要思考的一个问题是

对于

Son s1=new Son();

Person p2=new Son();

这两者调用,对于编译器或者内存分布上会有什么样的区分,为什么p2.如果调Son类下独有的方法就调不到这些问题。

这一切的一切的谜团需要挖掘到内存分布的去一步步揭开,先谈谈IL层面的

 ①IL相关的

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

 从IL代码我们看到了,call指令只用来调用了静态方法,而callvirt指令调用了虚方法和实例方法。

              msdn上摘了一段关于 callvir的相关的,具体可以看下 https://docs.microsoft.com/zh-cn/dotnet/api/system.reflection.emit.opcodes.callvirt?redirectedfrom=MSDN&view=netcore-3.1

 

 简单说 callvir相比于call来说,调哪个不够确定,需要运行的时候在动态去找,所以IL层面暂时看不出什么端倪。

   之前在查阅资料发现 关于 c1.ToString() 编译成IL代码 用的是  call,但是目前经过测试发现 是 用的callvirt ,倒是base.ToString()是用的call,

 

 

 

 

 我们可以看到call和callvirt各有个的作用,call不需要知道变量的实际对象类型,直接使用变量类型来调用方法,所以用它来调用实例方法是没有问题的。而callvirt需要检查变量所指对象的实际类型,根据实际类型来调用方法,而不是根据变量类型,这正好适合多态,实现了通过父类变量来调用子类方法

前面我们讨论的是引用类型,当对于值类型的方法,C#总是使用call指令调用的,因为值类型是密封的,不存在多态。对于未装箱的值类型总是分配在栈上的,所以只需知道变量类型,使用call指令加快处理速度,也就永远不会抛出null的异常。如果是用callvirt调用值类型的虚方法会导致装箱,造成性能损失。virtual会告诉编译器这个函数要支持多态;不要根据指针类型判断如何调用;而是要根据指针所指向的实际对象类型来判断如何调用。这一切就要看运行的时候了,在这之前,我们有必要看下内存分布相关的,因为他和运行有着密切的相关性

②内存相关的

 

关于对象在堆栈和堆上的分配,应该有大量的资料,我们知道对象的变量(比如:c1,c2 ,c3,c4)是保存在线程堆上的,而引用对象是保存在堆上的(new出来的各个对象)。每个堆上的对象都有额外的两个字段,分别是同步索引块和类型对象指针(应该众所周知)。对于这种结构的,分布大致是下面这样的,大家应该比较的熟悉

 

关于类型对象,我们关注的可能稍微少些,毕竟它的结构有些复杂。首先 需要知道的是,在运行程序时,CLR系统会加载3个域,其中最重要的就是我们的默认程序域。在加载程序域时,CLR会根据程序集中的元数据来构建各个自定义类型的对象,比如我们定义了一个CPU内,那么这个就构建一个CPU的类型对象,对象中记录了这个类型的静态字段、方法,所有这个类型的实例对象的类型对象指针都指向这个类型对象。从上图也可看出来

所以调用一个方法的过程是,通过栈上的引用变量找到GC堆上的对象,通过这个实例对象的类型对象指针找到它自身的类型对对象,然后从类型对象中的方法表中调用对应方法(这里指一般情况,不涉及继承和多态)。比如CPU这个类型对象如下,其中有个TpyeHandle,是堆上Cpu对象指向的地方,这个类型对象,除了一些静态字段外,还有指向它的方法表MethodTable.

对于方法表来说,方法表是存放在默认程序域中的是通过对象的类型指针(TypeHandle)和GC中的对象联系起来的。实际这里的TypeHandle是指向方法表的,方法表是放在程序域的高频堆中的。类加载器在当前类,父类和接口的元数据中遍历,然后创建方法表。在排列过程中,它替换所有的被覆盖的虚方法和被隐藏的父类方法,创建新的槽,在需要时复制槽。而类型对象的虚接口图,接口数量也会记录在方法表中。在方法表中有块区域(上图泛白色区域),叫方法槽表(Method Solt Table),它指向各个方法的描述,我们知道实际JIT编译后的指令是存放在JIT堆中的。而在方法槽表下面是静态变量存储区域。决定类型行为的方法保存在方法槽表的区域中。

对于方法槽表的结构。最开始是方法槽数、任何类型的开始4个方法总是ToString, Equals, GetHashCode, and Finalize。这些是从System.Object继承的虚方法。然后后面是类型从基类继承的虚方法,接着是自己类型实现的方法,最后是构造方法。方法槽表的主要就够就是:虚方法--实例方法--构造方法,这样的排序。这里要特别主要的是,基类的实例方法和静态方法是不会继承到子类的方法槽表中的,这里和我们之前理解的,子类会继承父类所有的非构造方法是不同的。因为继承是逻辑上的,而这里是物理上的结构。也就是说,一个类型的方法表槽中,只有父类的虚方法和自己定义的方法(暂不管那4个方法和构造方法)。理解了方法槽表的结构,讲有助与理解继承和多态的本质。

这里再简单说下程序域(Domain)

说到程序域,在CLR执行托管代码的第一行代码前,会创建三个应用程序域。其中两个对于托管代码甚至CLR宿主程序(CLR hosts)都是不可见的。它们只能由CLR启动进程创建,而提供CLR启动进程的是shim——mscoree.dll和mscorwks.dll (在多处理器系统下是mscorsvr.dll)。正如 上图所示,这些域是系统域(System Domain)和共享域(Shared Domain),都是使用了单件(Singleton)模式。第三个域是缺省应用程序域(Default AppDomain),它是一个AppDomain的实例,也是唯一的有命名的域。对于简单的CLR宿主程序,比如控制台程序,默认的域名由可执行映象文件的名字组成。其它的域可以在托管代码中使用AppDomain.CreateDomain方法创建,或者在非托管的代码中使用ICORRuntimeHost接口创建。复杂的宿主程序,比如 ASP.NET,对于特定的网站会基于应用程序的数目创建多个域。

③运行多态相关的

 从方法槽表我们可以知道,子类只会继承父类的虚方法到自己的方法表槽,在初始化程序的时候,每个类型都有一个自己的方法表(对象类型),存储在默认程序域的加载堆中。这个时候就已经明确了某个类型可以调用那些方法(从元数据中获得的)。然后程序开始由JIT进行编译,可以通过VS的调试-窗口-反汇编,结果如下

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

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

再返回到 上面这个Cpu这个结构中

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

override 和 new

明白了多态是如何实现的,谈到.NET中的多态就不能不提到这两个关键字。override是‘重写’,也就是子类重写父类的虚方法,增强父类中的此方法。而new在这里是‘隐藏’的意思,也就是说这个方法和父类虚方法没有仍和关系,是子类中一个新的方法,为什么叫隐藏我们往下看

对于虚方法的继承,CLR处理如下。首先编译Cpu类型,产生Cpu的方法表,其中包括了Cpu::fun()的槽,它指向了自己的实现地址。然后编译IntelCpu类型时发现他继承于Cpu类型,然后Cpu类型的整个虚方法槽布局被复制到IntelCpu类型的方法表中,当然槽的内同也是一样,也就是说,这个时候IntelCpu::fun()方法和Cpu::fun()指向同一个实现

 然后系统发现方法使用了override关键字,也是就把IntelCpu::fun()中的实现地址该为自己的实现地址,当调用时,就不会调用Cpu::fun()的实现了,从而实现了多态

 而对于new关键字,系统采用的方法是重新建立一个方法槽,名字也为IntelCpu::fun()。这个时候方法表中存在了2个fun()方法了。到底调用那一个方法呢,这就要根据方法类型了

到这里,让我们再整理一番上个的代码, 下面 InterCpu和 NewCpu都直接继承自 Cpu

   

 ①对于 变量类型同对象类型不同(就是父类引用子类对象)

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方法。更具上面的图就很清楚的看出了运行时的结果。

②变量类型同对象类型相同(就是最正规的引用)

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方法的地址 ♥♥♥♥

 可以看出 对于override和new,new是自己新建的这个方法。而如果直接使用override方法,被重写的方法仍旧是虚方法,可以被自己的子类继续重写,一层一层

再看个例子

  

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

 

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

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

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

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

如下图所示:

 

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

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

再次强调下的是 平时我们说的继承,子类会继承父类的所有方法(包括私有方法,只是不能访问,但不包括构造方法),这个实际是逻辑上的继承,而真正物理上的集成只对虚方法有效,而且对于非虚方法也不需要去继承到子类中,方法表中是没有其他方法的身影的。

最后再 说下 接口 抽象类相关的

   

 上面的列子包含了抽象方法,虚方法和接口方法,以及他们的继承和重写。实际上抽象方法和接口方法都是虚方法,只不过他们不需要也不能显示的使用virtual关键字,编译后看下

 

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

再看下Cpu的编译后的

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

 

原文地址:https://www.cnblogs.com/wwkk/p/13414622.html