虚继承主要解决多重继承会在子类中存在多份拷贝的问题,这不仅浪费空间,而且存在二义性。
在之前的 C++ 继承中已经说过虚继承基本概念,这里不再赘述。这篇文章主要探究虚继承的原理。文章中多处给出了类实例对象的内存布局,查看其内存布局时,使用 VS 工具 /d1 reportAllClassLayout
进行查看,关于这个工具的详细介绍,请点击这里。
虚继承的实现原理
虚继承的底层实现一般与编译器相关,一般会通过虚基类表指针和虚基类表实现,先看如下这个程序:
#pragma pack(1)
class A
{
public:
int a;
};
class B : virtual public A
{
public:
int b;
};
class C : virtual public A
{
public:
int c;
};
其内存布局图如下所示:
> cl /d1 reportSingleClassLayoutB CppTest.cpp
class B size(12):
+---
0 | {vbptr}
4 | b
+---
+--- (virtual base A)
8 | a
+---
B::$vbtable@:
0 | 0
1 | 8 (Bd(B+0)A)
vbi: class offset o.vbptr o.vbte fVtorDisp
A 8 0 4 0
----------------------------------------------------
> cl /d1 reportSingleClassLayoutC CppTest.cpp
class C size(12):
+---
0 | {vbptr}
4 | c
+---
+--- (virtual base A)
8 | a
+---
C::$vbtable@:
0 | 0
1 | 8 (Cd(C+0)A)
vbi: class offset o.vbptr o.vbte fVtorDisp
A 8 0 4 0
从中可以看出,每个虚继承的子类都有一个虚基类表指针 vbptr(virtual base table pointer,占用一个指针的大小,32 位下为4字节,64 位为 8 字节)和该指针指向一个虚基类表(额外的空间,不占用实例对象的空间),上面的程序中 B 虚继承 A,那么 B 类中将会有一个虚基类表指针 vbptr,C 类虚继承 A 类,也是一样。该 vbptr 指针指向一个虚基类表,关于虚基类表下面再详细说。
现在我们再看一个例子:
#pragma pack(1)
class A
{
public:
int a;
};
class B : virtual public A
{
public:
int b;
};
class C : virtual public A
{
public:
int c;
};
class D : public B, public C
{
public:
int d;
};
这次我们使用类 D 多重继承类 B 和类 C(B 和 C 都是虚继承 A),看一下内存布局图:
> cl /d1 reportSingleClassLayoutD CppTest.cpp
class D size(24):
+---
0 | +--- (base class B)
0 | | {vbptr}
4 | | b
| +---
8 | +--- (base class C)
8 | | {vbptr}
12 | | c
| +---
16 | d
+---
+--- (virtual base A)
20 | a
+---
D::$vbtable@B@:
0 | 0
1 | 20 (Dd(B+0)A)
D::$vbtable@C@:
0 | 0
1 | 12 (Dd(C+0)A)
vbi: class offset o.vbptr o.vbte fVtorDisp
A 20 0 4 0
需要强调的是,虚基类依旧会在子类中存在拷贝,但仅仅只存在一份;我们看到虚基类 A 仅仅在类 D 的实例对象中存在一份,也就是上面内存布局里的 virtual base A。当继承的子类被当作父类继承时,虚基类表指针也会被继承;我们上面已经测试过 B 虚继承 A,C 虚继承 A 都会在各自的实例对象中多出一个虚基类表指针 vbptr 指向一个虚基类表,现在 D 类继承与 B 类和 C 类,其类中的虚基类表指针也会被继承。我们也可以从上面的内存布局中看到 D 中类 B 和类 C 中都没有类 A 的成员而是都多了一个虚基类表指针 vbptr,而且类 D 中只有一份虚基类 A 的成员,所以奥秘就在 类 B 和类 C 中的虚基类表指针 vbptr 上。
我们再看类 B 和类 C 的虚基类表的结构,该表中记录了虚基类 A 与本类的偏移地址,因为现在虚基类 A 在类 D 的实例对象中只能存在一份,所以类 B 和类 C 中只要通过虚基类表中给的偏移量访问就能访问到虚基类 A。上面的内存布局中,仅存的一份虚基类 A 的地址相较于类 D 的实例对象的首地址偏移量为 20,而类 B 的 vbptr 相较于类 D 的实例对象的首地址偏移量为 0,所以类 B 中想要访问虚基类 A 需要偏移 20 个字节,所以虚基类表中记录的与虚基类 A 的偏移量为 20,而类 C 的访问虚基类 A 的偏移量为 12,也是同样可以这样计算出来。这样就可以找到虚基类 A 的数据成员,而且类 B 和类 C 对虚基类 A 的数据的修改都是在同一地址,这样就不用像普通菱形继承那样维持着公共基类(也就是这里说的虚基类)的两份同样的拷贝了,同时也能节省一点空间。
我们可以联系到多态中虚函数表,这两者有一定的相似之处,不过虚基类表存储的时虚基类相对当前实例对象的偏移,而虚函数表则存储的是虚函数地址。
到这里,关于虚基类表的探究就已经结束了,我们对虚继承的原理也有了一定的认识,还可以自己在程序中访问虚基类表,其基本思路如下:
D* d = new D;
std::cout << "[0] ->" << *(int*)(*(int*)d) << std::endl;
std::cout << "[1] ->" << *(((int*)(*(int*)d)) + 1)<< std::endl; // 偏移量20
std::cout << "b value : " << *((int*)d + 1) << std::endl;
std::cout << "[0] ->" << *(int*)(*((int*)d + 2)) << std::endl;
std::cout << "[1] ->" << *((int*)(*((int*)d + 2)) + 1) << std::endl; // 偏移量12
std::cout << "c value : " << *((int*)d + 3) << std::endl;
std::cout << "d value : " << *((int*)d + 4) << std::endl;
std::cout << "a value : " << *((int*)d + 5) << std::endl;