首页 > 编程语言 >C++虚继承原理与类布局分析

C++虚继承原理与类布局分析

时间:2024-04-02 19:55:05浏览次数:26  
标签:继承 C++ class int 基类 原理 vbptr public

C++虚继承原理与类布局分析

引言

在开始深入了解虚继承之前,我们先要明白C++引入虚继承的目的。C++有别于其他OOP语言最明显的特性就是类的多继承,而菱形继承结构则是多继承中最令人头疼的情况。

我们都知道,当派生类继承基类时,派生类内部会保存一份基类数据的副本。在D->B|C, B|C->A的菱形继承结构中,BC各自存有一份A成员变量的副本,这导致D继承BC后同时保存了两份A成员变量,这就导致了空间浪费和语法二义性的问题。

所以C++引入了虚继承,用于解决菱形继承导致的数据冗余。

本文的目标是探究虚继承的实现方式和类布局(Class Layout)的具体规则,主要内容源自于本人对C++: Under the Hood的解读和提炼。

不过在开始之前,我们需要先熟悉一下普通继承下的类布局,方便与之后的虚继承进行对比。

请注意,以下用于分析的数据皆来自于MSVC的编译结果。C++标准定义了一些基本规范,但不同编译器的实现方式可能会有所差异,所以内容仅具有一定的参考性。

单继承

以下是由A类派生B类的单继承例子:

class A
{
public:
    int a1;
    int a2;
};
class B : public A
{
public:
    int b1;
    int b2;
};

通过在VS中启用Class Layout的输出,我们可以得到以下内容:

class A	size(8):
	+---
 0	| a1
 4	| a2
	+---

class B	size(16):
	+---
 0	| +--- (base class A)
 0	| | a1
 4	| | a2
	| +---
 8	| b1
12	| b2
	+---

Visual Studio中查看类布局的方法可以参考这篇博客

看起来可能有点抽象,它其实是等价于下图中的内容:

img

由于派生类继承了其基类的所有属性和行为,因此派生类的每个实例都将包含基类实例数据的完整副本。在B中,A的成员数据摆放在B的成员数据之前。虽然标准并没有如此规定,但是当我们需要将B类的地址嵌入A类的指针时(例如:A *p = new B();),这种布局不需要再添加额外的位移,就可以使指针指向A数据段的开头(在接下来的多继承中更能体现这么做的好处)。图中A*B*指针指向的位置也体现了这一点。

因此,在单继承的类层次结构中,每个派生类中引入的新实例数据只是简单地附加到基类的布局末尾。

多继承

class A
{
public:
    int a1;
    int a2;
};

img

class B
{
public:
    int b1;
    int b2;
};

img

class C : public A, public B
{
public:
    int c1;
    int c2;
};

img

C多重继承自AB,与单继承一样,C包含每个基类实例数据的副本,并且置于类的最前方。与单继承不同是,多继承不可能使每个基类数据的起始地址都位于派生类的开头。从图中也可以看出,在基类A占据起始位置后,基类B只能保存在偏移量为8的位置。这就使得将C*转换为A*B*时的操作出现了差异。

C c;
(void *)(A *)&c == (void *)&c
(void *)(B *)&c > (void *)&c
(void *)(B *)&c == (void*)(sizeof (A) + (char *)&c)

这几个判断语句的结果都为true,因此可以看出当C*转为B*时,会在原地址的基础上进行偏移。这也是多继承带来的开销之一。

编译器实现可以采用任何顺序布置基类实例和派生类实例数据。MSVC通常的做法是先按声明顺序布局基类实例,然后按声明顺序布置派生类的新数据成员。 不过在后续的例子中我们将会看到,当部分基类具有虚基类表(或虚函数表)而其他基类没有时,情况就不一定如此了。

菱形继承

现在就搬出我们在文章开头提到的菱形继承的例子,来看看具体的布局是怎么样的。

class A
{
public:
    int a1;
    int a2;
};

img

class B : public A
{
public:
    int b1;
    int b2;
};

img

class C : public A
{
public:
    int c1;
    int c2;
};

img

class D : public B, public C
{
public:
    int d1;
    int d2;
};

img

BC都继承了A,因此也都保存了一份基类A的实例数据副本。

当类D同时继承了类BC之后,也完整地保存了BC的实例数据副本,也就导致D中出现了两份A的实例数据副本。

编译器不能确定我们究竟是要访问从B继承来的A成员,还是从C继承来的A成员,从D*转换到A*的偏移量也无法确定。因此,下面这些操作都是具有二义性的,不能成功编译:

D d;
d.a1 = 1; 			// E0266	"D::a1" 不明确
A *p_a = (A *)&d; 	// C2594	“类型强制转换”: 从“D *”到“A *”的转换不明确

想要成功执行的话,就必须显式地声明访问路径,以消除二义性:

D d;
d.B::a1 = 1; 			// 或者d.C::a1
A *p_a = (A *)(B *)&d; 	// 或者(A *)(C *)&d

虚继承

