本文介绍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的地址
std::cout << "c1 pointer::" << &c1 << std::endl;
我这里获取到c1的内存地址为0x000000D9887FF958,然后使用这个地址去内存界面(调试–窗口–内存–内存1)中查找(必须在程序运行过程中才能查看内存数据),结果如下:
因为我们给变量都赋值了,可以通过取值区分变量,我们能够得到c1的内存布局如下:
那下面问题来了,我们如果想给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在内存的布局如下:
首位的那个指针就是我们前面说的虚函数表指针。
然后我们继续添加类C1继承B1
class C1:public B1
{
public:
int c1 = 4;
};
观察C1实例的大小和内存布局如下:
我们发现,对于多层继承关系,虚基类的成员变量始终放在最后
对于虚继承,编译器在编译期间就已经生成了虚基类表,一个虚继承的类对应一个虚基类表,这点和包含虚函数的类一样,一个包含虚函数的类对应一个虚函数表
虚基类表中保存的不是指针,这一点和虚函数表不同,虚基类表中保存的是偏移量,是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)
值
传给rax寄存器,lea是取地址指令,就是获取当前地址的值,而不是地址的内容。经过这步,rax寄存器的值给rcx寄存器,这一步是参数的传递保存的是B2真正的地址我们可以总结,虚基类表的偏移是为了能够对虚基类进行操作
那么虚基类表的第一项呢?
为了了解第一项的值,我们得先运行一下代码,因为运行代码以后内存中的数据布局和上面控制台打印的类布局结构是不一样的,因为类布局结构并没有考虑边界对齐
,所以我们给出上面代码运行时的内存数据,先给出c1的内存布局:
然后根据c1中虚基类表指针的值再去找到虚基类表的内存:
可以看到虚基类表的前三项分别为:
这样我们结合自己的类的定义就大体知道第一项的值代表虚基类表指针到拥有当前指针的类地址的偏移。
到目前为止,我们都是分析虚继承,事实上,虚基类的应用至少需要三层,别忘了虚基类的目的是什么,是为了保证基类在类布局中只保留一份
。两层的时候是没有同一个基类出现多次的情况的。
保留上面的代码,新添加一个类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字节,布局如下,注意,布局没有考虑边界对齐:
我们分析一下布局的情况:
下面是内存的数据:
根上面的分析刚好一致。
一共有两个虚基类表,一个偏移位置为8,一个偏移位置为32
所以基类到两个虚基类表的偏移为:
然后查看第一个虚基类表指针00007ff6468fbc38对应的虚基类表的内存数据:
可以看到虚基类表的前三项分别为:
然后查看第二个虚基类表指针00007ff6468fbdb8对应的虚基类表的内存数据:
可以看到虚基类表的前三项分别为:
如果一个类既包含虚函数表又包含虚基类表的情况下,应该是怎么排列的呢?
看下面的简单代码:
class A
{
public:
int a = 1;
};
class C1:virtual public A
{
public:
int c1 = 4;
virtual void c1_func() { };
};
C1包含虚函数,所以应该有一个虚函数表指针,又虚继承A,所以应该有一个虚基类表指针,我们打印一下C1的布局:
可以看到,先排列虚函数表指针,在排列虚基类表指针
。