C/C++语言特别适合用于开发压榨机器性能的程序,在资源有限的情况下,C/C++程序员不能放过机器的每一点计算力以及每一字节内存。当然了,要做到这一点,编程语言本身要提供精准的控制能力,C/C++语言作为一种强类型的编程语言,“精准控制”自然不在话下。这样看来,将机器性能发挥到极限的责任最后还是落在程序员身上了,要做到这一点,我们至少需要对各种数据类型使用的内存情况了然于胸。
类的内存模型
一般来说,我们在刚开始接触C/C++语言时,就会知道不同的数据类型占用内存空间通常不同的概念,比如 char 类型占用 1 个字节内存空间,int 类型常常占用 4 字节内存空间,double 类型常常占用 8 字节内存空间,有经验的程序员还会明白指针以及结构体占用内存空间的情况,等等。
请注意“常常”一词,C语言标准暂时还没有明确定义 int 等数据类型占用内存空间的情况。
事实上,我之前也在讨论C语言特性的时候专门写文章分析过数据类型与内存使用之间的关系,虽说也涉及到稍稍复杂一些的内存对齐概念,但是对于C++语言中的类,倒是完全没有提及,而类是C++语言中非常重要的概念,要是弄不清楚它的内存分布,“精准控制”就完全是吹牛了。
查阅了不少资料,没有找到直接讨论C++语言中类的内存分布的,我倒也不想再花时间在搜索引擎上,倒不如通过几条线索自己分析,直观的理解C++语言中类的内存分布了。
空类
请看下面这段C++语言代码:
class A {
};
cout << sizeof(A) << endl; // 输出 1
看来即使是空类,编译器在处理它时,也会隐含的添加 1 个字节。
类的成员变量
现在为类 A 增加几个成员变量,并且分别将此时类 A 的 size,实例化对象的地址,以及成员变量的地址打印出来:
class A {
public:
int pub_i1;
int pub_i2;
};
A a;
cout << "sizeof A: " << sizeof(A) << endl;
cout << "a addr: " << &a << endl;
cout << "A::pub_i1 addr: " << &a.pub_i1 << endl;
cout << "A::pub_i2 addr: " << &a.pub_i2 << endl;
这段C++语言代码的输出如下,请看:
sizeof A: 8
a addr: 0x7fff98567040
A::pub_i1 addr: 0x7fff98567040
A::pub_i2 addr: 0x7fff98567044
可以看出,此时类 A 的的 size 恰好等于两个成员变量占用的内存之和(我的机器上sizeof(int)等于 4),并且对象 a 的地址和第一个成员变量 pub_i1 的地址相等,第二个成员变量 pub_i2 的地址则紧跟在 pub_i1 之后,由此可以推测类 A 的内存分布如下图:
此时的类 A 倒有些类似于C语言中的结构体:
struct A {
int pub_i1;
int pub_i2;
};
的确如此,事实上,类 A 中成员变量的在内存中的存储方式也会涉及到内存对齐,这一点也和C语言中的结构体类似,不过这不是本文的重点,感到陌生的读者可以再回头看看我之前的文章。
类的成员函数
为类 A 添加两个成员函数,并且将其地址打印出来:
class A {
public:
int pub_i1;
int pub_i2;
void pub_foo1() {}
void pub_foo2() {}
};
A a;
cout << "sizeof A: " << sizeof(A) << endl;
cout << "a addr: " << &a << endl;
cout << "A::pub_i1 addr: " << &a.pub_i1 << endl;
cout << "A::pub_i2 addr: " << &a.pub_i2 << endl;
printf("A::pub_foo1() addr: %p\n", (void *)&A::pub_foo1);
printf("A::pub_foo2() addr: %p\n", (void *)&A::pub_foo2);
这段C++语言代码的输出如下:
sizeof A: 8
a addr: 0x7ffe2dbc3120
A::pub_i1 addr: 0x7ffe2dbc3128
A::pub_i2 addr: 0x7ffe2dbc312c
A::pub_foo1() addr: 0x400b28
A::pub_foo2() addr: 0x400bc2
从输出可以看出,成员函数的加入并未增加类 A 的 size,而且两个成员函数 pub_foo1() 和 pub_foo2() 在地址上离类 A 的成员变量非常远,有理由推测此时类 A 在内存中的分布如下,请看:
即,类 A 的(非虚)成员函数其实在内存中是独立分布的,并且彼此之间不毗邻,它们都远离类 A 对象的地址,并不占据类 A 的 size。
类的私有成员
前面讨论的都是类 A 的公有成员,现在为其增加几个私有成员,并通过公有函数 pub_foo1() 将它们的地址打印出来,相关的C++语言代码如下,请看:
class A {
...
private:
int prv_i1;
int prv_i2;
void pub_foo1() {
cout << "A::prv_i1 addr: " << &prv_i1 << endl;
cout << "A::prv_i2 addr: " << &prv_i2 << endl;
printf("A::prv_foo1() addr: %p\n", (void *)&A::prv_foo1);
printf("A::prv_foo2() addr: %p\n", (void *)&A::prv_foo2);
}
void prv_foo2() {}
};
...
a.pub_foo1();
修改后的C++语言编译执行后输出如下,请看:
sizeof A: 16
a addr: 0x7ffdbbfe6980
A::pub_i1 addr: 0x7ffdbbfe6980
A::pub_i2 addr: 0x7ffdbbfe6984
A::pub_foo1() addr: 0x400ace
A::pub_foo2() addr: 0x400bb0
A::prv_i1 addr: 0x7ffdbbfe6988
A::prv_i2 addr: 0x7ffdbbfe698c
A::prv_foo1() addr: 0x400bba
A::prv_foo2() addr: 0x400bc4
此时 sizeof(A) 变为 16 了,这恰好等于 4 个 int 型成员变量占用的内存之和,并且仔细观察还能发现这 4 个 int 型成员在内存中是连续分布的,并且顺序是它们在类中被定义的顺序(这一点读者可自行验证)。private 成员并无特别之处,私有函数也是独立于类 A 对象 a 独立存储的,因此推测此时类 A 的内存模型如下,请看:
虚函数
我们已经知道C++语言中类的常规非虚成员函数并不占用类的 size 了,那么作为目前唯一已知动态绑定的虚函数是否也如此呢?我们在类 A 中添加两个虚函数:
class A {
public:
...
void pub_foo2() {}
virtual void pub_vfoo1() {}
virtual void pub_vfoo2() {}
private:
...
};
...
printf("A::pub_vfoo1() addr: %p\n", (void *)&A::pub_vfoo1);
printf("A::pub_vfoo2() addr: %p\n", (void *)&A::pub_vfoo2);
a.pub_foo1();
编译并执行这段C++语言代码,可以得到如下输出,请看:
sizeof A: 24
a addr: 0x7fffb26a22a0
A::pub_i1 addr: 0x7fffb26a22a8
A::pub_i2 addr: 0x7fffb26a22ac
A::pub_foo1() addr: 0x400b28
A::pub_foo2() addr: 0x400bc2
A::pub_vfoo1() addr: 0x400bcc
A::pub_vfoo2() addr: 0x400bd6
A::prv_i1 addr: 0x7fffb26a22b0
A::prv_i2 addr: 0x7fffb26a22b4
A::prv_foo1() addr: 0x400be0
A::prv_foo2() addr: 0x400bea
此时类 A 的 size 为 24,增加了 8 个字节,看来虚函数的确很特别(常规函数并不增加类的 size)。还不止于此,在我的机器上,一个函数指针占用的内存空间为 8 字节,这里我们添加了 2 个虚函数,却只增加 1 个函数指针的大小,为什么呢?
还记再前面一节中我们曾提到C++语言编译器会为含有虚函数的类添加虚表存放虚函数指针吗?这里多出一个指针正是虚表指针__vptr
:只需要一个指针就能够找到虚表,进而在虚表中找到所有的虚函数。请注意,含有虚函数的类 A 的对象 a 的地址已经不等于第一个成员变量的地址,而是多出了 8 个字节的内存偏移,这个偏移量恰好能够存放一个指针,因此我们推测含有虚函数的类 A 在内存中的分布如下图,请看:
这种推测对不对呢?我们可以再做实验确认下,编写下面的C++语言代码:
...
a.pub_foo1();
void **__vptr = (void **)&a;
void **virtual_table = (void **)(*__vptr);
printf("virtual table[0]: %p\n", virtual_table[0]);
printf("virtual table[1]: %p\n", virtual_table[1]);
对上述C++语言代码稍作解释:我们已经分析虚表指针__vptr
恰好等于类 A 对象 a 的地址,因此可以根据&a
获得实际的虚表指针地址。因为__vptr
指向的正是虚表(virtual_table),所以将得到的地址中的值取出就得到了 virtual_table 的地址,依次将 virtual_table 中的前 2 个(类A只有 2 个虚函数)元素的值打印出来,应该分别等于类 A 的两个虚函数地址,对不对呢?实际编译并执行这段C++语言代码,得到的输出如下,请看:
sizeof A: 24
...
A::pub_vfoo1() addr: 0x400c16
A::pub_vfoo2() addr: 0x400c20
...
virtual table[0]: 0x400c16
virtual table[1]: 0x400c20
我省去了一些信息,这样便可以清晰的看出输出其实与我们的推测一致。
小结
本文主要通过简单的C++语言代码示例分析和讨论了类在内存中的布局,最后我们还分析了所谓的“虚表”以及“虚表指针”在内存中的分布,当然了,本文并无实际的理论支撑,一切都是从实践推导出来的,但是这样多多少少可以提供一些参考,事实上,本文更多的是介绍一种分析方法,下一节将使用同样的方法分析类在继承的过程中的内存变化,敬请关注。
全部代码请点这里。