从sizeof习题来看C++虚继承内存布局和内存对齐

例题及结果

// test.cc
#include <iostream>
using namespace std;

struct B {
    int dataB;
};

struct D1 : virtual B {
    int dataD1;
};

struct D2 : virtual B {
    int dataD2;
};

struct MI : D1, D2 {
    int dataMI;
};

int main() {
    cout << sizeof(B) << endl;
    cout << sizeof(D1) << endl;
    cout << sizeof(D2) << endl;
    cout << sizeof(MI) << endl;
    return 0;
}

分别在win10和Ubuntu 16.04下编译64位的程序,因为64位程序下指针所占字节数为8。Ubuntu下是64位程序,而VS下默认是32位(x86)的,因此需要进行如下配置。


或者打开64位命令行工具手动编译


运行结果如下

$ uname -snm
Linux ubuntu x86_64
$ g++ test.cc
$ ./a.out 
4
16
16
40
windows运行结果

可以看到两者得到的结果不一样,原因之后分析

虚继承简述

C++中普通的继承可以简单地理解为派生类拥有基类成员的一份拷贝,而多继承则是按照继承顺序把基类的成员依次拷贝过来。如果不同基类拥有同名成员则需要明确访问哪个基类的成员。

struct Base1 { int a = 1; };
struct Base2 { int a = 2; };
struct Derived : Base1, Base2 {};

void foo(Derived& d) {  // 访问不同基类的同名成员
    printf("%d %d\n", d.Base1::a, d.Base2::a);
}

而在处理菱形继承(B->D1->MI B->D2->MI)时,则会有一些问题。假设B有一个成员变量int a;,那么D1中会有一份a的拷贝,D2中也会有一份a的拷贝,MI继承自D1D2,因此M2中实际上有2个变量a。《C++ Primer》给出的示例是iostream继承自istreamostream,而这两者都继承自ios_base,其中ios_base包含了一个缓冲区,如果不使用虚继承,那么iostream实际上包含了2个缓冲区。
如果让D1D2虚继承自B,那么MI中则只有一份B成员的拷贝。那么虚继承是怎么实现的?

虚继承的内存布局

参考【C++拾遗】 从内存布局看C++虚继承的实现原理的做法,截取D1D2对象的分布情况。

test.cc中D1和D2的内存布局

可以看到D1类的内存布局如下,一共占20个字节,至于sizeof(D1)是24而非20的原因最后再说。

  1. [0, 8) vbptr,即虚表指针(virtual base table pointer),64位下占8个字节
  2. [8, 12) 成员变量dataD1,int类型占4个字节
  3. [12, 16):内存对齐,占用4个字节
  4. [16, 20):基类B的成员变量dataB,int类型占4个字节。

排列顺序为vbptr->derived class member->base class member,如果改成普通继承,排列顺序为base class member->derived class member

test.cc中D1和D2改成普通继承后的内存布局

现在重点是虚表指针,注意下面这部分

D1::$vbtable@:
0       | 0
1       | 16 (D1d(D1+0)B)
vbi:       class  offset  o.vbptr  o.vbte  fVtorDisp
               B      16        0       4  0

虚表指针指向的即上面这样的虚表,虚表维护了一些信息,比如第一项就是基类名称B,第二项是偏移量。注意,虚继承看似把基类和派生类的排列顺序改变了,实际上并没有变。基类信息还是放在前面,只不过是以一个指针取而代之,能够从指针指向的对象中获取基类成员的偏移量,另一方面能够获取基类的类型信息,从而在派生类D1的派生类MI中决定是否是否只保留1份基类的拷贝。

dynamic_cast和reinterpret_cast的区别

