(六)虚表的本质
其实我们大家应该都已经猜到了:我们虚表的本质就是一个函数指针数组。通过访问这个函数指针数组就可以得到我们想要的虚函数的地址,之后通过这个地址就可以调用我们相应的虚函数。我们这个函数指针数组是以nullptr结尾的,也就是全0。跟上图中显示的效果也完全相同。
那么我们就可以根据这个特定编写出如下的代码进行验证我们最后一个地址到底是不是我们在派生类当中新加入的虚函数的地址了。所示代码如下:
经过这种方式就可以打印出我们虚函数表当中所有存储的虚函数了。很明显之后的新加入的指针就是我们在派生类当中创建的虚函数。但是我们在验证的时候需要进行注意:实际上我们的验证是不符合规范的。对于我们类当中成员函数的访问都是通过this指针进行的,但是我们是通过函数的地址进行强制访问,有的编译器会产生报错。提示我们访问的规则不符合规范。在这里我们仅作为了解即可。
需要我们注意的是:在派生类对基类进行继承的时候,我们的虚函数表也会一同被继承下来。但是这个继承所进行的操作是先创建一个函数指针数组,之后将我们基类当中虚函数表所存储的虚函数的地址全部复制到我们新创建的函数指针数组当中。
之后经过派生类对构成重写的虚函数地址的覆盖以及新创建虚函数地址的添加操作之后就得到了一张新的虚函数表。我们需要注意的是:虚函数表当中存储的数据可能相同,但是虚函数表的地址并不相同,所以并不是共用一张表。
对于不同的类来说,虚函数表是不相同的,虚函数表当中存储的数据可能相同。对于一个类所创建的多个不同对象来说,其公用同一张虚函数表。
(七)虚表在内存当中存储的位置
那么我们创建好的新的虚函数表存储在什么位置呢?我们先进行思考:存放在栈区?由于我们的很多数据都是存放在栈区的,在栈区当中存储的数据也会进行压栈,很难进行扩大容量等行为。很明显存放在栈区并不是我们想要的结果。
存放在堆区吗?也不是没有可能,如果是存放在堆区上,那么我们创建虚表的时候进行的操作大概就是向堆区申请一块空间,之后有新的虚函数产生就重新申请一块更大的空间进行存储我们虚函数的地址。但是反复的重新申请又会增加很大的系统负担,如果一下子开辟很多空间又会造成内存空间浪费的现象,还有更好的选择吗?
存放在静态区吗?存放在静态区的概率似乎比存放在堆区的概率还大。因为我们的成员函数就是存放在一块公共的空间便于我们进行调用的。如果存放在堆区就跟我们成员函数的性质相一致,还不会有堆区跟栈区的困扰。
存放在常量区吗?对于我们不允许进行修改的数据我们可以将数据存储在常量区当中。思考一下:我们的虚表可以进行修改吗?似乎我们没有对虚表进行修改过,但是在派生类进行重写的时候系统自动改变了我们虚表当中的内容,这样算不算是修改了呢?虚表会存储在常量区吗?
我们经过代码对上述的疑问进行验证:
经过验证我们会发现,我们虚表的地址跟我们常量区存储数据的地址更加接近所以虚表的地址应该是存储在常量区上的。
(八)静态绑定和动态绑定
所谓的动态绑定跟静态绑定实际上就是我们多态的形式。多态的形式可以分为两种:一种是静态的多态,一种是动态的多态。
1.静态多态
对于我们静态的多态来说实际上就是函数的重载。静态的多态在编译的时候就已经确定好了想要调用的函数的地址。多态的形式表现为我们调用相同的函数名,通过传不同参数的形式实现不同的效果。
2.动态多态
对于我们动态多态来说其实就是我们本章节讲的多态的形式。动态的多态在运行的时候通过查找相应的虚函数表调用相应的函数地址,进而实现多态的作用。其主要的实现方式就是虚函数的重写。
(九)纯虚函数和抽象类
所谓的纯虚函数其实就是在我们设置的虚函数的后面加上=0的形式,这种类型的函数就叫做纯虚函数。含有纯虚函数的类叫做抽象类,抽象类不能实例化产生相应的对象,因此只能通过继承之后才可以使用。所以纯虚函数和抽象类其实是共同存在的,作用就是强制我们进行虚函数的重写。通常情况下我们会将一个没有实际实例的基类设置成为抽象类。所示代码如下:
我们会发现当我们将基类设置成为抽象类之后,我们定义的全虚函数就会被编译器强制要求重写,如果不进行重写系统就会产生报错。我们尝试将上述的函数进行重写:
我们会发现经过重写之后代码就可以正常运行了。
实际上抽象类的作用经常被用来定义某种事务的基本参数,也就是这种事物一定要具有的属性和参数。例如:我们的汽车类就规定了我们不同类型的汽车在创建对象的时候就一定要具有自己的速度信息和容量信息等等。
(十)多继承当中虚函数的重写
之前我们说到的继承都是单继承的形式,对于我们的派生类来说只有一个基类,但是我们的继承不仅仅有单继承还有多继承。那么多继承当中的虚函数重写是什么样的呢?
在单继承当中派生类继承基类之后就会创建一张自己的虚函数表,之后将基类虚函数表当中的数据复制一份,再对我们复制下来的虚函数表进行适当的覆盖和添加,之后就形成了派生类当中的虚函数表。其实再多继承当中进行的操作跟我们单继承当中所进行的操作很相似。
当我们不对多继承当中的虚函数进行重写的时候就会发现多继承的继承模式实质上跟我们单继承的继承模式完全相同。对于A类我们会创建一张属于A类的虚函数表,对于B类我们会创建一张属于B类的虚函数表。当我们使用C类同时继承A类和B类的时候,我们这两张虚函数表也同时被继承下来。编译器会自动将两张虚函数表当中的虚函数复制一份添加到我们的派生类当中相对应的虚函数表当中。
当我们对独属于类A或类B当中的虚函数进行重写的时候实际上就是进行两份单继承多态重写的方式。编译器会自动将继承A的虚函数表当中对应的函数地址进行覆盖得到一个完整的派生类虚函数表A,同样的对于读书与B类的虚函数进行重写的时候,也会对相应的函数地址进行覆盖得到一个完整的派生类虚函数表B。对于没有进行重写的虚函数会保持原本基类当中的函数地址不变。
但是如果多继承基类当中存在多个相同函数名的虚函数,并且我们在派生类当中对该虚函数进行了重写操作的时候就会产生不同的作用。
首先我们对类A和类B进行相同函数名的虚函数进行重写操作。 之后使用两个两个基类的指针尝试访问构成多态的函数。
使用基类A的指针进行访问C类重写的函数,调用的应该是C类当中的重写之后的虚函数,使用基类B的指针进行访问C类重写的函数,调用的应该也是C类当中重写之后的虚函数。这两次调用的函数应该相同。我们运行的结果也正如我们预期中的那样产生了两次相同的结果。
但是通过观察C类当中的虚函数表我们会有新的疑问,为什么我们虚函数表当中对应的位置函数的地址不相同呢?不是调用的是同一个虚函数吗?
我们可以通过反汇编的形式进行分析出现上述问题的原因。
通过反汇编的形式我们可以发现:当我们使用类A进行调用虚函数的时候,我们只需要通过一次call指令和一次jump指令就可以跳到我们重写虚函数的位置。但是对于使用类B进行调用虚函数的时候我们得需要通过两次jump函数才可以找到我们重写虚函数的地址。中间的一次jump操作我们进行了一个寄存器当中的减操作。
根本原因其实是因为在多继承当中类存放的位置不同。
我们可以发现在C类对象当中由于我们先继承A类所以会先在C类当中构建出一个A类,之后才会构建出我们的B类对象。这也就造成了对于我们的C类对象来说其中的初始地址跟我们C类中的A类的初始地址是相同的。
当我们使用A类指针去接受一个C类的对象的指针的时候,我们是可以直接进行使用的。但是对于我们的B类来说就不行了,我们需要先计算出前面A类的大小,之后跳过A类的地址才可以得到我们B类的地址。
而我们中间一次jump操作所带来的寄存器的减操作,实际上就是调整好我们传入的地址,即传入B类当中的this指针。eax是保存this指针的寄存器。我们可以进行验证一下。A类当中仅仅只存储有一个虚函数表,这个是一个指针类型的数据。所以占4个字节。减去刚好等于我们B类在C类当中的位置。如果我们对A类加上一个成员变量的话,我们寄存器的见操作数值也会相应的变化。
我们会发现经过测试程序运行的现象跟我们预期的结果刚好相符。
所以在多继承的多态当中对同名虚函数进行重写所对应的虚函数的地址不同,这是为了更正我们传入的对象的地址所进行的封装。当然我们也可以在底层完全隐藏,这样的话也有可能会在窗口上显示相同的虚函数的地址,这仅仅是不同编译器所带来的不同的效果而已。
(十一)菱形虚拟继承当中的虚函数
实际上菱形虚拟继承当中的虚函数很简单,仅仅是将两个看起来很复杂的对象进行了嵌套而已。回顾一下菱形继承的存储方式:为了解决数据冗余和二义性的问题。我们将中间的派生类设置为虚继承的形式。被设计成虚继承的类会将基类部分分离出来,由一张虚基表进行管理。而在我们中间的派生类当中仅仅保存着新加的数据和我们的虚基表的地址。在虚基表第二个指针的位置上会记录派生类和基类地址的相对偏移量。
所以我们的重合的派生类当中会存储两张虚基表,加上一些新添加的数据。
当我们加上多态的概念之后就变成了复合的形式。
但是需要我们注意的是:在中间类进行重写的时候,我们的重合类也需要进行重写操作。如果不进行重写的话,那么我们在通过重合的派生类调用函数的时候就会产生歧义。因为我们有两个平等的调用对象,也就是中间类当中的B和C类。因此在中间派生类进行重写的时候,我们重合节点对应的类一定要进行重写,否则系统就会进行报错。
当我们对重合的派生类进行重写之后程序恢复正常。
那么我们的虚继承的虚函数表是怎样进行存储的呢?
当我们在中间的派生类对虚函数进行重写的时候由于新创建的虚函数主体并不相同,所以会创建一个单独的虚函数表进行相应数据的记录。所以中间会生成两张虚函数表。同时我们A的虚函数表被D类所继承,如果D类对A类进行虚函数的重写操作,会直接使用新的虚函数地址覆盖我们原有的虚函数的地址。那么我们进行存储的一共有三张虚函数表。
综上所述,对于菱形虚拟继承当中的多态来说我们一共需要创建五张表。对于这一部分的知识在实际的生活当中几乎不会被使用,所以我们仅仅作为了解即可。