写点什么

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

作者:爱分享
  • 2024-04-15
    广东
  • 本文字数:8396 字

    阅读完需:约 28 分钟

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

在 C 语言中,数据和数据的处理操作(函数)是分开声明的,在语言层面并没有支持数据和函数的内在关联性,我们称之为过程式编程范式或者程序性编程范式。C++兼容了 C 语言,当然也支持这种编程范式。但 C++更主要的特点在支持基于对象(object-based, OB)和面向对象(object-oriented, OO),OB 和 OO 的基础是对象封装,所谓封装就是将数据和数据的操作(函数)组织在一起,在语言层面保证了数据的访问和操作的一致性,这样从代码上更能表现出数据和函数的关系。在这里先不讨论在软件工程上这几种编程范式的优劣,我们先来分析对象加上封装后的内存布局,C++相对于 C 语言是否需要占用更多的内存空间,如果有,那么到底增加了多少内存成本?本文接下来将对各种情形进行分析。

空对象的内存布局

请看下面的代码,你觉得答案应该输出多少?


#include <iostream>using namespace std;
class Object { // empty};
int main() { Object object; cout << "The size of object is: " << sizeof(object) << endl;
return 0;}
复制代码


答案是会输出:The size of object is: 1,是的,答案是 1 字节。在 C++中,即使是空对象也会占用一定的空间,通常是 1 个字节。这个字节用来确保每个对象都有唯一的地址,以便在程序中进行操作。

含有数据成员的对象的内存布局

  • 非静态数据成员


现在再往这个类里面加入一些非静态的数据成员,来看看加入非静态的数据成员之后内存布局占用多少空间。


#include <iostream>using namespace std;
class Object {public: int a; int b;};
int main() { Object object; cout << "The size of object is: " << sizeof(object) << endl; cout << "The address of object: " << &object << endl; cout << "The address of object.a: " << &object.a << endl; cout << "The address of object.b: " << &object.b << endl;
return 0;}
复制代码


运行结果输出的是:


The size of object is: 8The address of object: 0x16f07f464The address of object.a: 0x16f07f464The address of object.b: 0x16f07f468
复制代码


现在 object 对象总共占用了 8 字节。int 类型在我测试的机器上占用 4 字节的空间,这个跟测试的机器有关,有的机器有可能是 8 字节,在一些很老的机器上也有可能是 2 字节。


看后面三行的地址,可以看出,数据成员 a 的地址跟对象的地址是一样的,也就是说它是排列在对象的开始处,接下来是隔了 4 个字节后的地址,也就是数据成员 b 的地址,这说明数据成员 a 和 b 是顺序且紧密排列在一起的,并且是从对象的起始处开始的。结果表明,在这种情况下,C++的对象的内存布局跟 C 语言的结构的内存布局是一样的,并不会比 C 语言多占用一些内存空间。


