深度解读《深度探索C++对象模型》之C++对象的内存布局(二)

目录

继承体系下的对象的内存布局

虚继承的对象的内存布局


接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎左下角点击关注!也可以关注公众号:iShare爱分享,或文章末尾扫描二维码,自动获得推文和全部的文章列表。

 上一篇讲解了空对象、含有数据成员和成员函数的内存布局,以及在有虚函数的情况下的内存布局,请从这里阅读:

深度解读《深度探索C++对象模型》之C++对象的内存布局(一)

这一篇重点讲解继承体系下的对象的内存布局,包括单一继承、多重继承、虚继承等不同的情况进行分析。

继承体系下的对象的内存布局

        继承是C++中很重要的一个功能,按照不同的形式有单一继承、多重继承、虚继承,按照继承权限有public、protected、private。下面我们一一来分析,为简单起见,我们只分析public继承。

  • 单一继承
#include <iostream>
#include <cstdio>
using namespace std;

class point2d {
public:
    int x() { return x_; }
    int y() { return y_; }
protected:
    int x_;
    int y_;
};

class point3d: public point2d {
public:
    int z() { return z_; }

    void print() {
        printf("The address of x: %p\n", &x_);
        printf("The address of y: %p\n", &y_);
        printf("The address of z: %p\n", &z_);
    }
protected:
    int z_;
};

int main() {
    point2d p2d;
    point3d p3d;
    cout << "The size of p2d is: " << sizeof(p2d) << endl;
    cout << "The size of p3d is: " << sizeof(p3d) << endl;
    cout << "The address of p3d: " << &p3d << endl;
    p3d.print();

    return 0;
}

        上面的代码编译运行输出:

The size of p2d is: 8
The size of p3d is: 12
The address of p3d: 0x16d2bb458
The address of x: 0x16d2bb458
The address of y: 0x16d2bb45c
The address of z: 0x16d2bb460

        类point3d只有一个数据成员z_,但大小却有12字节,很明显它的大小是加上父类point2d的大小8字节的。从输出的地址看,p3d的地址是0x16d2bb458,从父类继承而来的x_的地址也是0x16d2bb458,这说明从父类继承而来的数据成员排列在前面,从对象的首地址开始,按照它们在类中的声明顺序依次排序,接着是子类自己的数据成员,从上面的结果看起来对象中的数据成员在内存中是按照顺序且紧凑的排列在一起的,如下图所示:

        我们再来验证一下,把数据成员的声明类型改为char型,修改后输出结果:

The size of p2d is: 2
The size of p3d is: 3
The address of p3d: 0x16ba63467
The address of x: 0x16ba63467
The address of y: 0x16ba63468
The address of z: 0x16ba63469

        看起来似乎我们的猜测是正确的,我们再继续修改,把x_改为int型,其它两个为char型,声明顺序还是跟之前一样,这次的输出结果:

The size of p2d is: 8
The size of p3d is: 12
The address of p3d: 0x16d033458
The address of x: 0x16d033458
The address of y: 0x16d03345c
The address of z: 0x16d033460

        这次跟我们想要的结果不一样了,p2d的大小不是5字节而是8字节,p3d的大小不是6字节而是12字节,看起来编译器填充了内存空间使得他们的大小变大了。其实这时编译器为了访问效率选择了对齐,为了让变量的地址是4的倍数,它会填充中间的空挡,这些行为跟编译器有很大的关系,不同的编译器有不同的行为,类中数据成员的不同声明顺序和不同的数据类型可能就导致不同的结果。布局示意图如下:

  • 多重继承

        接下来看看一个类继承了多个父类,它的内存布局是怎么样的。请看下面的代码:

#include <iostream>
#include <cstdio>
using namespace std;

class Base1 {
public:
    int b1;
};

class Base2 {
public:
    int b2;
};

class Derived: public Base1, public Base2 {
public:
    int d;
    void print() {
        printf("The address of b1: %p\n", &b1);
        printf("The address of b2: %p\n", &b2);
        printf("The address of d: %p\n", &d);
    }
};

int main() {
    Derived obj;
    printf("The size of obj is: %lu\n", sizeof(obj));
    printf("The address of obj: %p\n", &obj);
    obj.print();

    return 0;
}

        输出结果:

