前言
本文介绍C++的虚基类
先看一个问题
先看一段代码
#include <iostream>
class A
{
public:int a = 1;
};class B1:public A
{
public:int b1 = 2;
};class B2 :public A
{
public:int b2 = 3;
};class C1:public B1,public B2
{
public:int c1 = 4;
};int main(int argc, const char* argv[])
{C1 c1;std::cout << "C1 size::" << sizeof(c1) << std::endl;return 0;
}
打印c1的大小,发现c1的大小是20字节,我们猜测一下这20个字节应该是
- C1类中的c1,int类型占用四个字节
- B1类中的b1,int类型占用四个字节
- B1类继承A类中的a,int类型占用四个字节
- B2类中的b2,int类型占用四个字节
- B2类继承A类中的a,int类型占用四个字节
我们通过下面添加下面语句打印c1的地址
std::cout << "c1 pointer::" << &c1 << std::endl;
我这里获取到c1的内存地址为0x000000D9887FF958,然后使用这个地址去内存界面(调试–窗口–内存–内存1)中查找(必须在程序运行过程中才能查看内存数据),结果如下:
因为我们给变量都赋值了,可以通过取值区分变量,我们能够得到c1的内存布局如下:
- a
- b1
- a
- b2
- c
那下面问题来了,我们如果想给c1中的a赋值会发生什么呢?因为c1在内存中有两个a,编译器无法确定a的偏移地址应该是哪个,所以如果操作c1中的a,编译器会报错:C1::a不明确
。
确实不明确。
怎么解决这个问题呢?C++通过使用虚基类解决这种问题,本文重点介绍
虚继承
定义
当一个类在继承的基类前面添加关键字virtual时,我们就说这个类从基类虚继承,看下面的代码:
#include <iostream>
class A
{
public:int a = 1;
};// 注意关键字virtual
class B1:virtual public A
{
public:int b1 = 2;
};
类B1就叫做从A虚继承,A在被虚继承的情况下被称为虚基类,注意,虚基类是有条件的,只有在被虚继承的时候才是虚基类
。
虚继承的特点
对于虚继承的类,上面的例子就是B1,编译器会在类的成员变量里面添加一个指针
,这个指针叫做虚基类表指针
,简称vbptr
,全称virtual base pointer,该指针指向一个虚基类表
,简称vbtable
,全称virtual base table
我们打印一下B1类实例的大小:
int main(int argc, const char* argv[])
{B1 b1;std::cout << "b1 pointer::" << &b1 << std::endl;std::cout << "b1 size::" << sizeof(b1) << std::endl;return 0;
}
运行结果发现b1的大小足足有24个字节,怎么会这么大呢,我们又打印出b1在内存中的地址,我这里是0x000000AC17EFFC08,去内存界面查找结果如下:
因为我们给变量都赋了值,所以比较容易查看,b1在内存的布局如下:
- 一个指针,占8个字节,因为我电脑是64位的
- b1的值,注意,这里不是a1,这跟常规的继承似乎不太一样,占用四字节
- 四字节的对齐位
- a的值。占用四字节
- 四字节的对齐位,正好24个字节
首位的那个指针就是我们前面说的虚函数表指针。
然后我们继续添加类C1继承B1
class C1:public B1
{
public:int c1 = 4;
};
观察C1实例的大小和内存布局如下:
- 一个指针,占8个字节,因为我电脑是64位的
- b1的值,注意,这里不是a1,这跟常规的继承似乎不太一样,占用四字节
- 四字节的对齐位
- c1的值。占用四字节
- 四字节的对齐位
- a的值。占用四字节
- 四字节的对齐位,32个字节
我们发现,对于多层继承关系,虚基类的成员变量始终放在最后
虚基类表
虚基类表的创建时机
对于虚继承,编译器在编译期间就已经生成了虚基类表,一个虚继承的类对应一个虚基类表,这点和包含虚函数的类一样,一个包含虚函数的类对应一个虚函数表
虚基类表的内容
虚基类表中保存的不是指针,这一点和虚函数表不同,虚基类表中保存的是偏移量,是int,什么偏移量呢?继续往后看!
。
我们重新修改一下代码,让C1同时虚继承自B2和A:
#include <iostream>
class A
{
public:int a = 1;
};class B1
{
public:int b1 = 2;void b1_func() {};
};class B2
{
public:int b2 = 3;void b2_func() {};
};class C1:virtual public B2, public B1, virtual public A
{
public:int c1 = 4;void c1_func() { b2_func(); };
};
然后使用vs的命令行工具(通常在开始菜单–visual studio 20xx文件夹–Developer Command Prompt for VS 20xx)
使用cd命令切换到当前工程目录下,使用下面的命令:
cl /d1 reportSingleClassLayoutC1 main.cpp
注意C1是打印的类名,main.cpp是类所在的文件名
回车以后可以得到C1的布局信息,信息如下:
可以看到在C1的虚基类表中有三条信息,比虚继承的类的数目多1,经过我们使用不同数量的虚继承类进行测试,得到如下结论:
一个虚基类表中的表项数目等于虚继承的类的数目加1
- 从虚基类表的第二项开始,表示虚基类与虚基类表指针的偏移量。第二项表示虚继承的第一个类,第三项表示虚继承的第二个类,依次类推。。。
到目前为止,我们有两个疑问?
- 为什么记录偏移?
- 第一项是个什么玩意?
为什么记录偏移
为什么要定义偏移呢,因为假如我们现在要访问B2类中的函数
void b2_func(){}
在这个函数中,可能有访问B2类某个变量的操作,或者给B2的某个变量赋值,我们知道,C++是通过在成员函数中插入this指针参数来达到这个目的的。既然成员函数在编译期间就已经编译完成了,也就是代码已经写好了,那么我们传递的this值必须指向真正的B2的位置才行,不然通过this地址+偏移寻找成员变量的操作就会失败。那么怎么才能找到真正的B2的位置呢?毕竟我们现在只有C1的位置。
这就是虚基类表的作用,通过从虚基类表中获取对应虚基类的偏移,然后通过下面的公式获取虚基类的真实地址:
B2的真实地址 = C1的地址+虚基类表指针的偏移+虚基类的偏移
下面是访问该函数的汇编代码:
c1->b2_func();
00007FF6975F235E mov rax,qword ptr [c1]
00007FF6975F2362 mov rax,qword ptr [rax+8]
00007FF6975F2366 movsxd rax,dword ptr [rax+4]
00007FF6975F236A mov rcx,qword ptr [c1]
00007FF6975F236E lea rax,[rcx+rax+8]
00007FF6975F2373 mov rcx,rax
00007FF6975F2376 call B2::b2_func (07FF6975F1203h)
- 第一行将c1地址的值传给rax寄存器
- 第二行是通过获取虚基类表指针的内容将虚基类表的地址传给rax寄存器
- 第三行将虚基类表的第二项的值传递给rax寄存器
- 第四行将c1地址的值传给rcx寄存器
- 第五行将(c1地址值+虚基类表指针的偏移+虚基类表的第二项的值)的
值
传给rax寄存器,lea是取地址指令,就是获取当前地址的值,而不是地址的内容。经过这步,rax寄存器的值给rcx寄存器,这一步是参数的传递保存的是B2真正的地址 - 第六行将rax寄存器的值给rcx寄存器,这一步是参数的传递
- 第七行开始函数的调用
我们可以总结,虚基类表的偏移是为了能够对虚基类进行操作
关于第一项
那么虚基类表的第一项呢?
为了了解第一项的值,我们得先运行一下代码,因为运行代码以后内存中的数据布局和上面控制台打印的类布局结构是不一样的,因为类布局结构并没有考虑边界对齐
,所以我们给出上面代码运行时的内存数据,先给出c1的内存布局:
然后根据c1中虚基类表指针的值再去找到虚基类表的内存:
可以看到虚基类表的前三项分别为:
- fffffff8:-8
- 00000010:16
- 00000014:20
这样我们结合自己的类的定义就大体知道第一项的值代表虚基类表指针到拥有当前指针的类地址的偏移。
三层虚继承
到目前为止,我们都是分析虚继承,事实上,虚基类的应用至少需要三层,别忘了虚基类的目的是什么,是为了保证基类在类布局中只保留一份
。两层的时候是没有同一个基类出现多次的情况的。
保留上面的代码,新添加一个类C2,和C1有同样的继承关系,新建一个类D1,同时继承C1和C2:
class C2 :virtual public B2, public B1, virtual public A
{
public:int c2 = 5;void c2_func() { b2_func(); };
};class D1 :public C1, public C2
{
public:int d1 = 6;
};
到这里先暂停一下,再来回顾一下虚基类的概念:
- 虚基类的最终目标是存在多个时只保留一份,并且放在布局的最后面
虚基类表是针对于虚继承的类的,不是虚基类
- 虚基类表指针存在于虚继承的类,是类的一部分,通常放在类的前面,虚基类没有这玩意
好,回到例子,我们打印一下D1实例的大小,然后看一下D1的布局:
D1实例的大小是64字节,布局如下,注意,布局没有考虑边界对齐:
我们分析一下布局的情况:
- D1:因为D1先继承C1,并且不是虚继承,所以先排列C1的数据
- C1:因为 C1虚继承B2和A,所以虚基类B2和A扔到最后,这里先不管,只知道C1有一个虚函数表指针,又因为C1还继承自B1,所以要先排列B1
- B1:b1=2 //目前4字节
- B1:边界对齐 //目前8字节
- B1排列完之后,C1没有别的继承了,开始排列自己
- C1:vbtable,先排列虚基类表指针 //目前16字节
- C1:c1=4 //目前20字节
- C1:边界对齐 //目前24字节
- C1:因为 C1虚继承B2和A,所以虚基类B2和A扔到最后,这里先不管,只知道C1有一个虚函数表指针,又因为C1还继承自B1,所以要先排列B1
- D1:C1的排列完成后,开始排列C2,C2和C1结构一样,可以跳过往后看
- C2:因为 C2虚继承B2和A,所以虚基类B2和A扔到最后,这里先不管,只知道C2有一个虚函数表指针,又因为C2还继承自B1,所以要先排列B1
- B1:b1=2 //目前28字节
- B1:边界对齐 //目前32字节
- B1排列完之后,C2没有别的继承了,开始排列自己
- C2:vbtable,先排列虚基类表指针 //目前40字节
- C2:c2=5 //目前44字节
- C2:边界对齐 //目前48字节
- C2:因为 C2虚继承B2和A,所以虚基类B2和A扔到最后,这里先不管,只知道C2有一个虚函数表指针,又因为C2还继承自B1,所以要先排列B1
- D1:继承的类都排列完成,开始排列自身的成员
- D1:d1=6 //目前52字节
- D1:边界对齐 //目前56字节
- D1:继承的类都排列完成,开始排列虚基类,虚基类的排列按照继承关系出现的先后顺序排列,对于当前例子,先排列B2,再排列A
- B2:b2=3 //目前60字节
- A:a=1 //目前64字节
下面是内存的数据:
根上面的分析刚好一致。
一共有两个虚基类表,一个偏移位置为8,一个偏移位置为32
所以基类到两个虚基类表的偏移为:
- 偏移位置为8:
- B2:48
- A:52
- 偏移位置为32:
- B2:24
- A:28
然后查看第一个虚基类表指针00007ff6468fbc38对应的虚基类表的内存数据:
可以看到虚基类表的前三项分别为:
- fffffff8:-8
- 00000030:48
- 00000034:52
与结果一致
然后查看第二个虚基类表指针00007ff6468fbdb8对应的虚基类表的内存数据:
可以看到虚基类表的前三项分别为:
- fffffff8:-8
- 00000018:24
- 0000001c:28
与结果一致
如果包含虚函数呢
如果一个类既包含虚函数表又包含虚基类表的情况下,应该是怎么排列的呢?
看下面的简单代码:
class A
{
public:int a = 1;
};class C1:virtual public A
{
public:int c1 = 4;virtual void c1_func() { };
};
C1包含虚函数,所以应该有一个虚函数表指针,又虚继承A,所以应该有一个虚基类表指针,我们打印一下C1的布局:
可以看到,先排列虚函数表指针,在排列虚基类表指针
。