提问人:Hydrogen 提问时间:4/7/2023 更新时间:7/14/2023 访问量:212
虚拟继承的幕后发生了什么?
What is happening under the hood of virtual inheritance?
问:
最近我一直在尝试为一款老游戏制作一个插件,遇到了类似于 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 的东西在等着我。当我从继承中删除所有关键字时,类大小非常小,内存布局对我来说很有意义。但每当我穿上继承时,尺寸突然爆炸,布局似乎是一个谜。virtual
virtual
就像我在类中打印出 a 变量一样,它显示 ,但总大小是 ,这没有任何意义 - 和在哪里?offsetof
WeaponPrefab
8
48
m_ivar
m_flvar
(我不是想用未定义的行为来激怒人们,而只是想应对原版游戏中现有的 ABI。
编译器资源管理器链接:https://godbolt.org/z/YvWTbf8j8
答:
我已经运行了代码,类大小的答案是
sizeof(CBaseEntity) = 16
sizeof(CBaseWeapon) = 32
sizeof(Prefab) = 24
sizeof(WeaponPrefab) = 48
一般来说,虚函数的实现和虚继承是实现定义的,并且可能因编译器和其他选项而异。话虽如此,也许我可以对对象的大小提供一些解释,至少对于可能的实现是这样。
CBaseEntity
只是一个多态类型,因此有一个指向(对于我知道的 C++ 的所有实现都是如此,但不是标准强制要求),它还包含 .指针的大小 = 8,大小 = 8,所以总共正好是 16。vtable
int64
int64
CBaseWeapon
继承自并持有双精度。它已经必须至少是 24 号。现在,虚拟继承意味着 和 的对象位置之间的差异不是固定的 - 只有最终类才能确定它。此信息需要存储在类的实例中。我相信该信息位于布局开头的某个地方。为了包含这些信息,应该添加填充,以便由于对齐要求,大小可以被 8 整除。因此,总大小总和为 32。基本上,它在CBaseEntity
CBaseWeapon
CBaseEntity
CBaseWeapon
CBaseEntity
Prefab
类似于 ,但它不成立 。所以 24 或 8 在 .CBaseWeapon
double
CBaseEntity
WeaponPrefab
实际上继承自 、 、 和 包含 。所以,它已经需要.如果有的话,令人惊讶的是,它并没有更大。这可能是因为不存储任何对象,并且这两个类以某种方式共享布局位置变量,从而优化了类的存储大小。比如说,如果向 添加一个双杆件,则 的大小将增加到 64。CBaseEntity
CBaseWeapon
Prefab
char[8]
16+16+8+8 = 48
WeaponPrefab
Prefab
Prefab
WeaponPrefab
但是,正如我之前所说,这在很大程度上取决于确切的规格。我不知道你为哪个平台编码。我确信 ABI 的规范在互联网上的某个地方,您可以查找详细信息。例如,查看 Itanium C++ ABI,它可能与您相关,也可能与您无关。
编辑:正如@MilesBudnek所分析的那样,“layout-location 变量”实际上是指向编译器生成的偏移表的指针。因此,它需要 8 个字节或平台规定的任何字节。
评论
Prefab
24
vptr
CBaseEntity::m_ivar
16
Prefab
CBaseEntity
sizeof
警告:这都是实现细节。不同的编译器可能会以不同的方式实现细节,或者可能同时使用不同的机制。这就是 GCC 在这种特定情况下的做法。
请注意,我忽略了用于实现方法调度的 vtable 指针,而是专注于如何实现继承。virtual
virtual
使用普通的非虚拟继承,a 将包含两个子对象:一个是它继承的子对象,另一个是它通过 继承的子对象。它看起来像这样:WeaponPrefab
CBaseEntity
CBaseWeapon
Prefab
WeaponPrefab
┌─────────────────────┐
│ CBaseWeapon │
│ ┌─────────────────┐ │
│ │ CBaseEntity │ │
│ │ ┌─────────────┐ │ │
│ │ │ int64_t │ │ │
│ │ │ ┌─────────┐ │ │ │
│ │ │ │ m_ivar │ │ │ │
│ │ │ └─────────┘ │ │ │
│ │ └─────────────┘ │ │
│ │ double │ │
│ │ ┌─────────┐ │ │
│ │ │ m_flvar │ │ │
│ │ └─────────┘ │ │
│ └─────────────────┘ │
│ Prefab │
│ ┌─────────────────┐ │
│ │ CBaseEntity │ │
│ │ ┌─────────────┐ │ │
│ │ │ int64_t │ │ │
│ │ │ ┌─────────┐ │ │ │
│ │ │ │ m_ivar │ │ │ │
│ │ │ └─────────┘ │ │ │
│ │ └─────────────┘ │ │
│ └─────────────────┘ │
│ char[8] │
│ ┌─────────┐ │
│ │ words │ │
│ └─────────┘ │
└─────────────────────┘
virtual
继承可以避免这种情况。每个对象只有一个子对象,每个子对象都继承自 ly。在这种情况下,两者合二为一:virtual
CBaseObjects
WeaponPrefab
┌───────────────────┐
│ char[8] │
│ ┌─────────┐ │
│ │ words │ │
│ └─────────┘ │
│ Prefab │
│ ┌───────────────┐ │
│ └───────────────┘ │
│ CBaseWeapon │
│ ┌───────────────┐ │
│ │ double │ │
│ │ ┌─────────┐ │ │
│ │ │ m_flvar │ │ │
│ │ └─────────┘ │ │
│ └───────────────┘ │
│ CBaseEntity │
│ ┌───────────────┐ │
│ │ int64_t │ │
│ │ ┌─────────┐ │ │
│ │ │ m_ivar │ │ │
│ │ └─────────┘ │ │
│ └───────────────┘ │
└───────────────────┘
不过,这带来了一个问题。请注意,在非虚拟示例中,对象始终是 0 字节,无论它是独立的还是 .但在示例中,偏移量是不同的。对于独立对象,它将与对象的开头相差 0 个字节,但对于作为子对象的子对象,它将与对象的开头相差 8 个字节。CBaseEntity::m_ivar
Prefab
WeaponPrefab
virtual
Prefab
CBaseEntity::m_ivar
Prefab
WeaponPrefab
Prefab
为了解决这个问题,对象通常带有一个额外的指针,指向由编译器生成的静态表,该表包含其每个基类的偏移量: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 在至少有一个数据成员的情况下如何布置对象。Prefab
WeaponPrefab
Prefab
评论
WeaponPrefab
Prefab
CBaseWeapon
WeaponPrefab
CBaseWeapon*
CBaseEntity*
WeaponPrefab
CBaseWeapon
Prefab
CBaseEntity
dynamic_cast
WeaponPrefab
CBaseEntity
与每个实例中 vptr(vtable 指针的缩写)和 vtable 的布局规则不同,对于 SI(单继承)来说,这些规则非常简单 (1),这里的规则非常复杂,虽然我愿意非常详细地讨论这些规则,但我认为它可能有点无聊 - 如果请求只是简单的,则完全无关紧要: 我可以保持与非虚拟继承相同的布局(和 ABI)吗?
虚拟继承的规则比简单的非虚拟继承的规则更复杂,因为虚拟对于成员函数和基类来说意味着基本相同的东西 (2):它增加了一定程度的灵活性,即改变行为的潜力,正如派生类中的“覆盖”(3) 断言 (4) 所允许的那样。
但是虚拟继承会覆盖基类继承,因此它会影响数据布局,这与虚拟函数覆盖不同!
与往常一样,这种灵活性是通过添加间接级别来实现的。可以通过以下任一方式完成:
- 基类子对象内另一个对象片段的内部指针;
- 表示该片段相对位置的偏移量;
- 或者将 vptr 转换为具有所有基类子对象(另一个给定类的子对象)依赖的、与实例无关的信息的 vtable:该信息取决于我们所处的特定基类子对象(由最派生对象的完整继承路径 (5) 指定),而不是特定实例(完整对象的所有特定基具有相同的相对位置)。
最新的选择是最节省空间的:基类子对象最多一个指针,通常更少(问题太复杂,无法讨论何时引入另一个 vptr,除非您希望我提供详细信息 (6))。
笔记
(1) 如果排除析构函数调用与删除运算符、运算符和幂(类指针或无效指针)等问题,即使是微不足道的。typeid
dynamic_cast
(2)我知道很多作者解释说,虚拟关键字在这里被重载了两个完全不相关的目的,但我不同意。
(3) overriding 这个词通常不用于虚拟基础:我们通常不会将虚拟继承定义为对另一个继承的覆盖,但这样说与虚拟函数覆盖的类比是合适的。
(4) 因为基类继承不是在派生类中“声明”的,也不是声明,所以我在这里使用“断言”;语法 “asserts” 表示是公共基础,就像 “asserts” 表示虚拟成员函数一样。: public Base
: public Base
Base
virtual void foo();
foo()
(5) 完整的继承路径提到了到达特定间接基所需的每个直接基数,例如 .只有使用这样的路径,您才能在所有情况下明确指定基类子对象。(7)MostDerived::Derived2::Derived1::Base
派生到基指针的转换仅在允许的完整路径方面指定:仅当可以找到一个这样的路径时,才能将指针转换为基类型(并且通过虚拟继承,可以存在许多等效的此类路径)。
(6)如果你愿意,我可以添加很多细节;这是一个承诺 - 或者如果你害怕精细的 ABI 细节,就是一个威胁......
(7)值得注意的是,早期版本的C++标准没有对这种路径进行认真的解释,这是C++的基本继承概念,尤其是在处理多重继承时。
它被完全隐含地留给直觉读者推断:解码标准是批判性思维和直觉的练习。如果你坚持黑白的字母和规则,你经常会错过整个想法并弄错东西!但是,这种直觉阅读只有在接受严肃的C++技能教育时才能奏效。
评论
Vptr
Vtable
Vptr
offsetof(WeaponPrefab, words)
Vptr
WeaponPrefab : virtual public CBaseWeapon, virtual public Prefab
这里不需要(除非你计划进一步扩展继承格层次结构 - 不推荐)。virtual
offsetof
m_flvar
m_ilvar