The size of obj is: 12
The address of obj: 0x16f737460
The address of b1: 0x16f737460
The address of b2: 0x16f737464
The address of d: 0x16f737468

        对象的总大小是12字节,它是子类自身拥有的一个数据成员4字节加上分别从两个父类继承而来的两个数据成员共8字节的总和。从输出的地址可以看出来,从父类Base1继承来的成员b1和对象的首地址相同,接着是从父类Base2继承而来b2,最后是子类自己的成员d,说明对象的布局是从b1开始,然后是b2,最后是d,这个跟继承的顺序有关,第一继承而来的数据成员排在最前面,按照在类中声明的顺序依次排列,其次是第二继承而来的数据成员,以此类推,最后是子类自己的数据成员。布局示意图如下:

  • 父类带虚函数的继承

        如果父类中带有虚函数,那么对子类的内存布局有何影响?在上面的代码中的两个父类各加上一个虚函数,而子类暂时先不加虚函数,如下代码:

// 在class Base1中加入以下代码
virtual void virtual_func1() {
    printf("This is virtual_func1\n");
}

// 在class Base2中加入以下代码
virtual void virtual_func2() {
    printf("This is virtual_func2\n");
}

编译运行,输出结果:

The size of obj is: 32
The address of obj: 0x16b807448
The address of b1: 0x16b807450
The address of b2: 0x16b807460
The address of d: 0x16b807464

        这次对象的大小竟然是32字节,比上面的例子增加了20字节,这里并没有增加任何数据成员,只是仅仅在父类增加了虚函数,根据上面的分析,增加虚函数会引入虚函数表指针,指针占8字节的大小,那为什么会增加这么多呢?我们可以借助工具来分析一下,编译器一般会提供一些辅助分析工具供开发人员使用,其中有一个功能是把每个类的布局给打印出来,gcc、clang、vs都有类似的命令,clang可以使用下面的命令来查看:

clang -Xclang -fdump-record-layouts -stdlib=libc++ -std=c++11 -c filename.cpp

        输出的结果很多,我截取关键的一部分:

        上图中,左边的数字就是对象的成员相对于对象的起始地址的偏移量。从上图我们可以得出以下的结论:

        1.父类中各有一个虚函数表以及一个指向它的虚函数表指针,子类分别从父类中继承下来,父类有多少个虚函数表,子类就有多少个虚函数表。这里额外插一句,子类虽然继承了父类的虚函数表,但子类的虚函数表不会和父类的虚函数表是同一个,就算子类没有覆盖父类的任何虚函数,编译器也会复制多一份虚函数表出来,尽管它们的虚函数表的内容是一模一样的,但是一般情况下子类都会覆盖父类的虚函数,不然也没有必要用虚函数了,虚函数具体的分析以后再讲。

        2.编译器为了访问效率选择了8字节的对齐,也就是说成员变量b1占了8字节,数据本身占了4字节,为了对齐填充了4字节,使得下一个虚函数表指针可以对齐访问。

        所以,分析的结论就是子类对象的内存布局是这样的,首先是从Base1父类继承来的虚函数表指针,占用8字节,接着是继承来的b1成员变量,加上填充的4字节共占用了8字节,再接着是从父类Base2继承来的虚函数表指针,占用8字节,之后是继承的b2成员变量,占用4字节,子类自己的成员变量d紧跟着排列在后面,总共32字节。布局示意图如下:

虚继承的对象的内存布局

        虚继承是为了解决棱形继承情形下重复继承的问题提出来的解决办法,如下面的代码:

#include <iostream>
#include <cstdio>
using namespace std;

class Grand {
    int a;
};

class Base1: public Grand {
};

class Base2: public Grand {
};

class Derived: public Base1, public Base2 {
};

int main() {
    Grand g;
    Base1 b1;
    Base2 b2;
    Derived obj;
    //obj.a = 1;	// 这行编译不过。
    printf("The size of g is: %lu\n", sizeof(g));
    printf("The size of b1 is: %lu\n", sizeof(b1));
    printf("The size of b2 is: %lu\n", sizeof(b2));
    printf("The size of obj is: %lu\n", sizeof(obj));
    return 0;
}

        上面的代码中如果不把第23行代码屏蔽掉是编译不过的,因为Base1和Base2都继承了Grand,Derived又继承了Base1和Base2,Grand中的成员a将会被重复继承两次,这时在子类Derived中就存在了两个成员a,这时从Derived访问a就会出现错误,因为编译器不知道你要访问的是哪一个a,出现了名字冲突的问题。屏蔽掉第23行后编译运行,看下输出结果:

The size of g is: 4
The size of b1 is: 4
The size of b2 is: 4
The size of obj is: 8

        从结果中也可以验证,子类Derived占了两倍的大小。为了解决像这种重复继承了两次的问题,办法是引入虚继承,我们修改下代码继续分析:

#include <iostream>
#include <cstdio>
using namespace std;

