对于在布局上不是POD的类类型C,假设其所有组件类型(即所有严格基类和非静态数据成员类型)已完成布局,已经定义了大小、数据大小、非虚大小、对齐和非虚对齐(请参阅[2.1 General])。类型C的布局将按照以下过程完成:

I.Initialization(初始化)

    1. 初始化sizeof(C)为0,align(C)为1,dsize(C)为0
    1. 如果C是动态类类型:
    • a. 确定间接主基类:找出所有虚基类(无论是直接还是间接的,但是还有其他约束条件,见后半句),这些虚基类必须在继承体系中是其他直接或间接基类的主基类。这些虚基类称为间接主基类
    • b. 选择主基类B:如果C有一个动态基类(基类是动态类,即基类①包含虚函数②或继承了包含虚函数的类③或有虚基类。这种情况下,类C也成为了一个动态类型),那么下面要选出一个主基类B:
      • ①选择按直接基类声明顺序排列(即继承列表)的第一个非虚动态基类(若存在)。
      • ②如果不存在这样的基类(即全是虚继承的基类),则选择一个近似空的虚基类(nearly empty virtual base class)(若存在,条件见后半句),这个近似空的虚基类是按继承图的前序排列选择的第一个不是间接主基类(2a中提到)的近似空的虚基类,或者
      • ③如果近似空的虚基类都是间接主基类,则选择第一个近似空的虚基类。
    • c. 若C没有主基类:在偏移量0处分配虚表指针(vptr)。设置sizeof(C)align(C)dsize(C)为指针的适当值(在Itanium 64位ABI中,所有这些值都是8字节)。(这通常是当C直接定义了虚函数,但没有任何合适的基类可以作为主基类时发生)
Paw5zx个人理解:点击查看更多

2b的伪代码说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if 存在非虚的动态直接基类:
# ①
主基类 = 按继承列表顺序出现的第一个这样的基类
else:
Cands = { C的所有虚基类(直接或间接)中近似空的那些 }
if Cands 非空:
if 存在不是间接主基类的元素:
# ②
主基类 = 继承图前序遍历中出现的第一个非间接主基类的近似空虚基类
else:
# ③Cands全部都是间接主基类
主基类 = 继承图前序遍历中的第一个近似空虚基类
else:
# 没有可选主基类
# 2c:在offset 0放置C自己的vptr
主基类 = 无
对象起始处 = C自己的vptr

上述情况(2b)现在被认为是设计中的错误。使用第一个间接主基类作为派生类的主基类不会节省对象中的任何空间,并且会导致虚函数指针的一些重复,这些重复出现在额外复制的一份基类虚表中。

Paw5zx个人理解:点击查看更多

对于2b的设计错误举例说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct A    // nearly empty & dynamic
{
virtual void f(){ std::cout << "A::f\n"; }
};

struct B : virtual A
{
int b;
};

struct C : virtual A
{
int c;
};

struct D : virtual B, virtual C
{
void f() override { std::cout << "D::f\n"; }
};

从理论上推导:
在选择主基类时,首先D没有非虚直接动态基类,2b①不成立;其次其虚基类中的近似空的虚基类只要A,但是又由于A是其间接主基类,因此2b②不成立;所以根据2b③,D的主基类为A

下面进一步验证,编译时添加-fdump-lang-class选项,查看调试信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Class D
size=40 align=8
base size=8 base align=8
D (0x0x7f809d5a01c0) 0 nearly-empty
vptridx=0 vptr=((& D::_ZTV1D) + 48)
B (0x0x7f809d579888) 8 virtual
lost-primary
subvttidx=32 vptridx=8 vbaseoffset=-32 vptr=((& D::_ZTV1D) + 88)
A (0x0x7f809d5626c0) 0 nearly-empty virtual
primary-for D (0x0x7f809d5a01c0)
vptridx=16 vbaseoffset=-40
C (0x0x7f809d5798f0) 24 virtual
lost-primary
subvttidx=48 vptridx=24 vbaseoffset=-48 vptr=((& D::_ZTV1D) + 128)
A (0x0x7f809d5626c0) alternative-path