为了解决这一问题,C++引入了虚继承的概念。在仅保留一份重复的实例数据副本的情况下,通过虚基类表(vbtable)来访问共享的实例数据。听起来有些难以理解,所以接下来我会通过分析虚继承下的类布局来解释虚继承语法的实现。

我们先来分析单继承情况下,虚继承与普通继承之间的类布局差异。

class A
{
public:
    int a1;
    int a2;
};

img

class B : public A
{
public:
    int b1;
    int b2;
};

img

class C : virtual public A
{
public:
    int c1;
    int c2;
};

img

A为基类,B继承于AC虚继承于A

通过对比BC的类布局我们可以发现两个明显的差异:

  • 虚继承中,派生类布局的起始位置增加了vbptr指针,该指针指向vbtable
  • 虚继承中,基类的实例数据副本被放置在了派生类的末尾

vbtable中的两个条目也很好理解,我们首先要知道XdYvbptrZ表示的是在X类中,YvbptrZ类入口的偏移量。因此:

  • 第一条记录CdCvbptrC = 0表示,C类中,CvbptrC类入口的偏移量为0
  • 第二条记录CdCvbptrA = 16表示,C类中,CvbptrA类入口的偏移量为16。从图中也可以看出C类中,C::vbptr的保存位置为0A类的入口位于16,因此偏移量为16

在数据访问的过程中,需要用到vbtable中的偏移量来计算访问地址,这就涉及到了查表+偏移的操作。因此,虚继承的访问开销会比前面在多继承中提到的固定偏移计算来得更大,与此同时vbptrvbtable也造成了额外的内存开销。

从单继承的例子来看,虚继承带来了更大的时间和内存开销,但却没有体现出任何的额外优势。并且也看不出vbptrvbtable存在的必要性,毕竟为什么我们不直接让A* = C* + 16

而接下来通过菱形继承的例子,我们就会明白这种做法的必要性。

虚继承——菱形继承

class A
{
public:
    int a1;
    int a2;
};

img

class B : virtual public A
{
public:
    int b1;
    int b2;
};

img

class C : virtual public A
{
public:
    int c1;
    int c2;
};

img

class D : public B, public C
{
public:
    int d1;
    int d2;
};

img

需要注意,在这个例子中BC虚继承于A,而D则是普通继承于BC

在为菱形继承添加上虚继承之后,我们可以明确地看到BC结尾的A实例数据副本,在D的结尾被合并成了一份。与此同时,编译器根据D的布局结构创建了新的vbtableBCvbptr也被修改为指向新的vbtable

