虚拟继承的幕后发生了什么?

What is happening under the hood of virtual inheritance?

提问人:Hydrogen 提问时间:4/7/2023 更新时间:7/14/2023 访问量:212

问:

最近我一直在尝试为一款老游戏制作一个插件,遇到了类似于 Diamond Inheritance 的问题。

我有一个非常简化的例子,写如下:

#include <iostream>
#include <stdint.h>
#include <stddef.h>

using namespace std;

struct CBaseEntity
{
    virtual void Spawn() = 0;
    virtual void Think() = 0;

    int64_t m_ivar{};
};

struct CBaseWeapon : virtual public CBaseEntity
{
    virtual void ItemPostFrame() = 0;

    double m_flvar{};
};

struct Prefab : virtual public CBaseEntity
{
    void Spawn() override { cout << "Prefab::Spawn\n"; }
    void Think() override { cout << "Prefab::Think\n"; }
};

struct WeaponPrefab : virtual public CBaseWeapon, virtual public Prefab
{
    void Spawn() override { cout << boolalpha << m_ivar << '\n'; }
    void ItemPostFrame() override { m_flvar += 1; cout << m_flvar << '\n'; }

    char words[8];
};

int main() noexcept
{
    cout << sizeof(CBaseEntity) << '\n';
    cout << sizeof(CBaseWeapon) << '\n';
    cout << sizeof(Prefab) << '\n';
    cout << sizeof(WeaponPrefab) << '\n';

    cout << offsetof(WeaponPrefab, words) << '\n';
}

前两个是从游戏的源代码中提取的,我将它们制作成纯粹的虚拟类,因为我不需要实例化它们。 第三个类 () 是我在 mod 中扩展所有类的类。Prefab

问题是: 我刚刚注意到班级规模发生了变化,这可能表明有破坏 ABI 的东西在等着我。当我从继承中删除所有关键字时,类大小非常小,内存布局对我来说很有意义。但每当我穿上继承时,尺寸突然爆炸,布局似乎是一个谜。virtualvirtual

就像我在类中打印出 a 变量一样,它显示 ,但总大小是 ,这没有任何意义 - 和在哪里?offsetofWeaponPrefab848m_ivarm_flvar