其中显示,A是D的主基类(primary-for D (0x0x7f809d5a01c0))。然后我们关注(待补充)

这样做的好处是,使用派生类虚表指针作为基类虚表指针通常可以省去一次加载,并且在调用其虚函数时不需要对this指针进行调整。

Paw5zx个人理解:点击查看更多

这样做的好处是,使用派生类虚表指针作为基类虚表指针通常可以省去一次加载,并且在调用其虚函数时不需要对this指针进行调整。

其中“这样做”指的是2b中选择主基类的操作,这样一来,C可以与主基类B共享虚表指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct A {
virtual void f() { std::cout << "A::f\n"; }
};

struct B : virtual A {
int x;
void f() override { std::cout << "B::f\n"; }
};

struct C : B {
int y;
void g() { f(); } // 调用继承的虚函数
};

本来想的是2b规则可以让编译器在某些情况下避免调整this指针,但这是不正确的,因为虚函数调用算法要求:函数必须通过一个定义了该函数的类的指针来查找,而不是仅仅继承该函数的的类。删除这一要求并不是一个好主意,因为那样一来,编译器就无法保证生成所有需要的thunk,并且无法将这些thunk与它们实际跳转的目标函数一并放置。(要保证每个thunk与它对应的目标函数出现在同一个vtable切片中)

例如,考虑如下示例:

【示例:

1
2
3
4
struct A { virtual void f(); };
struct B : virtual public A { int i; };
struct C : virtual public A { int j; };
struct D : public B, public C {};

当B和C被声明时,A就是他们的主基类,因此,尽管在A-in-B和A-in-C虚表中分配了vcall offset,this指针也不需要进行调整,也不会产生thunk。然而在D的对象中,A不再是C的主基类了(因为D中只有一份A实例,此时A仍是B的主基类),因此如果我们允许C::f()的调用,去使用C子对象中A的虚表的副本,我们需要将this指针从C*调整至B::A*,这将需要一个第三方thunk。由于我们需要在调用C::f()前先转换为 A*,因此C-in-D中A虚表的副本将永远不会被引用,因此这是非必要的。——示例结束】

理解这一块(不理解,后面返过来再看)

II.Allocation of Members Other Than Virtual Bases(虚基类以外成员的内存分配)