现在我们就可以解答前面提出的问题:“为什么不直接让`A* = C* + 16呢?”

从图中就可以看出,在C类的布局中,C* + 16 == A*是成立的,因此以下代码的运行结果是1

C* p_c = new C();
A* p_a = p_c;		// 编译器自动转换的结果
printf("%d", (void*)p_a == (void*)(16 + (char*)p_c)); // 返回1

而在D类之中,C* + 16访问的就是D::d1的地址了,这种做法明显是错误的,因此代码的运行结果是0

C* p_c = new D(); // 注意:这里的C*来源于类型D
A* p_a = p_c;
printf("%d", (void*)p_a == (void*)(16 + (char*)p_c)); // 返回0

所以根本的问题在于,不同类中的A*相对于C*的位置是不固定的,在运行时多态的情况下,我们无法仅在编译阶段计算出确定的偏移量。

但有了vbptrvbtable之后,无论是C类的C*还是D类的C*,我们都可以访问当前vbptr所指向的vbtable获取偏移量。而vbptrvbtable都是可以在编译时根据类布局来确定的。所以下面的代码中,无论C*的来源是C类还是D类,运行的结果始终为1

C* p_c = new D();
A* p_a = p_c;
int* vbptr_c = *(int**)p_c; // 这里根据C类的布局知道vbptr位于C*的起始位置(编译时确定)
printf("%d", (void*)p_a == (void*)(*(vbptr_c + 1) + (char*)p_c)); // vbptr_c + 1是因为A*偏移量位于vbtable[1](编译时确定)

虚表指针(vbptr)的位置

关于虚继承的实现方式已经解释的差不多了,接下来我们再介绍几种类布局的情况,以帮助你更好地理解这些概念。

让我们先复习一下上一个章节中的例子来说明:

class A
{
public:
    int a1;
    int a2;
};

class C : virtual public A
{
public:
    int c1;
    int c2;
};

img

我们已经介绍过了这个布局,C虚继承A后,在起始位置添加了vbptr,并将A的实例数据副本布置在了末尾。

让我们把情况弄得稍微复杂一些:

class A
{
public:
    int a1;
    int a2;
};

class B // 注意,这次B没有继承A
{
public:
    int b1;
    int b2;
};

class C : virtual public A, public B
{
public:
    int c1;
    int c2;
};

img

我们让C虚继承A的同时,再普通继承B。这次C发生了两个变化:

  1. vbptr的位置从0变为了8,也就是说vbptr的行为似乎和普通成员变量一样,被布置在基类的成员之后。注意我这里说的是"似乎",因为下一章节我们就会找到特例。
  2. 第二个变化则是vbtable中的CdCvbptrC的值从0变为了-8,这其实就是受到vbptr位置变化的影响。

共用虚基类表(vbtable)

介绍完“正常情况”后,我们再来看一个特殊情况。

class A
{
public:
    int a1;
    int a2;
};

img

class B : virtual public A
{
public:
    int b1;
    int b2;
};

img

class C : virtual public A, public B
{
public:
    int c1;
    int c2;
};

img

这次我们让B虚继承于A,然后和上一章一样,让C虚继承A的同时,再普通继承B

可以看到,由于BC都有vbptr,并且具有公共的虚基类A,导致二者的vbptr合并到了起始位置,并且共用一个vbtable

后续我经过几次测试后发现一个规律,当派生类同时进行虚继承和非虚继承的情况下,只要非虚继承的基类中存在vbptr指针,那么派生类的虚继承就会与之共用一个vbptrvbtable

参考资料

C++: Under the Hood

How virtual inheritance is implemented in memory by c++ compiler?

深入理解C++ 虚函数表


本文发布于2024年4月2日

最后编辑于2024年4月2日

标签:继承,C++,class,int,基类,原理,vbptr,public
From: https://www.cnblogs.com/ThousandPine/p/18111381

相关文章

  • 【C++算法】 卡常技巧
    文章目录updata学习引言技巧1——善用修饰符技巧2——输入输出`read`和`write`技巧3——对于运算的优化技巧4——展开循环技巧5——对与循环的优化updata2024.03.31发布此文章学习引言卡常,一种编程技巧,在对时间复杂度要求很高时,就可以用这种办法来节省时......
  • c++蛮力法解释
    蛮力法(bruteforce)是一种基本的问题求解策略,也被称为穷举法。它的基本思想是通过穷举所有可能的解来寻找问题的解决方案。在C++中,可以使用循环和条件判断语句来实现蛮力法。下面是一个示例,假设要解决的问题是找到数组中两个数的和等于给定目标值的情况:#include<iostream>#i......
  • 在VS或者CLion中引入C和C++的SDK
    visualstudio创建c++项目引入头文件和库文件拷贝的gpt的,可以用在VisualStudio2022中,虽然你创建的是一个C++项目,但它确实支持C语言的编译和运行。为了在你的项目中使用C语言的头文件和库文件,你可以按照以下步骤操作:1.**添加头文件和库文件到项目:**-首先,你......
  • C++ std常用math函数
    std::atan和std::atan2std::atan(x)  即tan(angle)=x  所求angle范围[-PI/2,PI/2] [-90°,90°]std::atan2(y,x)即tan(angle)=y/x 所求angle范围[-PI,PI][-180°,180°]  std::fmod(x,y)计算x/y的浮点余数,如std::fmod(3.1,2)=1.1对浮点数进行......
  • test c++
    testc++ #include<iostream>usingnamespacestd;intmain(){charmyChar[6]={'H','e','l','l','o','\0'};//char*pointer=myChar;//WORKS!!!char*pointer......
  • c++变量、常量
    ///变量声明(规定变量类型和名字)与定义(为变量开辟内存空间)///变量可声明多次,但只能被定义一次///变量名一般以小写开头,类名以大写开头intj=10;//初始化,创建时赋予初始值;有初始化机制,但最好手动初始化j=22;//赋值,将变量当前值替换为新的值inti;//声明[i],但非定义;i=10;......
  • Docker in Docker原理与实战
    一、DockerinDocker是什么?DockerinDocker(DinD)。简单来说,就像是“盒子里装盒子”,也就是在一个Docker容器里面跑起了另一个Docker服务。想象一下,你有一个神奇的集装箱(第一个Docker容器),而在这个集装箱里,还塞进去了一个小一号的集装箱(第二个Docker守护进程)。这样,你就可以......
  • C++ List 到 Python List 的转换
    当我们编写C++库的封装器通常涉及使用一种跨语言的接口技术,比如使用C接口或者使用特定的跨语言库,比如SWIG(SimplifiedWrapperandInterfaceGenerator)或者Pybind11。这里我将简要介绍如何使用Pybind11来封装一个C++库,以便在Python中使用。1、问题背景在编写C++库的......
  • C++ //练习 11.4 扩展你的程序,忽略大小写和标点。例如,“example.“、“exmaple,“和”
    C++Primer(第5版)练习11.4练习11.4扩展你的程序,忽略大小写和标点。例如,“example.”、"exmaple,"和”Example"应该递增相同的计数器。环境:LinuxUbuntu(云服务器)工具:vim 代码块/************************************************************************* >Fil......
  • 【Python BUG】ImportError: /usr/lib/x86_64-linux-gnu/libstdc++.so.6: version `CX
    报错ImportError:/usr/lib/x86_64-linux-gnu/libstdc++.so.6:version`CXXABI_1.3.9‘notfound背景开发的一个python预测程序(算法分析+应用服务),在原本的linux服务器下配置了开发环境,打包后在另一台凝思服务器解压,发现报错。应该是linux系统之间存在差异,动态库缺失......