(我不是想用未定义的行为来激怒人们,而只是想应对原版游戏中现有的 ABI。

编译器资源管理器链接:https://godbolt.org/z/YvWTbf8j8

C++ 多重 虚拟继承 内存布局

评论

0赞 Raffallo 4/7/2023
你必须阅读一些关于 C++ 的信息。当您有虚拟方法时,类的大小将因 的大小而大,在 x86 机器上为 4 个字节,在 x64 上为 8 个字节。 返回 8,因为前 8 个字节被 占用。VptrVtableVptroffsetof(WeaponPrefab, words)Vptr
1赞 Pepijn Kramer 4/7/2023
引擎盖下发生的事情并不那么重要。C++ 标准仅描述可观察的行为。在大多数实现/情况下,会向类中添加一个带有函数指针的额外表(在派生类中,这些函数指针可能指向被覆盖的函数)。并且,不是直接调用成员函数,而是使用函数指针表中的条目进行调用。(从而根据类类型选择正确的函数)。如果您想了解更多信息,请查看有关 vtables 的信息
1赞 n. m. could be an AI 4/7/2023
WeaponPrefab : virtual public CBaseWeapon, virtual public Prefab这里不需要(除非你计划进一步扩展继承格层次结构 - 不推荐)。virtual
1赞 Red.Wave 4/7/2023
你得到了吗?尝试那个可能会给你一些线索。offsetofm_flvarm_ilvar
1赞 JaMiT 4/8/2023
“我刚刚注意到 [X],这可能表明一个破坏 ABI 的东西在等着我”——这在我看来是倒退的。您进行了更改,然后寻找 ABI 损坏的迹象。我首先假设任何更改都会破坏 ABI,然后对于每个提议的更改,请寻找它不会的保证。

答:

2赞 ALX23z 4/7/2023 #1

我已经运行了代码,类大小的答案是

sizeof(CBaseEntity) = 16 
sizeof(CBaseWeapon) = 32
sizeof(Prefab) = 24
sizeof(WeaponPrefab) = 48

一般来说,虚函数的实现和虚继承是实现定义的,并且可能因编译器和其他选项而异。话虽如此,也许我可以对对象的大小提供一些解释,至少对于可能的实现是这样。

CBaseEntity只是一个多态类型,因此有一个指向(对于我知道的 C++ 的所有实现都是如此,但不是标准强制要求),它还包含 .指针的大小 = 8,大小 = 8,所以总共正好是 16。vtableint64int64

CBaseWeapon继承自并持有双精度。它已经必须至少是 24 号。现在,虚拟继承意味着 和 的对象位置之间的差异不是固定的 - 只有最终类才能确定它。此信息需要存储在类的实例中。我相信该信息位于布局开头的某个地方。为了包含这些信息,应该添加填充,以便由于对齐要求,大小可以被 8 整除。因此,总大小总和为 32。基本上,它在CBaseEntityCBaseWeaponCBaseEntityCBaseWeaponCBaseEntity

Prefab类似于 ,但它不成立 。所以 24 或 8 在 .CBaseWeapondoubleCBaseEntity

WeaponPrefab实际上继承自 、 、 和 包含 。所以,它已经需要.如果有的话,令人惊讶的是,它并没有更大。这可能是因为不存储任何对象,并且这两个类以某种方式共享布局位置变量,从而优化了类的存储大小。比如说,如果向 添加一个双杆件,则 的大小将增加到 64。CBaseEntityCBaseWeaponPrefabchar[8]16+16+8+8 = 48WeaponPrefabPrefabPrefabWeaponPrefab

但是,正如我之前所说,这在很大程度上取决于确切的规格。我不知道你为哪个平台编码。我确信 ABI 的规范在互联网上的某个地方,您可以查找详细信息。例如,查看 Itanium C++ ABI,它可能与您相关,也可能与您无关。

编辑:正如@MilesBudnek所分析的那样,“layout-location 变量”实际上是指向编译器生成的偏移表的指针。因此,它需要 8 个字节或平台规定的任何字节。

评论

0赞 Hydrogen 4/7/2023
非常感谢您的解释!只是好奇为什么会这样?对我来说,它应该只包含 a 和 a ,因此总共包含。Prefab24vptrCBaseEntity::m_ivar16
2赞 ALX23z 4/7/2023
@Hydrogen包含已经是 16 个。+8 更多 布局信息.PrefabCBaseEntity
0赞 curiousguy 4/12/2023
@Hydrogen 但你们俩都是对的!Q 中存在歧义:“具有虚拟基的类的大小是多少”。它是两个 Q,所以它有两个 A,你给一个,ALX23z 给另一个。这两个答案对于定义布局都是必不可少的:ALX23z 给出了完整对象(或成员子对象,或数组子对象)的分配值。您给出了在层次结构中定义基本子对象布局的答案。只有虚拟继承才具有这样的属性。
0赞 curiousguy 4/12/2023
"16+16+8+8 = 48“ 我不相信你应该做补充,除非作为估计。这些不是数据成员大小!即使您采取了预防措施,我们也不能总是添加碱基的大小:您添加的尺寸不完整 ()。要评估大小,您必须了解 vtables 的工作原理、它们需要包含的内容,这需要了解类布局,基于 ...vtables。这很复杂。sizeof
0赞 curiousguy 5/16/2023
我之前的评论过于简单化。当 vtable 具有不同的用途时,您应该进行加法,即 base 由于其不变量的发散性而必须不同的虚拟表,因此您必须计算 vtable 不变量:具有不同虚函数的类总是具有不同的 vtable。(具有相同签名的虚拟函数可以相同,也可以不同,具体取决于类布局,取决于 vptr 的数量,最后:取决于虚拟函数是否相同。对于超速者,这取决于 ABI 是否规定超速者有自己的 vtable 条目。
6赞 Miles Budnek 4/7/2023 #2

警告:这都是实现细节。不同的编译器可能会以不同的方式实现细节,或者可能同时使用不同的机制。这就是 GCC 在这种特定情况下的做法。

请注意,我忽略了用于实现方法调度的 vtable 指针,而是专注于如何实现继承。virtualvirtual

使用普通的非虚拟继承,a 将包含两个子对象:一个是它继承的子对象,另一个是它通过 继承的子对象。它看起来像这样:WeaponPrefabCBaseEntityCBaseWeaponPrefab

 WeaponPrefab
 ┌─────────────────────┐
 │ CBaseWeapon         │
 │ ┌─────────────────┐ │
 │ │ CBaseEntity     │ │
 │ │ ┌─────────────┐ │ │
 │ │ │ int64_t     │ │ │
 │ │ │ ┌─────────┐ │ │ │
 │ │ │ │ m_ivar  │ │ │ │
 │ │ │ └─────────┘ │ │ │
 │ │ └─────────────┘ │ │
 │ │  double         │ │
 │ │  ┌─────────┐    │ │
 │ │  │ m_flvar │    │ │
 │ │  └─────────┘    │ │
 │ └─────────────────┘ │
 │ Prefab              │
 │ ┌─────────────────┐ │
 │ │ CBaseEntity     │ │
 │ │ ┌─────────────┐ │ │
 │ │ │ int64_t     │ │ │
 │ │ │ ┌─────────┐ │ │ │
 │ │ │ │ m_ivar  │ │ │ │
 │ │ │ └─────────┘ │ │ │
 │ │ └─────────────┘ │ │
 │ └─────────────────┘ │
 │  char[8]            │
 │  ┌─────────┐        │
 │  │ words   │        │
 │  └─────────┘        │
 └─────────────────────┘

virtual继承可以避免这种情况。每个对象只有一个子对象,每个子对象都继承自 ly。在这种情况下,两者合二为一:virtualCBaseObjects

WeaponPrefab
┌───────────────────┐
│   char[8]         │
│   ┌─────────┐     │
│   │ words   │     │
│   └─────────┘     │
│ Prefab            │
│ ┌───────────────┐ │
│ └───────────────┘ │
│ CBaseWeapon       │
│ ┌───────────────┐ │
│ │  double       │ │
│ │  ┌─────────┐  │ │
│ │  │ m_flvar │  │ │
│ │  └─────────┘  │ │
│ └───────────────┘ │
│ CBaseEntity       │
│ ┌───────────────┐ │
│ │  int64_t      │ │
│ │  ┌─────────┐  │ │
│ │  │ m_ivar  │  │ │
│ │  └─────────┘  │ │
│ └───────────────┘ │
└───────────────────┘

不过,这带来了一个问题。请注意,在非虚拟示例中,对象始终是 0 字节,无论它是独立的还是 .但在示例中,偏移量是不同的。对于独立对象,它将与对象的开头相差 0 个字节,但对于作为子对象的子对象,它将与对象的开头相差 8 个字节。CBaseEntity::m_ivarPrefabWeaponPrefabvirtualPrefabCBaseEntity::m_ivarPrefabWeaponPrefabPrefab

为了解决这个问题,对象通常带有一个额外的指针,指向由编译器生成的静态表,该表包含其每个基类的偏移量:virtual

                              Offset Table for
WeaponPrefab                  standalone WeaponPrefab
┌────────────────────┐        ┌──────────────────────┐
│   Offset Table Ptr │        │Prefab offset:      16│
│   ┌─────────┐      │        │CBaseWeapon offset: 24│
│   │         ├──────┼───────►│CBaseEntity offset: 40│
│   └─────────┘      │        └──────────────────────┘
│   char[8]          │
│   ┌─────────┐      │
│   │ words   │      │
│   └─────────┘      │
│ Prefab             │        Offset Table for
│ ┌────────────────┐ │        Prefab in WeaponPrefab
│ │Offset Table Ptr│ │        ┌──────────────────────┐
│ │  ┌─────────┐   │ │        │CBaseEntity offset: 24│
│ │  │         ├───┼─┼───────►│                      │
│ │  └─────────┘   │ │        └──────────────────────┘
│ └────────────────┘ │
│ CBaseWeapon        │        Offset Table for
│ ┌────────────────┐ │        CBaseWeapon in WeaponPrefab
│ │Offset Table Ptr│ │        ┌──────────────────────┐
│ │  ┌─────────┐   │ │        │CBaseEntity offset: 16│
│ │  │         ├───┼─┼───────►│                      │
│ │  └─────────┘   │ │        └──────────────────────┘
│ │  double        │ │
│ │  ┌─────────┐   │ │
│ │  │ m_flvar │   │ │
│ │  └─────────┘   │ │
│ └────────────────┘ │
│ CBaseEntity        │
│ ┌────────────────┐ │
│ │  int64_t       │ │
│ │  ┌─────────┐   │ │
│ │  │ m_ivar  │   │ │
│ │  └─────────┘   │ │
│ └────────────────┘ │
└────────────────────┘

请注意,这并不完全准确。由于没有数据成员,GCC 实际上避免给它自己的偏移表,而是让它共享 的表和指针。此图是 GCC 在至少有一个数据成员的情况下如何布置对象。PrefabWeaponPrefabPrefab

评论

2赞 ALX23z 4/7/2023
如何确定 和 的位置?它还以虚拟方式继承它们。你的意思是它也存储指向它们的指针吗?但是,他得到的对象大小的结果太小了。WeaponPrefabPrefabCBaseWeapon
1赞 Miles Budnek 4/7/2023
@ALX23z 你是对的,第一个指针应该是 a 而不是 a ;固定。 可以始终追逐指针,或者如果它需要找到它的子对象。WeaponPrefabCBaseWeapon*CBaseEntity*WeaponPrefabCBaseWeaponPrefabCBaseEntity
2赞 Miles Budnek 4/7/2023
@grizzlybears asciiflow.com
1赞 Hydrogen 4/7/2023
啊,谢谢,我想我只是明白为什么如果我在运行时做一个,我会得到一个不同的地址......从实际查询内存区域的某些内容进行转换......dynamic_castWeaponPrefabCBaseEntity
3赞 Miles Budnek 4/7/2023
@ALX23z 你再一次是对的。它实际上存储指向静态偏移表的指针,而不是直接指向父类。我基于我认为从对象开始到第一个成员的 16 字节偏移量来假设这个假设,但事实证明我读错了输出。我应该从一开始就看看大会。
0赞 curiousguy 4/12/2023 #3

与每个实例中 vptr(vtable 指针的缩写)和 vtable 的布局规则不同,对于 SI(单继承)来说,这些规则非常简单 (1),这里的规则非常复杂,虽然我愿意非常详细地讨论这些规则,但我认为它可能有点无聊 - 如果请求只是简单的,则完全无关紧要: 我可以保持与非虚拟继承相同的布局(和 ABI)吗?

虚拟继承的规则比简单的非虚拟继承的规则更复杂,因为虚拟对于成员函数和基类来说意味着基本相同的东西 (2):它增加了一定程度的灵活性,即改变行为的潜力,正如派生类中的“覆盖”(3) 断言 (4) 所允许的那样。

但是虚拟继承会覆盖基类继承,因此它会影响数据布局,这与虚拟函数覆盖不同!

与往常一样,这种灵活性是通过添加间接级别来实现的。可以通过以下任一方式完成:

  • 基类子对象内另一个对象片段的内部指针;
  • 表示该片段相对位置的偏移量;
  • 或者将 vptr 转换为具有所有基类子对象(另一个给定类的子对象)依赖的、与实例无关的信息的 vtable:该信息取决于我们所处的特定基类子对象(由最派生对象的完整继承路径 (5) 指定),而不是特定实例(完整对象的所有特定基具有相同的相对位置)。

最新的选择是最节省空间的:基类子对象最多一个指针,通常更少(问题太复杂,无法讨论何时引入另一个 vptr,除非您希望我提供详细信息 (6))。

笔记

(1) 如果排除析构函数调用与删除运算符、运算符和幂(类指针或无效指针)等问题,即使是微不足道的。typeiddynamic_cast

(2)我知道很多作者解释说,虚拟关键字在这里被重载了两个完全不相关的目的,但我不同意。

(3) overriding 这个词通常不用于虚拟基础:我们通常不会将虚拟继承定义为对另一个继承的覆盖,但这样说与虚拟函数覆盖的类比是合适的。

(4) 因为基类继承不是在派生类中“声明”的,也不是声明,所以我在这里使用“断言”;语法 “asserts” 表示是公共基础,就像 “asserts” 表示虚拟成员函数一样。: public Base: public BaseBasevirtual void foo();foo()

(5) 完整的继承路径提到了到达特定间接基所需的每个直接基数,例如 .只有使用这样的路径,您才能在所有情况下明确指定基类子对象。(7)MostDerived::Derived2::Derived1::Base

派生到基指针的转换仅在允许的完整路径方面指定:仅当可以找到一个这样的路径时,才能将指针转换为基类型(并且通过虚拟继承,可以存在许多等效的此类路径)。

(6)如果你愿意,我可以添加很多细节;这是一个承诺 - 或者如果你害怕精细的 ABI 细节,就是一个威胁......

(7)值得注意的是,早期版本的C++标准没有对这种路径进行认真的解释,这是C++的基本继承概念,尤其是在处理多重继承时。

它被完全隐含地留给直觉读者推断:解码标准是批判性思维和直觉的练习。如果你坚持黑白的字母和规则,你经常会错过整个想法并弄错东西!但是,这种直觉阅读只有在接受严肃的C++技能教育时才能奏效。