class Grand {
public:
    int a;
};

class Base1: virtual public Grand {
public:
    int b;
};

class Base2: virtual public Grand {
public:
    int c;
};

class Derived: public Base1, public Base2 {
public:
    int d;
};

int main() {
    Grand g;
    Base1 b1;
    Base2 b2;
    Derived obj;
    obj.a = 1;
    printf("The size of g is: %lu\n", sizeof(g));
    printf("The size of b1 is: %lu\n", sizeof(b1));
    printf("The size of b2 is: %lu\n", sizeof(b2));
    printf("The size of obj is: %lu\n", sizeof(obj));
    printf("The address of obj: %p\n", &obj);
    printf("The address of obj.a: %p\n", &obj.a);
    printf("The address of obj.b: %p\n", &obj.b);
    printf("The address of obj.c: %p\n", &obj.c);
    printf("The address of obj.d: %p\n", &obj.d);
    
    return 0;
}

        这时访问Derived类的对象中的成员变量a就没有冲突了,如上面代码的第30行,上面代码的输出结果:

The size of g is: 4
The size of b1 is: 16
The size of b2 is: 16
The size of obj is: 40
The address of obj: 0x16d70b420
The address of obj.a: 0x16d70b440
The address of obj.b: 0x16d70b428
The address of obj.c: 0x16d70b438
The address of obj.d: 0x16d70b43c

        改为虚继承后,obj.a = 1;这行代码能编译通过了,不会出现名字冲突了。我们来看看孙子类Derived的对象的大小,竟然是40字节,增大了这么多,还是使用上面的命令来dump出对象的内存布局,结果如下图,截取部分:

        这里先补充一点,虚继承是借助于虚基类表来实现,被虚继承的父类的成员变量会放在虚基类表中,通过在对象中插入的虚基类表指针来访问虚基类表,有点类似于虚函数表,实现方式不同的编译器采用不一样的方式,gcc和clang是虚函数表和虚基类表共用一个表,称为虚表,所以只需要一个指针指向它,叫做虚表指针,而Windows平台的Visual Studio是采用两个表,所以Windows下对象里会有两个指针,一个虚函数表指针和一个虚基类表指针,虚基类的实现细节后面再详细分析。

        从上图可以看到,孙子类Derived的对象的内存里拥有两个虚表指针,因为父类Base1和Base2分别虚继承了爷爷类Grand,每一个虚继承将会产生一个虚表指针,按照继承的顺序依次排列,首先是Base1子对象的内容,包含了一个虚表指针和成员变量b,b之后会填充4字节到8字节对齐,然后是Base2子对象的内容,同样也包含了一个虚表指针和成员变量c,再之后是孙子类Derived自己的成员变量d,它是紧凑的排列在c之后的,最后是爷爷类Grand中的成员变量a,可以看到虚继承下来的成员变量被安排到最后的位置了,从打印的地址也可以看出来。布局示意图如下:


本主页会定期更新,为了能够及时获得更新,敬请关注我:点击左下角的关注。也可以关注公众号:请在微信上搜索公众号“iShare爱分享”并关注,或者扫描以下公众号二维码关注,以便在内容更新时直接向您推送。 

  • 48
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
深度探索C++对象模型》第版是C++领域中经典的著作之一。该书通过深入剖析C++对象模型,为C++开发者提供了深入理解和应用C++的基础知识和一些高级特性的重要指南。 该书首先介绍了C++中的基本概念,包括对象、类、继承等,并详细解释了C++中的虚函数、多态性和动态绑定等重要概念。这些概念是理解C++对象模型的基础,也是在实际开发中运用C++的关键。 接着,该书讲解了C++中的内存布局对象模型。通过对C++对象内存中的表示和存储方式的剖析,读者可以更好地理解C++对象的构造、析构和使用方式。同时,该书也介绍了C++中的虚函数表、虚指针和虚基类等重要机制,为读者揭示了C++中多态性的原理和实现方式。 此外,书中还介绍了C++中的派生类对象和基类对象之间的转换机制,包括指针转换、引用转换和类型转换等。这些内容对于理解C++中的对象关系以及编写灵活可扩展的代码都非常重要。 最后,该书还通过对C++中异常处理机制和模板编程的深入探讨,让读者了解到C++的一些更高级的特性和应用场景。 总之,《深度探索C++对象模型》第版是一本非常有价值的C++开发指南,适合C++的初学者和进阶开发者阅读。通过对C++对象模型的深入理解,读者可以更好地掌握C++的核心特性,从而写出高效、可扩展的C++代码。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

iShare_爱分享

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值