C++内存对齐与多态

最近在看Inside the C++ Object Model,一种日经面试题,求sizeof(A)

1
2
3
4
5
6
7
8
9
10
struct A{
char c;
int i;
long long ll;
int j;
virtual void f(){};
};
struct B{

};

这种题目其实考的知识点很多,包含了虚函数虚继承、内存对齐等一系列C++的语言特性。

普通情况和继承条件下的align和padding

首先,int长度是不固定的,与编译器和平台有关,所以首先可以回答“不知道”。但假设是按“通常情况来计算”,于是埋头苦算。
恩,出现了虚函数,包括虚的析构函数就要创建虚表,在对象中会储存一个虚指针vptr,根据指令集字长定。这里可以考虑为4。但下面不能简单相加了,在实际输出下,答案是32。
究其原因是C++的内存对齐机制,对齐要求来自于一些CPU的架构、编译器或者系统设计上的考虑,在对齐的存储下,有些存储操作是原子的,所以可以很快。根据标准,对齐应该遵守下面的规则:

  1. 每个成员应该对其到它的大小,即开始位置应当能够被其size整除
    因此如果一个struct中一个成员不能对齐到对应的size,那么就需要在前面一个成员后面加padding。因此我们发现struct中所有字段的顺序不一样,其大小也会不一样。
  2. 整个struct按最大成员大小进行对齐
    在对齐的情况下,对象的大小也会改变。根据Inside the C++ Object Model Ch1.3中的注解,一个class object的大小包括其non-static data members的总大小,加上保持alignment时需要padding上去的空间(在members之间以及在objects之间),以及维持虚函数等的开销。因此上面的结构等价于
    1
    2
    3
    4
    5
    6
    7
    8
    void * vftable; // 4
    char c; // 5
    char gap[3]; // 8
    int i; // 12
    char gap[4]; // 16
    long long ll; // 24
    int j; // 28
    char gap[4]; // 32

根据Inside the C++ Object Model Ch3.1,一个空的struct仍有1字节,这是为了保证其实例化得到的所有对象能在内存上拥有自己独有的地址,我们发现这里没有为这个空的struct加上padding。但正如下面即将看到的一样,这个1字节在使用虚继承时可以被去掉。

虚继承下的 align 和 padding

下面看看复杂一点的虚继承。使用虚继承,避免在菱形继承中 A 出现两份的 X 拷贝,进而出现二义性的问题。对于给定的虚基类,无论它在继承体系中作为虚基类出现了多少次,只继承一个共享的基类子对象,这样菱形继承中的二义性问题也能够解决了(但普通多继承并没有解决)。
下面看看 Inside the C++ Object Model 上的这段菱形继承代码,它的大小如何?

1
2
3
4
class X{};
class Y: public virtual X{};
class Z: public virtual X{};
class A: public Y, public Z{};

作者指出应当为 1 8 8 12,但也同时指出有些编译器(译者指出例如MSVC)会产生 1 4 4 8

  • 首先 X 有 1 的开销这是毫无疑问的。
  • Y 大小为 8。
    • 首先 Y 虚继承了 X,所以还需要一个4字节的指针,指向虚基类的 X 大小为1字节的子对象。这是类似于 vftable 指针的机制,称为 vbtable 指针。
    • 然后 X 的1字节的占位符也被继承下来到了 Y 对象中。这里译者的图应该错了,不然没有办法解释后面 A 也能算空类,为啥不给 A 加上1字节。
    • 最后为已有的 5 字节加上 3 字节的 padding,总共有8字节。
  • A 拥有 12 字节,这包括
    • YZ 的子对象中除 X 以外的部分,各4字节。注意不是8字节,由于X被共享了,这也是虚继承的核心思想。
    • 再加上被共享的 X 的1字节,共9字节,加上 padding 共 12 字节。Inside the C++ Object Model 指出此时 A 自己的大小是 0 字节。

MSVC 优化掉了空类 X 占用的1字节开销,这样 Y 最后的3字节的 padding 也不需要了,因此只保留了一个指针。

虚函数+虚继承

虚函数和虚继承可能同时出现。

控制对象对齐方式

对齐要求源自CPU的一些指令需要传入的地址满足能整除某个值。

C语言中的规定

有的时候我们需要禁用对齐功能,例如我们需要将某个struct按照二进制格式进行传输或者存储,这时候我们需要严格的、紧缩的大小,而不是加上一个和具体对齐方式有关的padding占用空间。在g++中可以通过__attribute__((__packed__))来禁止编译器进行优化对齐。packed属性可能是不安全的,因为它会导致非对齐访问。注意这里要区分packed和aligned,aligned(x)强调的是我对象所在的地址应当至少以x字节对齐,为了对齐会导致padding的加入,从而改变内存布局。packed会禁用padding。因此会产生__attribute__((packed, aligned(4)))这样的奇怪用法。

C++11标准中的规定

C++11提供了一系列控制对齐的功能,其中alignof能够求出对应类型的对齐,std::alignment_of是标准库对alignof的封装,具有size_t类型

1
2
3
4
5
template<class _Ty>
struct alignment_of
: integral_constant<size_t, alignof(_Ty)>
{ // determine alignment of _Ty
};

指针与对齐

考虑一个多继承的情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct BA
{
int a;
};
struct BB
{
double b;
};
struct D : public BA, public BB
{
};
int main(){
D d;
D d2;
D * pd = &d;
B1 * pb1 = &d;
B2 * pb2 = &d;
int * pi = reinterpret_cast<int*>(&d);
pd == pb1; // true,指向同一对象
pb1 == pb2; // CE,虽然指向同一对象,但两个指针类型都不等于对象类型(即使有继承关系)
pi == pd; // CE,虽然指向同一对象,但两个指针类型都不等于对象类型
}