我们已经表明,非虚类的对象实例不包含虚指针,编译器在编译阶段也没有为非虚类没有构建虚表.而本篇我们会从简单的单继承链分析虚类中虚表构造过程和内存布局。这一切假定你有如下基础
- 对gdb调试器使用有一个比较全面的了解。
- 对栈内存管理和堆内存管理有所了解。
- 类/结构体的内存对齐操作有所了解
- 对类的继承特性有所了解。
本文是从编译器的角度结合GDB调试器来理解虚表的创建过程,而不是像绝大部分份文章逼格高一般抛一大堆和虚成员函数相关的理论,而是从更务实从内存分析的角度讨论为什么在使用虚函数过程中需要虚指针和虚表。
明确虚函数的目的
我们要明确在类继承中使用虚函数的目的。在开发需求中,我们旨在让调用层代码保留相同的公共接口,因为调用层代码不需要关心被调用层的功能实现细节。那么虚函数就是**让不同的派生类将继承自父类的同一个虚成员函数(接口)的根据派生类的功能需求进行不同行为的实现,以此达到不同的派生类提供调用层的决策代码同一个函数接口的不同实现版本,从而保持对调用层代码逻辑无需变动,而且隐藏了同一个函数接口的不同版本的实现细节。
示例导入
#include <iostream>
class Employee{
public:
bool iService=true;
virtual ~Employee(){};
virtual void add_salary(){
std::cout<<"add_salary method in Employee"<<std::endl;
}
};
class Teamer:public Employee{
public:
int idNo=1000;
virtual ~Teamer(){}
void add_salary(){
std::cout<<"add_salary method in Teamer"<<std::endl;
}
virtual void info(){
std::cout<<"Teamer info for Teamer"<<std::endl;
}
void show(){
std::cout<<"show method in Teamer"<<std::endl;
}
};
int main(void){
Employee *tm1=new Teamer();
Employee *tm2=new Teamer();
Employee *pp1=new Employee();
Employee *pp2=new Employee();
delete tm1,tm2,pp1,pp2;
}
在这里我们可以尝试打印*tm1,*tm2,*pp1和 *pp2,如下图所示
从上图的输出中,我们要引入一个虚指针(_vptr)的概念
- 虚类的对象初始化时会自动创建一个隐藏的数据成员_vptr指针指向虚表,此前声明该虚类的对象编译器也创建了该虚类的虚表。
- 后续同一个虚类所有对象实例共享同一个虚表,截图中的tm1和tm2的隐藏指针指向同一个地址0x400cf0,pp1和pp2的虚表是同理如是.
- 虚表表当前的地址是一个已经+16字节偏移后的内存地址
另外我们还打印出所有Teamer对象和Employee对象,他们获得内存分配都为16个字节。因此我们不妨在查看我们刚才实例化的所有对象。
查看对象的内存数据
现在我们不妨看看刚才实例化的各个对象的内存布局,使用x命令,因为每个对象的堆内存块尺寸都为16个字节,因此我们使用x/16xb将他们的内存数据转存到屏幕中,如下图所示。
- _vptr在虚类的对象中就占用8个字节,该_vptr存储了指向该虚类的虚表的内存地址值。
- iService是一个bool类型仅占用1个字节,另外高位的3个字节空间由于内存对齐的原因都以0填充。
-
idNo是一个4字节的int类型,对于Teamer的对象0x03e8的值就是十进制的1000,对于Employee的对象这里的4个字节由于按8字节内存对齐,仅作为填充位之用。
ss17.png
备注:这里我们回顾了内存对齐的相关知识。
探究虚表的内存布局
我们从前文打印的第一个Teamer对象 tm1的信息中,可以知道其_vptr指针指向0x400cf0,你是否发现“<虚表 for Teamer+16>”的字样。这个其实表明0x400cf0是已经+16字节偏移后的地址值
我们已经在前文提到在首个的新的虚类对象且初始化时,编译器会该类动态创建一个虚表,但为什么每个不同虚类的虚表都要额外偏移16个字节呢? 在本示例中,我们不妨减去这个偏移量,也即得到0x400ce0这个地址,然后使用x命令,该命令将300字节的内存数据转储到屏幕。
(gdb) x/300xb 0x400ce0
上面的命令以十六进制格式打印300字节,从0x400d00开始。 为什么要这个地址? 因为在上面我们看到类Teamer的虚表指针指向0x400d10,该地址已经偏移0x10个字节,即减去0x10就能得到原本虚表的地址。
下图中_ZTV是虚表的前缀,_ZTS是type-string(名称)的前缀,_ZTI是type-info的前缀。
我们从下图可以得到很多虚表的内存细节。
- 每个Teamer虚表存在一个虚表表头占用16个字节,前8个字节0填充,后8个字节包含一个指向与该类对应的typeinfo表的地址(没必要理会,只需知道他们占用16个字节即可)。
- 每个typeinfo表的前面也包含一个typeinfo name的信息(没必要理会,l罗列出来只是让你知道有这么一个描述字段)
-
绿色的部分就是不同虚类的虚表,虚表就是包含了该类定义的所有virtual成员函数的函数地址。
我们可以从上图中绿色部分的内存数据中即每行冒号之后的8字节空间提取有用的数据,例如
- 0x400cf0到0x400d08的内存区域中的内存数据,对应的是Teamer类类虚表中virtual成员函数地址的条目。
- 0x400d30到0x400d40的内存区域中的内存数据,对应的是Employee类虚表中virtual成员函数地址的条目
我们这两个内存区域的数据分别整理成如下表,注意写本文时使用的是CentOS 7的x64小端机器,因此读取图中的内存数据时,是从右向左读取,因此整理下表每个内存位置对应的值,并且分别是有info symbol命令 再次查看每个内存位置的值对应的具体含义。
结合整理如下表可知:虚表中的地址值分别代表虚拟类中对应虚函数的地址。
更简单获取虚类的虚表条目的另外一条命令就是info vtbl,这里就不展示了,我们看到上图的虚表中的虚解构函数都成对地出现,我们先暂不讨论为什么会这样,因为我日后会令起一文再阐述该问题。
- 第一个解构函数,称为完整对象解构函数(complete object destructor),执行销毁操作时无需在对象上调用delete()。
- 第二个解构函数称为删除析构函数( deleting destructor),在销毁对象后调用delete()。
- 两者都摧毁了任何虚拟基类.一个独立的非虚函数称为基类对象解构函数(base object destructor)执行对象的销毁操作,但不执行其虚拟基类子对象的销毁操作,并且不调用delete()。
- 非虚函数是静态绑定的(编译时绑定),因此在虚表中不存在任何非虚函数。
虚表构建细节
我们仍然使用上文的调用示例代码
int main(void){
//
Employee *tm1=new Teamer();
Employee *tm2=new Teamer();
Employee *pp1=new Employee();
Employee *pp2=new Employee();
delete tm1,tm2,pp1,pp2;
}
从上面的示例代码中我们已经知道
- 首先,每个使用虚函数的类或从基类派生的虚函数的类都被赋予自己的虚表。该表只是C++编译器在“编译时”设置的静态数组。虚表包含当前类中所有虚成员函数的函数指针的相关条目,那么填入虚表的虚成员函数指针有四种来源。
- 派生类本身原创定义的虚函数,例如上图的Teamer::info()函数。
- 从父类继承的虚成员函数,且该函数未被派生类重写。
- 从父类继承的虚成员函数,但该函数已被派生类重写。值的注意的是,虚表的虚成员函数指针始终指向该类中的最新的派生版本的虚成员函数。理解这句话非常重要!举个例子Teamer类从Employee类继承了add_salary()函数,但Teamer类重写(注意:不是重载)了该add_salary()函数,对于Teamer虚表来说,填入表中的add_salary()函数的地址是0x400b3e,而不是父类的add_salary()的地址0x400ab4。
- 若当前类定义了虚解构函数,那么该类的虚解构函数的解构函数的地址会“成双成对”地填入虚表中。按照惯例,由于定义类时优先定义解构函数,再实现其他成员函数,因此该虚解构函数对的地址通常会出现在表中头两行,上图是很好的例证。
- 然后,当类对象实例化时会将*_vptr设置为指向该类的虚表。例如,当创建类型为Teamer的对象时*_vptr设置为指向Teamer的虚表。构造类型为Employee对象时,*_vptr设置为指向的Employee的虚表。我们这里先不讨论virtual解构函数,目前只针对其他虚函数进行讨论。
- 对于基类Employee类型的对象,它只能访问Employee的成员,Employee类型的对象无法访问Teamer类的的成员函数,因为地址为0x400ab4的地址仅指向Employee::salary()
- 同理,Teamer类型的对象也只能访问Teamer::add_salary()和Teamer::info()。
多态:
理解完虚表的内存布局和构建细节之后,这个时候才合适抛出一些理论性的东西,多态是面相对象语言一个重要的特性,多态即让同一个用户自定义类型的对象在不同的决策时机呈现不同的行为实现。
C++中的多态就分为
- 编译时多态:就包括类成员函数重写和operator函数重载
-
运行时多态:C++编译器在运行时,根据决策逻辑判断传入所对象的类型,然后查找并根据该类虚表中的虚成员函数的地址,以进行动态调度目标类中的成员函数。
ss17.png
小结
我们在本篇的最后引入了C++多态的概念,我们会在后续的文章会详细阐述运行时多态的实现技术,而虚函数是C++实现运行时多态的基础。而实现运行时动态调度函数的驱动载体是虚指针和虚表,因此本篇着重介绍包含虚成员函数的类创建虚表的细节和内存布局。
网友评论