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中查看类布局的方法可以参考这篇博客

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

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

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

多继承

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

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

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

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;
};

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

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

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

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;
};

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

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

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;
};

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

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

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

需要注意,在这个例子中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;
};

我们已经介绍过了这个布局,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;
};

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

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

共用虚基类表(vbtable)

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

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

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

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

这次我们让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++虚继承原理与类布局分析的更多相关文章

  1. C++对象模型:单继承,多继承,虚继承,菱形虚继承,及其内存布局图

    C++目前使用的对象模型: 此模型下,nonstatic数据成员被置于每一个类的对象中,而static数据成员则被置于类对象之外,static和nonstatic函数也都放在类对象之外(通过函数指针指 ...

  2. C++多重继承分析——《虚继承实现原理(虚继承和虚函数)》

    博客转载:https://blog.csdn.net/longlovefilm/article/details/80558879 一.虚继承和虚函数概念区分 虚继承和虚函数是完全无相关的两个概念. 虚 ...

  3. C++ 虚继承实现原理(虚基类表指针与虚基类表)

    虚继承和虚函数是完全无相关的两个概念. 虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝.这将存在两个问题:其一,浪费存储空间:第二,存在二义性问题,通常可 ...

  4. 【整理】C++虚函数及其继承、虚继承类大小

    参考文章: http://blog.chinaunix.net/uid-25132162-id-1564955.html http://blog.csdn.net/haoel/article/deta ...

  5. C++ 各种继承方式的类内存布局

    body, table{font-family: 微软雅黑; font-size: 10pt} table{border-collapse: collapse; border: solid gray; ...

  6. C++ 多继承和虚继承的内存布局(转)

    转自:http://www.oschina.net/translate/cpp-virtual-inheritance 警告. 本文有点技术难度,需要读者了解C++和一些汇编语言知识. 在本文中,我们 ...

  7. 转载:C++ 多继承和虚继承的内存布局

    C++ 多继承和虚继承的内存布局[已翻译100%] 英文原文:Memory Layout for Multiple and Virtual Inheritance 标签: <无> run_ ...

  8. C++中虚继承派生类构造函数的正确写法

    最近工作中某个软件功能出现了退化,追查下来发现是一个类的成员变量没有被正确的初始化.这个问题与C++存在虚继承的情况下派生类构造函数的写法有关.在此说明一下错误发生的原因,希望对更多的人有帮助. 我们 ...

  9. C++构造函数 & 拷贝构造函数 & 派生类的构造函数 & 虚继承的构造函数

    构造函数 ,是一种特殊的方法 .主要用来在创建对象时初始化对象, 即为对象成员变量赋初始值,总与new运算符一起使用在创建对象的语句中 .特别的一个类可以有多个构造函数 ,可根据其参数个数的不同或参数 ...

  10. C++ - 类的虚函数\虚继承所占的空间

    类的虚函数\虚继承所占的空间 本文地址: http://blog.csdn.net/caroline_wendy/article/details/24236469 char占用一个字节, 但不满足4的 ...

随机推荐

  1. 在python中发送自定义消息

    .py import win32api, win32con, win32gui import win32gui_struct import ctypes from ctypes import * GU ...

  2. linux系统优化命令--day03

    用户管理与文件权限 给普通用户授权 root 用户 修改/etc/sudoers文件,文件非常重要, 不可以随意更改 vim /etc/sudoers 如果想要给用户赋予权限,我们要使用这个命令 vi ...

  3. 在矩池云上使用R和RStudio

    租用机器 在矩池云租用机器的时候,系统环境里搜索:R,选择 R4.2 镜像,如果需要使用RStudio,还需要在高级选项中新增一个自定义端口:8787,然后点击租用即可. 使用 JupyterLab ...

  4. 【Azure API 管理】APIM关闭开发者门户的办法

    问题描述 APIM默认提供了开发者门户,可以让用户体验如何来调用接口.但如果不想开发这个功能的情况下,是否有办法关闭呢? 问题解答 答案是:开发人员门户是没有办法关闭的.但是作为另一种的代替方案,如自 ...

  5. STL-RBTree模拟实现

    #pragma once #include<assert.h> #include<iostream> using std::cout; using std::endl; usi ...

  6. Java //数组的反转

    1 //数组的反转 2 //方式一 3 System.out.println("数组的反转"); 4 5 // for(int i = 0; i <arr.length/2; ...

  7. C++ //内建函数对象 算数仿函数 关系仿函数 //逻辑仿函数

    1 //内建函数对象 算数仿函数 关系仿函数 //逻辑仿函数 2 #include<iostream> 3 #include<string> 4 #include<fun ...

  8. 青少年CTF训练平台-web部分随笔

    文章管理系统 首先打开环境(>ω<。人)ZZz♪♪ 既然要做题,就要做全面了,图上说了,既然有假flag我就先找出来: 假flag: 打开vmware,使用sqlmap进行处理: sqlm ...

  9. nginx 重写(rewrite) 重定向(return error_page) 详解

    使用 rewrite 指令用于重写URL Nginx的rewrite指令用于重写URL,它有几个参数,这些参数定义了如何匹配和重写请求的URL.以下是rewrite指令的常见参数及其说明: Regex ...

  10. vivo统一接入网关VUA转发性能优化实践

    作者:vivo 互联网服务器团队 - Qiu Xiangcun 本文将探讨如何通过使用Intel QuickAssist Technology(QAT)来优化VUA的HTTPS转发性能.我们将介绍如何 ...