C++特有的转型方式static_cast/reinterpret_cast/const_cast都可以用C风格的类型转换来代替,只是明确了类型转换的具体含义。这三种转型方式共同点就是变量的值实际没变。
C++特有的转型方式reinterpret_cast是对对象的重新解释,因此从 Base*转换成Derived*后,指针的值(地址)是不会改变的。

    int32_t i = 0x11223344;
    auto pch = reinterpret_cast<int8_t*>(&i);
    std::for_each(pch, pch + 4, [](char ch) { printf("%02x ", ch);  });
    // 小端系统下输出结果为44 33 22 11

转型转换的只是类型,而值是不变的,所以pch&i指向的均是变量i的地址。
但是dynamic_cast就不同了

    D1 d1;
    cout << &d1 << endl;
    cout << (B*)&d1 << endl;
    cout << static_cast<B*>(&d1) << endl;
    cout << dynamic_cast<B*>(&d1) << endl;
    cout << reinterpret_cast<B*>(&d1) << endl;
$ ./a.out 
0x7ffd92187390
0x7ffd9218739c
0x7ffd9218739c
0x7ffd9218739c
0x7ffd92187390

可以发现reinterpret_cast的地址仍然是D1对象的地址。
从派生类转型为基类时,值发生了改变,这是通过虚表指针实现的,虚表记录了实际基类对象地址相对派生类对象首部的偏移量,比如这里偏移量是12(为什么不是16?),所以转型后的地址值增加了12。虚表记录了类型信息,从而实现了多态,所以如果将指向基类对象的指针转换成指向派生类对象的指针时会返回nullptr,对引用的转型而言则会抛出bad_cast异常。这点是static_cast和C风格转型不具备的。

注意:如果类中不包含虚函数,则该类不被视为多态类型,在进行dynamic_cast向下转型(从基类指针/引用转型成派生类指针/引用)时,编译期间就会报错(source type is not polymorphic),而不是在运行期间抛出异常。

内存对齐

在明白了虚继承的内存布局后,就可以回顾最开始的代码,看看为什么sizeof的结果是4,16,16,40(Ubuntu)和4,24,24,48(Win10)。

内存对齐的规则

  1. 对于类的每个成员,偏移量必须是min(#pragma pack()指定的数,成员大小) 的倍数。
  2. 类的size必须是min(#pragma pack()指定的数,类中最大成员大小) 的倍数。

注意“成员”不包含“成员函数”,因为成员函数在内存中和类的对象并不是在一起的。没有显示定义宏#pragma pack()的参数时,按照系统默认的数来进行。
在我的Ubuntu下,默认是#pragma pack(4),所以结果和Win10的结果有所差异。这里按照#pragma pack的值为8计算。

D1类,虚表指针占用8个字节;dataD1占用4个字节;
至此完成了D1类的部分(12字节),D1类最大成员是虚表指针,8个字节,因此必须补齐为8的倍数,8*1<8+4<8*2,因此补齐4个字节,变成16字节。
后面接着B类的部分(4字节),一共20字节,补齐为8的倍数,即24字节。
D2D1一样,因此也占24个字节。
MI继承自D2D1,内存布局如下

  1. D1的虚表指针,8字节;
  2. dataD1,4字节,补齐4字节;
  3. D2的虚表指针,8字节;
  4. dataD2,4字节,补齐4字节;
  5. dataMI,4字节,补齐4字节;
  6. dataB,4字节;
    一共44字节,补齐至8的倍数,48字节。

其他测试

为了熟悉内存对齐规则,分别对开头的代码做出下列修改重新运行(#pragma pack(8)

  1. 去掉dataB成员,结果为1,16,16,40;注意一个空类单独占据1个字节,但是作为基类时占据派生类的0个字节。
  2. B类中增加成员char s[3];,结果为8,24,24,48;只有B类的大小改变了,相当于派生类的对齐部分有3个字节被它填充了。
  3. 在'D1'类或'D2'类中增加成员char s[3];,结果均为4,24,24,48。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,864评论 6 494
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,175评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,401评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,170评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,276评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,364评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,401评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,179评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,604评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,902评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,070评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,751评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,380评论 3 319
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,077评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,312评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,924评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,957评论 2 351