虚函数的实现基本原理
首先要明白的一点是 多态是什么
多态就是用父类的指针 指向子类的实例,也就是泛型技术的一种
泛型技术 就是试图用不变的代码 实现可变的算法
而泛型技术,要么在编译的时绑定,要么在运行时绑定 C++的多态 也可以从这两个方向入手 静态多态 动态多态
静态多态
编译的时候的多态 通过重载 模板实现
动态多态
绑定的时候在运行的时候绑定 通过虚函数实现
虚函数通过一张虚函数表来实现的,也就是虚表
虚表里有什么
虚表里有什么呢? 存的主要是一个类的虚函数的地址 。这个表用来解决继承,覆盖的问题,保证其能指向真实存在的函数
所以当你实例化一个类时,如果这个类有虚函数,那么这表就会分配在这个实例的内存里
那么虚函数表是什么创建的?编译阶段,编译阶段就已经填充好了虚函数表里的内容, 可是为什么又说是在运行的时候确定的呢?因为vptr 虚函数指针是在实例对象后出现的,运行的时候,就是确定这个vptr的走向 所以表指针是存在于这个实例的内存里
如此依赖,用父类指针操作一个子类,虚函数表就由此体现了。 通过这个表 指明应该调用的函数。
那么,一个对象A,在内存中的分布是什么样的呢?
内存分布
定义一个对象, A*a=new A 他的分布长这样:
main函数里,栈帧上,要有一个A类型的指针,指向堆里分配好的对象A实例
A实例的头部 是一个vtable指针,紧接着是A对象按照声明顺序排列的成员变量
vtable指针 指向的是代码段A类型的虚函数表 的 第一个虚函数的起始地址
(为什么要强调是第一个虚函数的起始地址呢? 虚函数表有一个头部,紧接着才是按照声明顺序排列的虚函数)
(再去看看虚函数表里有什么 有两个析构函数,这是因为对象的构造方式 有两种,栈构造 堆构造 两种析构方式 栈内存的析构不需要delete 还有一个typeinfo,存储A的类基础信息 ,包括父类与类的名称,C++关键字 typeid 返回的就是这个对象 typeinfo也是一个类,没有父类的A来说,tinfo是class type info类型 )
综上,虚函数表不是单独存在,而是虚表的一部分
虚表里
- 会有虚拟继承
- 会有对象起始地址的偏移值
- 会有RTTI information 对象指针
- 会有虚函数表 存放虚函数指针列表
虚表的存在只是虚拟函数的50%,还需要能够指出每个对象的对应vtbl,这就是virtual table pointer 建立这种联系 每个声明了虚函数的对象 都有它,他是一个看不见的数据成员,指向对应类的虚表 这个数据成员 是vptr,编译器把它加载对象里,位置只有编译器知道
虚函数的调用过程
调用一个虚函数 通过对象内存中的vptr 找到虚表,通过虚表找到虚函数的实现区域来调用
被执行的代码 必须和调用函数对象的动态类型相一致。
编译器就是要探求如何高效提供这种特性
大多数编译对象 都是 虚表+虚表指针实现 类声明了虚函数 继承了虚函数 ,就会有一个虚表,虚表关键在于有一个函数指针数组
(链表?数组 无伤大雅)
这个数组里每个元素都是函数指针,都指向这个类的虚函数 ,同时该类的每个对象 都有一个vptr,指向虚表的地址
那么 如果继承呢?
- 子类的虚函数表 先把父类的虚函数放在前面,再放自己的虚函数指针 ,这就是覆盖
- 多继承的情况下,每个父类都有一个虚表,子成员函数放在第一个父类的表中,多继承的情况下,实例对象的内存结构 不只是存在一个虚函数表的指针 有几个基类中有虚函数,就会有几个虚函数表指针
虚函数性能
虚函数调用的过程:
-
通过对象的vptr找到vtbl 因为编译器知道在对象内哪里能找到vptr嘛,编译器放置的 那这步的代价,其实也就是偏移调整
-
然后找对应vtbl内指向被调用函数的指针
这步也简单,编译器内为每个虚函数 分配指针,这步也就是进行一个偏移 -
然后呢?
单继承的情况下,虚函数的代价和基本的非虚函数调用一样 而多继承的情况下,会根据多个父类生成多个vptr,这样对象查找vptr的操作会复杂 但不是直接影响
虚函数运行的代价是 虚函数不能内联函数
为啥呢? 为啥不能内联 内联函数 需要在编译的时候确定被调用的函数体来替代函数调用的指令,而虚函数的虚 是指 直到具体调用才能知道被调用的是哪个函数
所以 没法在编译的时候进行内联函数的展开
如果通过对象直接调用虚函数 当然可以内联,但大多数的虚函数是通过对象的指针 或引用 被调用的 所以就不能内联咯
空间占用
多态机制的实现,需要给类建立一个虚函数表,所以虚函数的代价:
- 增加类的体积
一个虚函数表的体积,相当于几个函数指针的体积
如果有大量的类,那么vtable就会占用大量的地址空间,而且类的继承,如果只重写一小部分,那么就会有大量重复的内容,浪费地址空间
拓展
静态联编 动态联编
笔者记得之前看到过动态联编和静态联编,这是什么呢
其实如果你没有采用指针和引用的方式来使用多态的话,编译器在编译的阶段就能确定你编译的是啥了,也就是可以转成内联函数来理解的。这里就是静态联编
而采用指针和引用利用多态,就会是动态联编,可以去看代码