目录
接下来我将持续更新“深度解读《深度探索C++对象模型》”系列,敬请期待,欢迎左下角点击关注!也可以关注公众号:iShare爱分享,或文章末尾扫描二维码,自动获得推文和全部的文章列表。
在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: 8
The address of object: 0x16f07f464
The address of object.a: 0x16f07f464
The 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: 8
The address of object: 0x16b25f464
The address of object.a: 0x16b25f464
The address of object.b: 0x16b25f468
The 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: 8
The 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: 8
The address of print: 0x102d93120
The address of static_func: 0x102d931c4
The address of a: 0x16d06f464
The address of b: 0x16d06f468
The address of static_a: 0x102d98000
This 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: 16
The address of object: 0x16f97f458
The address of print: 0x100482f74
The address of static_func: 0x10048301c
The address of virtual_func: 0x10
The address of a: 0x16f97f460
The address of b: 0x16f97f464
The address of static_a: 0x100488000
Destructor...
在没有增加任何数据成员的情况下,对象的大小增加到了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: 16
The address of object: 0x16b00f458
The address of print: 0x104df2f68
The address of static_func: 0x104df3010
The address of virtual_func: 0x10
The address of a: 0x16b00f460
The address of b: 0x16b00f464
The address of static_a: 0x104df8000
vptr is 0x104df4110
Destructor...
它的内容是0x104df4110,它其实是一个指针,在我的机器上占用8字节,在某些机器上可能是4字节。这个指针指向的其实是一个虚函数表,虚函数表是一个表格,表格里的每一项的内容存放的是每个虚函数的地址,这个地址指向虚函数真正的地址,在上面的打印中虚函数打印出来的地址是0x10,这个其实不是它的真正地址,是它在表格中的偏移地址。可以看到这个虚函数表地址和静态成员static_a的地址非常相近,其实虚函数表也是存放在数据段里面的,它在编译的时候由编译器确定好内容,并且编译器会自动扩充一些代码,在构造对象的时候把虚函数表的首地址插入到对象的起始位置。虚函数的详细分析在这里先不展开,后面再详细分析。从这里的分析可以看到,类里面增加虚函数,会在对象的起始位置上插入一个指针,对象的大小会增加一个指针的大小,为8字节或者4字节。如下面的示意图:
(未完待续。。。敬请点击左下角的关注以获得及时更新)
本主页会定期更新,为了能够及时获得更新,敬请关注我:点击左下角的关注。也可以关注公众号:请在微信上搜索公众号“AI与编程之窗”并关注,或者扫描以下公众号二维码关注,以便在内容更新时直接向您推送。