对于每个数据组成D((考虑的顺序)首先是C的主基类(在2b中选出,如果有的话),然后是按声明顺序排列的非主非虚的直接基类,然后是按声明顺序排列的非静态数据成员和未命名位域),分配如下:

    1. 如果D是一个(可能是未命名的)位域,设其声明的类型为T,声明的宽度为n位,根据sizeof(T)n的关系,有以下两种情况:
    • a. 如果sizeof(T) * 8 >= n,则位域的分配遵循底层C ABI的规定,但有一个约束,即位域绝不放置在C的基类的尾部填充中。(这个约束的表达存疑,在下面个人理解中讨论)。
      • ①如果dsize(C) > 0,且在偏移dsize(C) - 1处的字节的一部分被C自己声明(不是来自C的严格基类)的数据成员中的某个位域占用,则该字节(dsize(C) - 1)剩余的空闲位(dsize(C) - 1处的未填充位)可用于放置接下来的位域;
      • ②否则,下一个位域的起始位置就在偏移dsize(C)处。
      • ③随后更新align(C)max (align(C), align(T))
    • b. 如果sizeof(T) * 8 < n,设T'为满足size(T') * 8 <= n的最大整形POD类型。则位域D在按T'对齐的下一个偏移量开始分配,长度为n位。
      • ①前sizeof(T) * 8位用于保存位域的值,
      • ②之后的n - sizeof(T) * 8位作为填充。
      • ③随后更新 align(C)max (align(C), align(T')).

无论哪种情况,都要更新dsize(C)使其覆盖到包含位域D(或其一部分)的最后一个字节,并更新sizeof(C)max(sizeof(C),dsize(C))

Paw5zx个人理解(对于1a):点击查看更多

先贴出代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base
{
public:
virtual void f();
int base_a;
unsigned int base_b : 5;
};
class S : public Base
{
public:

unsigned int a : 2;
unsigned int b : 3;
unsigned int c : 6;
unsigned int d : 14;
};

经分析S内存布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 示意图
+------------------------+--------+----------------------------------+
| Member | Offset | Description |
+------------------------+--------+----------------------------------+
| vptr | 0 | 8 bytes |
| base_a | 8 | 4 bytes |
| base_b | 12:0 | 5 bits |
| padding | 12:5 | 3 bits |
| a | 13:0 | 2 bits |
| b | 13:2 | 3 bits |
| c | 13:5 | 6 bits |
| padding | 14:3 | 13 bits |
| d | 16:0 | 14 bits |
| padding | 17:6 | 18 bits |
| padding | 20 | 4 bytes |
+------------------------+--------+----------------------------------+
| Total Size | | 24 bytes (size) |
+------------------------+--------+----------------------------------+

有如下疑问:

  • ①为什么派生类成员可以放置到基类子对象的填充位中?
  • ②进一步,为什么a被分配在13:0而不是12:5?
  • ③为什么d分配在16:0?

对于①,我的疑问主要来源于这一句:

如果sizeof(T) * 8 >= n,则位域的分配遵循底层C ABI的规定,但有一个约束,即位域绝不放置在C的基类的尾部填充中。

但是查询了一些资料,感觉这个约束,可能只针对POD类型。并且在[1.1 Definitions]中也有提到:

本ABI规则内使用POD定义仅用来决定是否在基类子对象的尾部填充中分配对象。随着时间的推移,这些标准已经扩展了POD的定义,而且它们也禁止程序员使用memcpy直接读取或写入基类子对象的底层字节。因此,即使在最保守的解释中,实现也可以在任何在C++98中不是POD类型的类的尾部填充中自由分配对象。本ABI也满足这种情况。

网上也有相关的讨论:

大概总结一下就是,非POD类型基类子对象的尾部填充,是可被重用的。

补充:C++ ABI Closed Issues 中也提到了:

Each subobject allocated is placed at the next available position that satisfies its alignment constraints, as in the underlying psABI. This is interpreted with the following special cases:

  • The “next available position” after a non-POD class subobject (base class or data member) with tail padding is at the beginning of the tail padding, not after it. (For POD objects, the tail padding is not “available.”)
  • Empty classes are considered to have alignment and size 1, consisting solely of one byte of tail padding.
  • Placement on top of the tail padding of an empty class must avoid placing multiple subobjects of the same type at the same address.

下面做个实验验证一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 将Base中的虚函数删除,换为一个double类型的数据成员
// 这下Base就是POD类型
class Base
{
public:
double base_d;
int base_a;
unsigned int base_b : 5;
};
class S2 : public Base
{
public:

unsigned int a : 2;
unsigned int b : 3;
unsigned int c : 6;
unsigned int d : 14;
};

经分析S内存布局如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 示意图
+------------------------+--------+----------------------------------+
| Member | Offset | Description |
+------------------------+--------+----------------------------------+
| base_d | 0 | 8 bytes |
| base_a | 8 | 4 bytes |
| base_b | 12:0 | 5 bits |
| padding | 12:5 | 27 bits |
| a | 16:0 | 2 bits |
| b | 16:2 | 3 bits |
| c | 16:5 | 6 bits |
| d | 17:3 | 14 bits |
| padding | 19:1 | 7 bits |
| padding | 20 | 4 bytes |
+------------------------+--------+----------------------------------+
| Total Size | | 24 bytes (size) |
+------------------------+--------+----------------------------------+

可以看到,当Base是POD类型时,它在派生类中的子对象的尾部填充没有被重用(a从偏移量16开始的)。所以非POD类型基类子对象的尾部填充是可以被重用的。

当然这个行为极大地取决于平台与编译器。

然后就延伸出了问题②,为什么a被分配在13:0而不是12:5?

(没有查询到有文档明文规定,派生类位域成员的分配要跳过基类尾部填充里的部分位直到字节对齐,这种行为。所以先认为这是实现隐含的行为吧。)

对于问题③,为什么d分配在16:0?

  • ELF x86-64-ABI psABI 中提到:

    bit-fields must be contained in a storage unit appropriatefor its declared type

  • GCC Internals Manual 中提到:

    on most machines, a named bit-field whose type is written as int would not cross a four-byte boundary

总结一下就是:命名位域会被分配在其声明类型(declared type)/底层类型(underlying type)对应的存储单元之内,不会跨越该存储单元的边界。

对应上述示例,d的声明类型是unsigned int,其存储单元大小是4字节,分配完a,b,c后,当前存储单元仅剩13位,不足以容纳d。于是GCC放弃这个单元,从下一个对齐开始,即第16字节。

这一点从pahole的输出也可以看出:

1
2
3
4
/* Force alignment to the next boundary: */
unsigned int :0;

unsigned int d:14; /* 16: 0 4 */

编译器实现在d前面插入了一个匿名零宽位域,这样d就被强制对齐到下一个对齐边界了

Paw5zx个人理解(对于1b):点击查看更多
1
2
3
4
5
struct C
{
char x;
unsigned char a : 25;
};

T可以选为unsigned short,因此在分配a的时候,对齐提升至2。a被分配在偏移量2位置,前8位为值,后17位位填充位。所以C对象的大小是6

1
2
3
4
5
6
7
8
9
10
11
12
// 示意图
+------------------------+--------+----------------------------------+
| Member | Offset | Description |
+------------------------+--------+----------------------------------+
| x | 0 | 1 bytes |
| padding | 1 | 1 bytes |
| a | 2 | 8 bits value + 17 bits padding |
| padding | 5:1 | 7 bits |
+------------------------+--------+----------------------------------+
| Total Size | | 6 bytes (size) |
+------------------------+--------+----------------------------------+

    1. 如果D既不是空基类,也不是空数据成员:
    • 对于D的内存分配,从偏移量dsize(C)开始,若有需要则按以下方式对齐:
      • 若D是基类,则对齐到nvalign(D)(即起始地址向上取整到nvalign(D)的倍数);
      • 若D是数据成员,则对齐到align(D)(即起始地址向上取整到align(D)的倍数)。
        然后将D放置在该偏移位置,除非这样做会导致两个(直接或间接的)相同类型的组成部分具有相同的偏移量。如果发生这种同类型组件冲突,则将候选偏移量增加(意思就是分配成员内存时,要考虑之前成员的内存位置以及对齐,再分配新成员内存时若出现冲突就,扩大搜索,直到不冲突),同样:
      • 若D是基类则加nvalign(D);
      • 若D是数据成员则加align(D).
        然后再次尝试;重复上述过程直到成功(可以保证在sizeof(C)向上取整到所需对齐量之前一定会操作成功)。如果D是基类,此步骤只为其非虚部分分配空间,即不包括其直接或间接的虚基类。

    然后是对于类C的属性的更新:

    • 对于sizeof(C):
      • 如果D是基类,则更新sizeof(C)max(sizeof(C), offset(D) + nvsize(D))
      • 如果D是一个潜在重叠的数据成员,则更新sizeof(C)max(sizeof(C), offset(D) + max(nvsize(D), dsize(D)))
      • 如果D是普通数据成员,则更新sizeof(C)max(sizeof(C), offset(D) + sizeof(D))
    • 对于dsize(C)align(C)
      • 如果D是基类(且在这里不是空类),则更新dsize(C)offset(D) + nvsize(D)align(C)max(align(C), nvalign(D))
      • 如果D是一个潜在重叠的数据成员,则更新dsize(C)offset(D) + max(nvsize(D), dsize(D))align(C)max(align(C), align(D))
      • 如果D是其他任何数据成员,则更新dsize(C)offset(D) + sizeof(D)align(C)max(align(C), align(D))。
Paw5zx个人理解():点击查看更多
    1. 如果D是一个空的严格基类或一个空的数据成员:
      它的分配方式与上面情况(2)类似,只是会在从dsize(C)开始之前考虑额外的候选偏移量。首先,尝试将D放置在偏移量零的位置。如果不成功(由于组件类型冲突),则像处理非空基类和成员那样,从dsize(C)开始尝试放置。在该情况下,如果在dsize(C)处发生类型冲突(在必要时更新对齐),则将候选偏移量递增nvalign(D),并再次尝试,重复这一过程直到成功。
      一旦确定了offset(D),则
    • 对于sizeof(C):更新sizeof(C)max(sizeof(C), offset(D)+sizeof(D))
    • 对于align(C)
      • 如果D是基类则更新为max(alignof(C), nvalign(D))
      • 如果D是数据成员则更新为max(alignof(C), align(D))
    • 对于dsize(C):由于D是空的,因此不需要更新 dsize(C)。

III.Virtual Base Allocation(虚基类的分配)

最后按继承图顺序,为所有直接或间接的虚基类(除了主基类或任何间接主基类)分配空间。其分配方式与非虚基类一致:

  • 若基类非空,按照步骤II-2;
  • 若基类为空类,按照步骤II-3。

然后更新sizeof(C)max (sizeof(C), offset(D)+nvsize(D))。如果该虚基类非空,还要按照II-2的规则更新align(C)dsize(C)

主基类已经在步骤I-2b中完成分配了。当前类C的任何间接主基类E(即一个被选择为C的某个其他基类(直接或间接,虚或非虚)的主基类的那个类)将作为那个其他基类的一部分分配,而不是在这里分配。如果E同时被多个其他基类选为主基类,则在C中只保留一个E的实例,应是按照继承图顺序中的第一个。

考虑:

1
2
3
4
struct R { virtual void r (); };
struct S { virtual void s (); };
struct T : virtual public S { virtual void t (); };
struct U : public R, virtual public T { virtual void u (); };

R是U的主基类,因为它是第一个直接非虚动态基类。然后,由于U的继承顺序遍历(前序)是{U, R, T, S},所以接下来分配基类T。由于S是T的主基类,所以无需单独分配它。但是,考虑:

1
2
3
struct V : public R, virtual public S, virtual public T {
virtual void v ();
};

上例中,V的继承顺序遍历是{V, R, S, T}。然而,尽管S首先被考虑作为一个虚基类进行分配,但因为它是T(V的另一个基类)的主基类,所以不会单独分配。因此,sizeof(V) == sizeof(U),并且V的完整的布局等价于结构体C:

1
2
3
4
struct X {
R r;
T t;
};

IV.Finalization

对于C的每个潜在重叠的非静态数据成员D,更新sizeof(C)max (sizeof(C), offset(D)+sizeof(D)),例如:

1
2
3
4
struct alignas(16) A { ~A(); }; // dsize 0, nvsize 0, size 16
struct B : A {}; // dsize 0, nvsize 16, size 16
struct X : virtual A, virtual B {}; // dsize 8, nvsize 8, size 32
struct Y { [[no_unique_address]] X x; char c; }; // dsize 9, nvsize 9, size 32

然后sizeof(C)向上取整到align(C)的非0倍数。如果C是POD类型,但不是POD for the purpose of layout,则设置 dsize(C) = nvsize(C) = sizeof(C)