  • 静态数据成员


C++的类也支持在类里面定义静态数据成员,那么定义了静态数据成员之后类对象的内存布局是怎么样的呢?在上面的类中加入一个静态数据成员,如以下代码:


class Object {public:    int a;    int b;    static int static_a;};
复制代码


运行结果输出:


The size of object is: 8The address of object: 0x16b25f464The address of object.a: 0x16b25f464The address of object.b: 0x16b25f468The address of object.static_a: 0x104ba8000
复制代码


对象的大小结果还是 8 字节,说明静态成员变量并不会增加对象的内存占用空间。看下它们各个的地址,从结果可以看出,静态成员变量的地址跟非静态成员变量的地址相差很大,推断肯定不是和它们排列在一起的。在 main 函数中增加如下代码:


Object obj2;cout << "The size of obj2 is: " << sizeof(obj2) << endl;cout << "The address of obj2.static_a: " << &obj2.static_a << endl;
复制代码


输出结果为:


The size of obj2 is: 8The address of obj2.static_a: 0x104ba8000
复制代码


定义了第 2 个对象,这个对象的大小也还是 8 字节,说明静态对象不是存储在每个对象中的,而是存在某个地方,由所有的同一个的类对象所共有的。从第 2 行输出的地址可以看出来,它的地址和第 1 个对象输出的地址是一样的,说明它们指向的是同一个变量。其实类中的静态数据成员是和全局变量一样存放在数据段中的,它的地址是在编译的时候就已经确定的了,每次运行都是一样的。它和全局变量一样,地址在编译时确定,所以访问它没有任何性能损失,和全局变量的区别是它的作用域不一样,类的静态数据成员的作用域只有在类中可见,访问权限受它在类中定义时的访问权限区段所控制。

含有成员函数的对象的内存布局

上面所讨论的都是类里面只有数据成员的情况,如果在类里再加上成员函数时,类对象的内存布局会有什么变化?在类中增加一个 public 的成员函数和一个静态成员函数,代码修改如下:


#include <iostream>#include <cstdio>using namespace std;
class Object {public: void print() { cout << "The address of a: " << &a << endl; cout << "The address of b: " << &b << endl; cout << "The address of static_a: " << &static_a << endl; }
static void static_func() { cout << "This is a static member function.\n"; }
private: int a; int b; static int static_a;};
int Object::static_a = 1;
int main() { Object object; cout << "The size of object is: " << sizeof(object) << endl; printf("The address of print: %p\n", &Object::print); printf("The address of static_func: %p\n", &Object::static_func); object.print(); object.static_func();
return 0;}
复制代码


运行输出结果如下:


The size of object is: 8The address of print: 0x102d93120The address of static_func: 0x102d931c4The address of a: 0x16d06f464The address of b: 0x16d06f468The address of static_a: 0x102d98000This is a static member function.
复制代码


类对象的大小还是没变,还是 8 字节。说明增加成员函数并没有增加类对象的内存占用,无论是普通成员函数还是静态成员函数都一样。其实类中的成员函数并不存储在每个类对象中的,而是跟类的定义相关的,它是存放在可执行二进制文件中的代码段里的,由同一个类所产生出来的所有对象所共享。从上面输出结果中两个函数的地址来看,它们的地址很相近,说明普通成员函数和静态成员函数都是一样的,都存放在代码段中,地址在编译时就已确定。调用它们跟调用一个普通的函数没有什么区别,不会有性能上的损失。

含有虚函数的对象的内存布局

面向对象主要的特征之一就是多态,而多态的基础就是支持虚函数的机制。那么虚函数的支持对对象的内存布局会产生什么影响呢?这里先不分析虚函数的实现机制,我们先来分析内存布局的成本。在上面的例子中加入两个虚函数:一个普通的虚函数和虚析构函数,代码如下:


virtual ~Object() {    cout << "Destructor...\n";}
virtual void virtual_func() { cout << "Call virtual_func\n";}
// 在main函数里增加两行打印printf("The address of object: %p\n", &object);printf("The address of virtual_func: %p\n", &Object::virtual_func);
复制代码


编译运行,看看输出:


The size of object is: 16The address of object: 0x16f97f458The address of print: 0x100482f74The address of static_func: 0x10048301cThe address of virtual_func: 0x10The address of a: 0x16f97f460The address of b: 0x16f97f464The address of static_a: 0x100488000Destructor...
复制代码


在没有增加任何数据成员的情况下,对象的大小增加到了 16 字节,这说明虚函数的加入改变了对象的内存布局。那么增加的内容是什么呢?我们看到输出的打印中对象的首地址为 0x16f97f458,而数据成员 a 的地址为 0x16f97f460,这中间刚好差了 8 字节。而从上面的分析我们知道,原来 a 的地址是和对象的首地址是一样的,也就是说对象的内存布局是从 a 开始排列的,而现在在对象的起始地址和成员变量 a 之间空了 8 个字节,那么排在 a 之前的这 8 个字节的内容是什么呢?我们加点代码把它的内容输出出来,在 main 函数中加入以下代码:


long* p =  (long*)&object;long* vptr = (long*)*p;printf("vptr is %p\n", vptr);
复制代码


输出结果:


The size of object is: 16The address of object: 0x16b00f458The address of print: 0x104df2f68The address of static_func: 0x104df3010The address of virtual_func: 0x10The address of a: 0x16b00f460The address of b: 0x16b00f464The address of static_a: 0x104df8000vptr is 0x104df4110Destructor...
复制代码


它的内容是 0x104df4110,它其实是一个指针,在我的机器上占用 8 字节,在某些机器上可能是 4 字节。这个指针指向的其实是一个虚函数表,虚函数表是一个表格,表格里的每一项的内容存放的是每个虚函数的地址,这个地址指向虚函数真正的地址,在上面的打印中虚函数打印出来的地址是 0x10,这个其实不是它的真正地址,是它在表格中的偏移地址。可以看到这个虚函数表地址和静态成员 static_a 的地址非常相近,其实虚函数表也是存放在数据段里面的,它在编译的时候由编译器确定好内容,并且编译器会自动扩充一些代码,在构造对象的时候把虚函数表的首地址插入到对象的起始位置。虚函数的详细分析在这里先不展开,后面再详细分析。从这里的分析可以看到,类里面增加虚函数,会在对象的起始位置上插入一个指针,对象的大小会增加一个指针的大小,为 8 字节或者 4 字节。如下面的示意图:


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

继承是 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: 8The size of p3d is: 12The address of p3d: 0x16d2bb458The address of x: 0x16d2bb458The address of y: 0x16d2bb45cThe address of z: 0x16d2bb460
复制代码


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



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


The size of p2d is: 2The size of p3d is: 3The address of p3d: 0x16ba63467The address of x: 0x16ba63467The address of y: 0x16ba63468The address of z: 0x16ba63469
复制代码


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


The size of p2d is: 8The size of p3d is: 12The address of p3d: 0x16d033458The address of x: 0x16d033458The address of y: 0x16d03345cThe 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: 12The address of obj: 0x16f737460The address of b1: 0x16f737460The address of b2: 0x16f737464The 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: 32The address of obj: 0x16b807448The address of b1: 0x16b807450The address of b2: 0x16b807460The 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: 4The size of b1 is: 4The size of b2 is: 4The 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: 4The size of b1 is: 16The size of b2 is: 16The size of obj is: 40The address of obj: 0x16d70b420The address of obj.a: 0x16d70b440The address of obj.b: 0x16d70b428The address of obj.c: 0x16d70b438The 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,可以看到虚继承下来的成员变量被安排到最后的位置了,从打印的地址也可以看出来。布局示意图如下:



此篇文章同步发布于我的微信公众号:C++对象封装后的内存布局

如果您感兴趣这方面的内容,请在微信上搜索公众号 iShare 爱分享或者微信号 iTechShare 并关注,以便在内容更新时直接向您推送。

发布于: 2024-04-15阅读数: 11
用户头像

爱分享

关注

还未添加个人签名 2024-04-08 加入

还未添加个人简介

评论

发布
暂无评论
深度解读《深度探索 C++ 对象模型》之C++对象的内存布局_c++_爱分享_InfoQ写作社区