前面探索了C++对象的内存模型,其中简单的涉及到了虚函数,作为C++实现其多态的一个重要机制,这里进一步探索下虚函数机制,以前也看过网络上关于虚函数机制的一些精彩的文章,但现在决定自己再分析这个虚函数机制以加深理解,看与自己动手探索还是有区别的。
\一、异质链表**
1、“is-a” 原理
在公有派生方式下,对派生类的对象里的基类子对象的水平访问与基类单独生成的对象的访问是一样的。也就是说,我们完全可以把public 继承方式的派生类的对象当做基类的一个对象来使用。反过来则不行。这就是“is-a”原理
在公有派生方式下,基于“is-a”原理,我们可以得出以下几点:
- 一个派生类的对象可以赋给基类对象;
- 派生类对象可以初始化基类对象;
- 派生类的对象可以初始化基类的引用;
- 派生类的对象的地址可以赋给指向基类对象的指针。
前面三条都是一个性质,派生类对象赋给基类对象的时候,都是调用基类的赋值构造函数,这样赋值的时候,只会将派生类对象中继承的基类成员赋值给基类对象成员,不会发生内存越界的情况。下面是一段截取的反汇编代码:
1 | 60: base_obj = derived_obj; |
最重要的是第四条,对于这个基类的不同派生类的对象,我们可以使用指向基类的指针把它们(派生类对象)穿成一个链表,这个链表对于我们实现C++的多态性有很大的价值,被称之为异质链表。
通过基类指针将这个基类的不同子对象串联起来。一个充分必要条件就是,这几个子类都有一个公共的基类,且都与这个基类有“is-a”关系。
对于这我们可以这么理解:派生类的对象所占的存储空间要比基类的对象大,因为派生类继承了基类非私有成员函数和数据,再加上本身的成员数据,实例化的对象自然会比基类对象大,自然,派生类指针的寻址空间要比基类指针的寻址空间大。但由于对象的头部分是一样的,所以即使有超出基类指针所寻址的部分也能根据偏移量正确寻址,相反,如果派生类指针指向基类对象,则会把一部分不属于该基类对象的内存也包括进来,那么当派生类指针指向基类对象来使用派生类的函数的时候可能会发生严重的错误。
1、静态联编
计算机应用程序对它的变量、对象的访问,以及对函数的调用实际上都是通过地址,而不是像源程序那样通过名字。程序中的变量名、对象名和函数名都是在编译的时候被编译器变换成了地址,这个地址是逻辑地址,在程序运行的时候再转换为物理地址。总之,程序最终运行的时候对各类变量的访问都是通过地址。
函数名字也会被编译器转换为地址,这样对这个函数的调用,就编译成了去执行这个地址里的指令(汇编语言更清晰的表达出这点),函数的调用关系就变成了对某个地址的连接。而这是编译器在编译时做成的连接,在运行的时候是不可能改变的,这种连接我们称之为静态联编。
2、动态联编
先说下多态。多态性实质就是“一个接口,多种方法”。就是基于“is-a”原理,把不同子类的对象都当做父类来看待,可以屏蔽不同子类对象之间的差异,写出通用的代码,增加代码的适用性。赋值之后,父对象就可以根据赋值给它的子对象的特性以不同的方式运作。多态最常用的就是上面“is-a”原理引申出来的第四点。通过基类对象指针去操作子类对象的函数,来实现多态性。当然基类中也要定义这个函数接口。这样的话问题就来了,像第一点所说的,函数名在编译的时候就被换算成了一个固定地址,那么当基类指针去操作同一个函数接口时,最终都会跳转到同一个地址,那就无法实现多态了。针对这个问题就引入了虚函数机制,虚函数机制是基于动态联编的,动态联编是相对静态联编来的,它的函数名对应的地址是在运行的时候才决定的,在编译时并不能确定各个函数的对应地址(这里是说编译器不确定,函数本身地址肯定是定了的),只有在运行的时候根据是隶属于哪个对象才去调用这个对象的同名函数。
默认状态下都是静态联编,为了引入动态联编,我们引入了虚函数机制,在对应的函数名前加上 virtual 关键字即可。然后剩下的工作就交给编译器去完成了,编译器才是实现多态性的真正幕后英雄。
三、虚函数机制
有了前面的异质链表和动态联编,我们开始解开虚函数的神秘面纱。
1、单一继承
1 | class Base |
结果输出就不用多说了。
我们看看 ptr 指向的对象的布局:
看到虚表指针指向的虚函数表中第一个位置存放的是子类对象的函数,显然子类的虚函数已经覆盖了父类的同名同类型虚函数。
父类和子类都定义有虚函数,且存在不同类型的,情况就会如下所示
1 | class Base |
有意思的是调试的时候,Visual Studio 窗口中居然没有显示虚函数表的第三个函数指针,所以上面额外测试了一下。子类实际的虚表如下:
虚函数表:虚函数指针都在位于同一个函数指针数组中,很容易寻址到。
1 | 1> Derived::$vftable@: |
可以得出的是, 没有覆盖的情况下,基类的虚函数指针放在虚表的前面,然后再是派生类的虚函数指针,其中覆盖的放在自身的没有不覆盖基类的前面。多个覆盖自然就是按顺序来。
上面派生类只会有一张虚函数表,基类和派生类的虚函数指针都放置在其中。
那么虚函数的调用过程是怎么样的呢?这里修改了上面的程序,把基类的 fun_b() 函数退化为一般函数,然后分别调用一个虚函数和这个一般函数,看看它们的反汇编代码:
1 | 66: ptr->fun_a(); |
上面fun_a() 是虚函数,fun_b() 是一般函数,通过汇编代码看看这两个函数的调用过程,可以看出一般函数是通过类直接调用,没什么特殊之处。重点看看虚函数的调用:
第二行代码(第一行汇编 mov eax,dword ptr [ptr] ),取基类指针指向的数据(虚表指针);后面的 mov edx,dword ptr [eax],定位到了虚函数表首位置;第六行 mov eax,dword ptr [edx],即得到对应位置(首位置)的虚函数指针(如果调用的虚函数指针不是首位置,那么这里会是edx+x),后面 call eax 则是通过虚函数指针调用该虚函数。
总的说来,虚函数的调用是先定位到对应类的虚函数表,然后再去找对应位置的虚函数指针,继而调用该虚函数。
单一继承下,即只继承一个基类,那么派生类都只会产生一个虚函数表,前面已经说过了。然后所有的虚函数指针都放在这一张虚表中,派生类同类型虚函数会覆盖基类虚函数。虚函数机制下,编译器并不是简单的把基类指针类型编译成对应基类,而是按照这个基类指针去找到它指向的对象,换句话说不是看它的指针类型而是看它指向什么对象,对象下面有虚表指针,然后就是按照上面说的层层解引用调用对应的函数。
上面就是动态联编的过程,很显然效率要比静态联编低。
这虚函数指针的放置简单的说就是:先基类,然后派生类,派生类有覆盖的则直接对应覆盖。
2、多重继承
前面博文说到,多重继承(非虚拟继承)的情况下,继承多少个含虚函数基类(自身带虚函数表),派生类中就会生成多少个虚函数表。
1 | class Base1 |
这里的派生类公共继承了两个含有虚表的基类,自然地派生类中会产生两张虚表。
在讨论虚函数表前,先看看main函数,这里分别用了两个不同的手法调用函数 fun_a,第一个是静态联编,直接通过对象的名字来调用该对象的虚函数,无需额外的去定位对象了,而后面的动态联编,则要通过引用或指针先找到对应的对象,再去调用该对象的虚函数。
派生类中的虚函数表:
1 | 1> Child::$vftable@Base1@: |
这样 当不同基类类型的指针指向同一个派生类对象时,都能够调用到实际的函数。改一下main函数:
1 | int main() |
上面两次调用的局部汇编code:
1 | 77: Base1 &ptr1 = c; |
后面调用的指针位置调整,就是根据由哪个基类指针指向来调整的(继承的基类在派生类中的位置)。这样不管继承多少个带虚表的基类,最终都能准确的调用到对应的虚函数。
也可以看出派生类中的两张虚表指针并不是全部在内存的首位置,而是等继承的第一个基类“放置”后再处理第二个。。
上面这一切都是编译器的功劳,我们只是通过基类指针简单的调用虚函数,然后内部的各类转移都是编译器的功劳。
号外:构造函数,静态函数,内联函数,普通函数(非成员函数)、友元函数不能作为虚函数,成员模板函数也不能:虚函数仅适用于有继承关系的类对象。
虚函数是基于虚函数表的(内存空间),构造函数如果是虚函数,调用时也需要根据虚表寻找,但是虚表的产生依赖于构造函数(看下面的虚表指针的初始化),不能本末倒置,另外构造函数不能被继承(重写),静态函数是属于 class 自己的,必须有实体,其也不能被继承,内联函数在编译时展开,跟虚函数完全不是一个调调,水火不容的关系,至于普通函数,友元函数等,这两者是类外函数,不能被继承。
\四、虚表指针的初始化**
没有虚函数的类对象自然不会产生虚表指针,而有虚函数的类对象就会产生虚表指针,那么虚表指针是什么时候安插在对象中的呢?答案就是构造函数。
我们可以在构造child实例处设置断点,然后反汇编跟踪编译,进入child的构造函数下的Base1构造函数,我们会发现:
1 | 35: Base1(int i = 1) :a(i){} |
可以看出, 虚表指针是通过编译器在构造函数内安插在对象的前面的。另外也可以看到虚表指针的初始化都是基于this指针的,只有成员函数才有this指针,这就是为什么虚函数必须作为成员函数是用的原因。
在虚表指针的初始化过程中,对象执行了构造函数后,就得到了虚表指针,当其余代码访问这个对象虚函数时,会根据对象的首地址,取出对应的虚表函数,当函数被调用时,会间接访问虚表,得到对应的虚函数首地址,然后调用执行。说白了就是地址转移来转移去的。
这种通过虚表间接寻址的情况只有在使用对象的指针或引用来调用虚函数时候才会出现,当直接使用对象调用自身的虚函数时,无需查表访问。就是前面代码中的静态联编方式访问。
五、多态性
前面说了动态联编和虚函数机制,就不能不说说这个C++中很重要的一个特性:多态性
多态性,简单地说就是“一个接口,多种实现”,就是不同的对象对应同一消息产生不同行为。一般而言,用同一个接口函数,去执行不同的函数体。执行哪个函数体看关联的是哪个对象。
下面直接通过一个简单例子来演示这个多态性。
1 | class Course |
程序中不同的课程,通过同一个消息来选择,选择的结果却由课程对象自身决定。
虚函数机制是这个实现的内在基石。多态性是外在的现象。
最后再补充一下:C++明白指出,当 derived class 对象经由一个 base class 指针被删除,而该base class 带着一个 non-virtual 析构函数,其结果是未有定义——实际执行时通常发生的是对象的 derived 成分没被销毁,而其base 成分通常会被销毁,造成一个诡异的“局部销毁”的对象。
如下面一个例子,如果base class 是带着一个 non-virtual 析构函数,那么下面程序,派生类将不会进行析构:
1 | class Base |
即使 base class 中不含虚函数(注释掉Dosomgthing函数前的virtual),如果不定义虚析构函数,那么派生类的析构函数将不会被调用,所以如果base class 是作为基类使用,其析构函数也应该定义为虚函数。
虚析构函数与虚函数是相伴而生的。Scott Meyers 在《Effective C++》中建议:如果class 带有任何 virtual 函数,它就应该拥有一个 virtual 析构函数。理由很简单,我们希望所自动调用的析构函数,能够通过动态联编方式去调用所关联的实际对象的析构函数,而不是简单的指针的基类型对应的基类对象的析构函数。
盲目的添加虚函数会增大对象内存空间,我们的心得是:除了上述情况,如果该 class 作为基类使用,那么它就应该声明一个 virtual 析构函数。