0%

C++ 对象模型实战(二):栈对象、堆对象与 this 指针的本质

这一篇解决什么问题

上一篇用几个结构体搞清楚了对象内部的布局。接下来我想弄清楚两件事:

  1. Point a(1); 和 Point* b = new Point(2); 这两种写法,对象的内存到底在哪,生命周期差在哪。
  2. 成员函数里随手就用的 this,它到底是个什么东西,从哪来的。

还是一个最小工程,打印地址看现象。

栈与堆:两种对象,两套生命周期

规则很简单:

  • 栈对象(局部变量):内存在栈上,构造发生在声明处,离开作用域自动析构。不用管,也轮不到你管。
  • 堆对象(new 出来的):内存在堆上,构造发生在 new 这一刻,只有 delete 才析构。忘了 delete,对象就一直在那,这就是内存泄漏。

一个容易混淆的点:Point* b 这个指针变量本身是栈对象,它指向的 Point 才是堆对象。&b 和 b 是两个地址,分属两个世界。

this 指针:一个隐藏的函数参数

非静态成员函数在编译时会被改写:show() 实际变成类似 show(const Point* this) 的普通函数,调用处 a.show() 实际传入 &a。

在 MSVC x64 下,这个隐藏参数通过 rcx 寄存器传递(x64 调用约定的第一个参数)。所以 this 不是对象的组成部分,不占对象任何字节——它是每次调用成员函数时,由调用方临时递进来的一个地址。上一篇的布局实验也印证了这点:sizeof 里从来没有 this 的位置。

验证工程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 栈对象 vs 堆对象, this 指针本质.
#include <cstdio>

class Point {
public:
Point(int x) : x_(x) { printf(" ctor: this=%p\n", (void*)this); }
~Point() { printf(" dtor: this=%p\n", (void*)this); }

void show() const {
printf(" show: this=%p, x=%d, &x_=%p\n", (void*)this, x_, (void*)&x_);
}

private:
int x_;
};

int main() {
printf("--- stack object ---\n");
{
Point a(1); // 构造发生在声明处.
a.show();
printf(" &a =%p\n", (void*)&a);
} // 离开作用域, 析构自动发生.

printf("--- heap object ---\n");
Point* b = new Point(2);// 构造发生在 new 这一刻.
b->show();
printf(" &b =%p (pointer b itself, on stack)\n", (void*)&b);
printf(" b =%p (Point object, on heap)\n", (void*)b);
delete b; // 不 delete 析构永远不会发生, 内存泄漏.
}

运行结果与解读

先给出我的预测(VS2022 x64 Debug,地址为预测的形态示意):

  • 栈对象的 this、&a、&x 三者完全相同(x 偏移为 0),且是 000000xx....FF.... 形态的栈地址。
  • 堆对象的 this、b、&x_ 三者相同,是另一种前缀的堆地址;而 &b(指针本身)仍是栈地址。
  • 栈对象的 dtor 在作用域结束处自动出现;堆对象的 dtor 只在 delete 之后出现。

实际运行输出:

1
2
3
4
5
6
7
8
9
10
11
--- stack object ---
ctor: this=00000037746FF8E4
show: this=00000037746FF8E4, x=1, &x_=00000037746FF8E4
&a =00000037746FF8E4
dtor: this=00000037746FF8E4
--- heap object ---
ctor: this=0000017FB2BD8DB0
show: this=0000017FB2BD8DB0, x=2, &x_=0000017FB2BD8DB0
&b =00000037746FF908 (pointer b itself, on stack)
b =0000017FB2BD8DB0 (Point object, on heap)
dtor: this=0000017FB2BD8DB0

逐行比对,三条预测全部命中:

第一,this 和 &a 是同一个地址。 show() 里打印的 this 和外部打印的 &a 完全相同(都是 ...F8E4),堆对象那边 this 也和 b 的值相同。这直接证实了”this 就是调用时传进来的对象地址”。

第二,栈和堆的地址形态肉眼可分。 栈地址是 00000037... 开头,堆地址是 0000017F... 开头,两个区域离得很远。同时注意 &b(指针本身)是 00000037...——指针变量确实住在栈上,和它指向的堆对象天各一方。顺带一提,&b - &a = F908 - F8E4 = 0x24,两者都在 main 的同一个栈帧里,具体距离是编译器的帧布局决定的,不用深究。

第三,析构时机完全不同。 栈对象 a 的 dtor 紧跟在作用域结束的 } 之后自动打印;堆对象的 dtor 只在 delete b 之后出现。把 delete 注释掉再跑,第二个 dtor 就永远消失了。

另外 &x_ 和 this 相同,因为 x_ 是 Point 的第一个(也是唯一一个)成员,偏移为 0——这正是上一篇布局规则的延续:成员地址 = this + 偏移。

在反汇编里看 this 的传递

想看隐藏的参数传递,最直接的办法是看汇编。在 VS 里 F5 调试,在 a.show() 这一行打断点,命中后右键选择「转到反汇编」(快捷键 Ctrl+K,G)。这是本次运行的实际反汇编:

1
2
3
4
5
6
7
8
9
10
11
    Point a(1);
00007FF7AE7B1BCA mov edx,1
00007FF7AE7B1BCF lea rcx,[rbp+4]
00007FF7AE7B1BD3 call Point::Point
a.show();
00007FF7AE7B1BD9 lea rcx,[rbp+4]
00007FF7AE7B1BDD call Point::show
printf(" &a =%p\n", (void*)&a);
00007FF7AE7B1BE3 lea rdx,[rbp+4]
00007FF7AE7B1BE7 lea rcx,[string " &a =%p\n"]
00007FF7AE7B1BEE call printf

这段汇编证实了三件事。

1. lea rcx, [rbp+4] 出现了两次。 构造和 show() 调用前,都是先把 a 的地址(rbp+4,a 在栈帧里的位置)装进 rcx,再 call。this 作为隐藏参数经 rcx 传递,和预测一致。

2. 意外收获:mov edx,1。 构造调用的完整序列是 mov edx,1 + lea rcx,[rbp+4] + call Point::Point——构造函数也是普通成员函数,同样收 this(rcx),显式参数 1 被挤到了第二个寄存器 rdx。这就是 x64 调用约定的参数顺序:rcx、rdx、r8、r9,this 占了第一个位置,其余参数顺次后移。这个细节是预测时没想到的,是看汇编带来的新发现。

3. printf 那三行是同一个约定的对照组。 lea rcx,[格式串] + lea rdx,[rbp+4]——rcx 放第一个参数(格式串),rdx 放第二个(&a)。成员函数调用和普通函数调用没有本质区别,只是编译器替成员函数多塞了一个 this 参数。

小结

1
2
3
4
栈对象: 声明即构造, 离开作用域自动析构, 地址在栈区
堆对象: new 才构造, delete 才析构, 地址在堆区; 指针本身仍是栈变量
this: 不占对象空间, 是调用成员函数时通过 rcx 传入的隐藏参数
成员访问 = this + 成员偏移