一行 Foo f; 与一行 Foo* p = new Foo; 之间,隔着的不是语法差异,而是两种完全不同的生命周期契约——本章把分配、析构、性能与事故逐一实测给你看
核心方法论:第一性原理
「先别背'栈上快、堆上慢'这种结论。我们要问的是最底层的问题:对象到底住在一台计算机的哪里?谁负责把它造出来、谁负责把它拆掉?把这两件事亲手用日志打出来、用计时器量出来、用汇编看出来,'该用栈还是该用堆'就不再是需要查资料的选项,而是一种本能。」
用第一性原理问一个问题:一个 C++ 对象,物理上住在哪里?答案在第 6 章的虚拟地址空间里:每个进程的地址空间自下而上分为代码区、数据区、堆(heap,向上生长)和栈(stack,向下生长)。说人话:栈是"编译器替你管"的内存,堆是"你自己管"的内存。对象只有两种出身——要么诞生在栈上,由编译器安排生死;要么诞生在堆上,由你亲手安排生死。语法上它们长得极像,本质却天差地别:
// 方式一:栈上直接创建 —— 不需要指针,直接用对象本身
Foo stackFoo; // 声明即构造,离开作用域自动析构
// 方式二:堆上 new —— 拿到的是指针,指向堆里的对象
Foo* heapFoo = new Foo; // 必须手动 delete,否则泄漏
这里藏着一个底层真相:new 是两步走——先调 operator new 分配原始内存(内部就是 malloc),再在这块内存上调用构造函数;delete 同样两步:先析构,再调 operator delete 归还内存(内部就是 free)。而栈上创建没有这两步——编译器在函数开头移动一下栈指针,内存就"分配"好了。眼见为实,让对象把自己的地址打出来(本机 arm64 macOS 实测):
// ex1_where.cpp —— 把两种对象的地址打出来
#include <cstdio>
struct Foo {
int data;
Foo() { std::printf("Foo 构造 @%p\n", (void*)this); }
~Foo() { std::printf("Foo 析构 @%p\n", (void*)this); }
};
int main() {
int stackVar = 0; // 一个普通的栈变量
Foo stackFoo; // 栈上直接创建
Foo* heapFoo = new Foo; // 堆上 new
Foo* heapFoo2 = new Foo;
std::printf("栈变量 = %p\n", (void*)&stackVar);
std::printf("栈对象 = %p\n", (void*)&stackFoo);
std::printf("堆对象 = %p\n", (void*)heapFoo);
std::printf("堆对象2 = %p\n", (void*)heapFoo2);
std::printf("main = %p\n", (void*)&main);
delete heapFoo; // 堆对象:析构 + 释放,两步走
delete heapFoo2;
return 0;
}
# 运行输出(真实):
Foo 构造 @0x16fa2e0b8 ← 0x16f...:栈区(高位地址)
Foo 构造 @0x1008a59a0 ← 0x1008...:堆区(低位地址)
Foo 构造 @0x1008a59b0
栈变量 = 0x16fa2e0bc
栈对象 = 0x16fa2e0b8
堆对象 = 0x1008a59a0
堆对象2 = 0x1008a59b0
main = 0x1003d04e8 ← 代码区(比堆还低)
Foo 析构 @0x1008a59a0 ← delete 触发的析构
Foo 析构 @0x1008a59b0
Foo 析构 @0x16fa2e0b8 ← 离开 main 自动析构
| 对比项 | 栈上直接创建 | 堆上 new |
|---|---|---|
| 语法 | Foo f; | Foo* p = new Foo; |
| 内存区域 | 栈(高位,向下生长) | 堆(低位,向上生长) |
| 生命周期 | 随作用域,编译器管理 | 从 new 到 delete,程序员管理 |
| 释放方式 | 离开作用域自动析构 | 必须手动 delete |
| 分配开销 | 一条指令(移动栈指针) | 分配器查找空闲块(见 14.4) |
| 失败表现 | 栈溢出(罕见) | 内存耗尽:new 抛 std::bad_alloc |
看地址数字就懂布局:0x16f... 是栈、0x1008... 是堆、0x1003... 是代码——栈在最上面(高位)、堆在中间、代码在最低处。栈向下生长、堆向上生长,两片区域相向而行。本章只关心它们"管法不同":栈是编译器在管,堆是你在管。
注意:new 是"分配 + 构造"两步走,delete 是"析构 + 释放"两步走;栈对象没有这两步,构造就是分配、析构就是释放。这个区别会在 14.3 的 RAII 和 14.5 的泄漏事故里反复出现——先把它钉死在脑子里。
对象的生命周期(lifetime)指从构造完成到析构开始的这段时间。两种创建方式给出两种完全不同的契约。栈对象:生命周期 = 作用域。执行到声明处构造;一旦执行流离开作用域(遇到 }、return、甚至抛出异常),编译器立即自动调用析构函数——想赖着不走都不行。堆对象:生命周期 = 从 new 到 delete。new 之后它就"独立成户",编译器不再过问,只有你亲手 delete,析构才会执行;你不 delete,它就永远活着。
用构造/析构日志把这条契约实测出来。spawn() 的局部对象 local 在函数返回那一刻自动析构;而 main 里 new 出来的对象直到进程结束都没有任何析构输出——它不是"还没轮到",而是永远不会轮到:
// ex2_lifecycle.cpp —— 谁在自动析构,谁在等 delete?
#include <cstdio>
struct Player {
int hp;
explicit Player(int h) : hp(h) {
std::printf("Player(%d) 构造 @%p\n", hp, (void*)this);
}
~Player() { std::printf("Player(%d) 析构 @%p\n", hp, (void*)this); }
};
void spawn() {
Player local(100); // 栈对象:进入函数就构造
std::printf("spawn 函数体内,local 还活着\n");
} // 离开作用域:自动析构
int main() {
spawn();
std::printf("--- spawn 返回后 ---\n");
Player* p = new Player(999); // 堆对象:new 分配并构造
std::printf("main 快结束了,堆对象 %p 仍然活着(没人 delete 它)\n", (void*)p);
return 0; // 忘记 delete —— 析构永远不会执行
}
# 运行输出(真实):
Player(100) 构造 @0x16ef5a0bc
spawn 函数体内,local 还活着
Player(100) 析构 @0x16ef5a0bc ← 函数一返回,自动析构,一刻不差
--- spawn 返回后 ---
Player(999) 构造 @0x1015adb10
main 快结束了,堆对象 0x1015adb10 仍然活着(没人 delete 它)
← 直到进程退出都没有"Player(999) 析构"——泄漏
| 对比项 | 栈对象 | 堆对象 |
|---|---|---|
| 构造时机 | 执行到声明处 | new 执行时 |
| 析构时机 | 离开作用域(自动,不可抗拒) | delete 执行时(手动,可拖延) |
| 能提前结束吗 | 不能,只能等作用域结束 | 能,随时 delete |
| 能延长吗 | 不能 | 能,一直不 delete 就一直活着 |
| 忘记收尾的后果 | 无(编译器兜底) | 内存泄漏 + 析构里的资源没人释放 |
| 典型用途 | 局部变量、临时对象 | 动态数组、长生命周期、运行时才知道大小的对象 |
把两种契约想成两种雇佣关系:栈对象是"临时工",干完活自动走人;堆对象是"独立承包人",你不去结账(delete)他就一直赖着。写代码时问自己一句:"这个对象活多久合适?"——活不过当前作用域,用栈;必须活得比作用域久,才考虑堆。
注意:别用"反正进程退出时操作系统会回收全部内存"安慰自己。进程退出确实会回收内存,但不会执行你的析构函数——析构里要关的文件、释放的锁、断开的连接,统统没机会执行。更致命的是服务器、游戏这类常年不退出的进程,泄漏会一天天累积,直到某天 new 抛出 std::bad_alloc,进程当场崩溃。14.5 我们会亲眼看到泄漏长什么样。
栈对象自动析构,但多个栈对象之间是什么顺序?答案是严格的后进先出(LIFO,last in first out):后构造的先析构,和栈这种数据结构的行为完全一致——毕竟它们本来就住在栈上。嵌套作用域实测(ex3_nested.cpp,输出顺序:最内层对象最先析构,像收叠起来的餐盘一样一层层往外收):
// ex3_nested.cpp —— 嵌套作用域:构造与析构的顺序
#include <cstdio>
struct ScopeLog {
const char* name;
explicit ScopeLog(const char* n) : name(n) {
std::printf("[%s] 构造\n", name);
}
~ScopeLog() { std::printf("[%s] 析构\n", name); }
};
int main() {
ScopeLog outer("外层对象");
{
ScopeLog inner("内层对象");
{ ScopeLog deepest("最内层对象"); } // 先析构
} // 然后内层
return 0; // 最后外层
}
# 运行输出(真实):
[外层对象] 构造
[内层对象] 构造
[最内层对象] 构造
[最内层对象] 析构 ← 后进先出:最晚构造的,最早析构
[内层对象] 析构
[外层对象] 析构
这个"离开作用域必析构"的承诺,正是 C++ 最强大思想的基石——RAII(Resource Acquisition Is Initialization,资源获取即初始化)。说人话:把"借资源"写进构造函数,把"还资源"写进析构函数,让资源的生死跟对象的生死绑定。对象活着,资源就在;对象一析构,资源自动归还——即使中途 return 甚至抛异常,析构也一定会执行。用文件句柄实测:写文件写到一半"抛异常",文件照样被关掉:
// ex3_raii.cpp —— RAII:文件句柄随对象生命周期自动关闭
#include <cstdio>
class FileGuard {
public:
explicit FileGuard(const char* path) {
fp_ = std::fopen(path, "w");
if (fp_) std::printf("FileGuard:文件已打开\n");
}
~FileGuard() { // 析构 = 自动"还资源"
if (fp_) { std::fclose(fp_); std::printf("FileGuard:文件已关闭(析构自动释放资源)\n"); }
}
void write(const char* text) { if (fp_) std::fputs(text, fp_); }
private:
FILE* fp_;
};
void riskyWork() {
FileGuard guard("out.txt");
guard.write("hello\n");
std::printf("正在写文件……突然抛出异常!\n");
// 即使这里 return / 抛异常,guard 的析构也一定会执行
}
int main() {
riskyWork();
std::printf("main 正常结束,文件资源没有泄漏\n");
return 0;
}
# 运行输出(真实):
FileGuard:文件已打开
正在写文件……突然抛出异常!
FileGuard:文件已关闭(析构自动释放资源) ← 异常路上也把文件关了
main 正常结束,文件资源没有泄漏
| 事件 | 栈对象 | 堆对象 |
|---|---|---|
| 执行到声明处 | 构造 | — |
| new 表达式 | — | 分配内存 + 构造 |
离开作用域(} / return / 异常) | 自动析构(后进先出) | 纹丝不动 |
| delete 表达式 | — | 析构 + 释放内存 |
| 程序退出 | (已随作用域析构) | 可能仍是泄漏 |
RAII 是 C++ 送给程序员的"自动记账本":构造时借、析构时还,编译器保证每一笔记账都平。标准库里的 std::unique_ptr(独占式智能指针)就是 RAII 的官方代表——它把"堆对象的 delete"也写进了自己的析构。以后看到"智能指针",请想起这一节:它只是把 14.2 里"你欠堆对象的那次 delete"自动代劳了。
注意:RAII 只管"对象"本身,管不了"对象指向的东西"。栈上放一个 Player* p = new Player;,p 会随作用域自动"消失",但它指向的堆对象不会跟着消失——指针析构 ≠ 指向的对象析构。这几乎是每个 C++ 新手的第一场内存事故,14.5 的主角之一。
从第一性原理看,两种"创建"的分配成本根本不在一个量级。栈分配:一条指令。函数开头把栈指针往下挪 N 字节(x86-64 上就是一条 subq $N, %rsp),内存就"分配"好了;离开作用域再挪回来。没有搜索、没有锁、没有系统调用,每个线程有自己的栈,天然无竞争。堆分配:要过分配器。new 内部先调 operator new(即 malloc)——它要在自己的"账本"(空闲块链表、大小类桶)里找一块合适的空闲内存,可能要拆分、合并、加锁应对多线程并发,偶尔还要调 brk / mmap 系统调用向内核申请新内存。说人话:栈分配是"翻一页纸",堆分配是"去仓库翻箱子"。
空口无凭,上计时器。各来 100 万次"创建 + 销毁"一个 32 字节小对象:堆路线是 new + delete,栈路线是循环里反复构造销毁一个栈上数组。为了不让编译器把 new/delete 整体优化掉(它有权这么做),给对象加上构造/析构计数,并让指针"逃逸"出去——这样测到的是真实的 malloc/free:
// ex4_perf.cpp —— 栈分配 vs 堆分配,100 万次实测
#include <chrono>
#include <cstdio>
struct Item {
long a, b, c, d; // 32 字节的"小对象"
static long alive;
Item() : a(0), b(0), c(0), d(0) { ++alive; } // 非平凡构造/析构:禁止编译器省略分配
~Item() { --alive; }
};
long Item::alive = 0;
static Item* volatile lastPtr; // 让指针"逃逸",逼 new 真的去堆上分配
int main() {
const int N = 1000000;
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < N; ++i) {
Item* p = new Item; // 堆路线:真实 malloc/free
lastPtr = p; // 指针逃逸:分配不许被省略
delete p;
}
auto t1 = std::chrono::steady_clock::now();
auto t2 = std::chrono::steady_clock::now();
for (int i = 0; i < N; ++i) {
Item arr[4]; // 栈路线:只挪栈指针
(void)arr[0].a;
}
auto t3 = std::chrono::steady_clock::now();
auto ms = [](auto x, auto y) {
return std::chrono::duration_cast<std::chrono::milliseconds>(y - x).count();
};
std::printf("堆路线:100 万次 new/delete 耗时 %lld ms\n", ms(t0, t1));
std::printf("栈路线:100 万次栈上创建耗时 %lld ms\n", ms(t2, t3));
std::printf("存活的 Item 数:%ld\n", Item::alive);
return 0;
}
# 运行输出(真实,本机多轮实测堆路线 19~52 ms,栈路线始终 0 ms):
堆路线:100 万次 new/delete 耗时 26 ms
栈路线:100 万次栈上创建耗时 0 ms
存活的 Item 数:0(两边都构造+析构,应回到 0)
再往下一层,把这两个函数编译成 x86-64 汇编看指令级真相(真实反汇编,节选)。useStack 全程没有调用任何分配函数——subq $16, %rsp 一条指令完成"分配",addq $16, %rsp 一条指令完成"释放";而 useHeap 先调 operator new(内部是 malloc),再调构造函数,末尾再调析构和 operator delete(内部是 free)——每一步都是真实的函数调用:
# x86-64 汇编(真实,-O0,函数体节选)—— useStack:栈上创建
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp # 一条指令:"分配"完成
leaq -4(%rbp), %rdi # 算出对象地址
callq __ZN3FooC1Ev # 调用构造函数
addq $16, %rsp # 一条指令:"释放"完成
popq %rbp
retq
# 同一个文件里的 useHeap:堆上创建
movl $4, %edi # 参数:需要 4 字节
callq __Znwm # operator new —— 内部就是 malloc
movq %rax, %rdi
callq __ZN3FooC1Ev # 第二步:构造(new 的两步走)
callq __ZN3FooD1Ev # 析构
callq __ZdlPv # operator delete —— 内部就是 free
retq
| 对比项 | 栈分配 | 堆分配 |
|---|---|---|
| 机制 | 移动栈指针(一条指令) | 分配器搜索空闲块 / 大小类桶 |
| 成本 | O(1),无簿记 | O(1) 起,含簿记、加锁,甚至系统调用 |
| 系统调用 | 从不 | 可能(brk / mmap 向内核要内存) |
| 多线程 | 天然线程私有,无竞争 | 需要锁或原子操作保护分配器 |
| 局部性 | 极好(地址连续,Cache 友好) | 一般(地址分散,碎片化) |
| 耗尽时 | 栈溢出(进程崩溃) | new 抛 std::bad_alloc |
把性能直觉压缩成一句:"栈几乎免费,堆按次收费"。写代码时的默认选择永远是栈;只有当对象需要活得比作用域久、大小运行时才知道、或者体型巨大时,才买堆这张"贵一点的票"。热路径(每秒执行几百万次的循环)里出现 new,几乎一定是个性能事故——第 15 章讲性能优化时会回来算这笔账。
注意:栈虽快,但不是无底洞。每个线程的栈默认只有几 MB(Linux 上通常 8 MB),放得下小对象,放不下大数组。递归太深、栈上声明超大的数组,都会撞上栈底——栈溢出(stack overflow)通常直接段错误崩溃,连 bad_alloc 都不给你。大块数据(几 MB 的缓冲区)请走堆,或者用 std::vector 这类自带堆内存的容器。
堆对象把生命周期交到你手里,手一抖就是两类经典事故。第一类:悬空指针(dangling pointer)——delete 之后,指针变量还在,但它指向的内存已经归还给分配器。说人话:门牌号还贴在墙上,房子已经拆了。此时访问 p->value 属于未定义行为(undefined behavior,UB)——编译器对它不做任何承诺:可能打印旧值、可能打印垃圾、可能崩溃,甚至"看起来正常",然后在一个月后的深夜爆炸。实测一次(本机真实输出):
// ex5_dangling.cpp —— delete 之后还去访问(未定义行为!)
#include <cstdio>
struct Data {
int value;
explicit Data(int v) : value(v) {
std::printf("Data(%d) 构造 @%p\n", value, (void*)this);
}
~Data() { std::printf("Data 析构 @%p\n", (void*)this); }
};
int main() {
Data* p = new Data(42);
delete p; // 对象已销毁,内存已归还
std::printf("p->value = %d\n", p->value); // 危险!未定义行为
std::printf("程序居然走完了……但这只是运气好\n");
return 0;
}
# 运行输出(真实):
Data(42) 构造 @0x102cbda90
Data 析构 @0x102cbda90
p->value = 2 ← 42 变成了 2!内存早被 printf 内部借走改写了
程序居然走完了……但这只是运气好
注意看:我们存进去的 42 打印出来变成了 2——printf 自己内部分配的内存恰好复用了这块地址,把 42 覆盖了。这次没崩溃纯属运气,换个时机、换个编译器,结果完全不同。怎么把这种"隐藏炸弹"变成当场抓获的现行犯?用 ASan(AddressSanitizer,内存错误检测器)重新编译,它给每块堆内存布置"哨兵"并记账,访问已释放内存的瞬间就拦截并报告——一次抓全"访问行 / delete 行 / new 行"三处现场(真实报告节选):
# clang++ -O1 -g -fsanitize=address ex5_dangling.cpp -o ex5_dangling_asan
==69792==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d0
READ of size 4 at 0x6020000000d0 thread T0
#0 ... in main ex5_dangling.cpp:21 ← 访问的那一行,当场抓获
freed by thread T0 here:
#1 ... in main ex5_dangling.cpp:16 ← delete p 的那一行
previously allocated by thread T0 here:
#1 ... in main ex5_dangling.cpp:15 ← new Data 的那一行
SUMMARY: AddressSanitizer: heap-use-after-free ex5_dangling.cpp:21 in main
第二类:内存泄漏(memory leak)——new 了却永远不 delete,堆上这块内存从此"失联"。单个小对象无所谓,可怕的是循环里泄漏、服务器上日积月累。检测手段随平台而异:Linux 上首选 valgrind(内存调试器,valgrind --leak-check=full ./程序,退出时逐块盘点,打印类似 definitely lost: N bytes in M blocks 的总结);macOS 上没有 valgrind,官方替代是 Xcode 自带的 leaks 命令。实测一次:
// ex5_leak.cpp —— 泄漏演示:new 了 4 次,一次都不 delete
#include <cstdio>
struct Chunk {
char payload[1024]; // 每块 1KB
Chunk() { std::printf("分配了一块 1KB 内存 @%p\n", (void*)this); }
};
int main() {
for (int i = 0; i < 4; ++i) new Chunk; // 只有 new,没有 delete
std::printf("程序结束:4 块内存谁都没 delete,全部泄漏\n");
return 0;
}
# macOS:leaks --atExit -- ./ex5_leak(真实输出节选):
Process 70194: 3 leaks for 3840 total leaked bytes.
STACK OF 3 INSTANCES OF 'ROOT LEAK: <malloc in main>':
3 ex5_leak ... main + 48 ex5_leak.cpp:12 ← 直接定位到 new Chunk 那一行
3 (3.75K) << TOTAL >>
注意一个细节:代码明明 new 了 4 次,leaks 只报了 3 处——工具也有盲区。macOS 的 leaks 在进程退出瞬间枚举内存节点,最新分配的那块经常漏网。这恰恰是第一性原理的活教材:工具的计数只能当参考,真正靠得住的,是你自己理解"new 必须配 delete、谁的资源谁负责"。Linux 的 valgrind 相对更严谨,但同样不是万能——别把任何工具的结论当圣经。
注意:悬空指针还有两个近亲:双重 delete(同一指针删两次,第二次是未定义行为,堆可能被写坏)和 delete 栈对象(delete 一个从来不是 new 出来的栈地址,几乎必然崩溃)。加上 new[] 配 delete 而不是 delete[],一共四件套,全是"生命周期管错"的变体。第 16 章综合实战会把这四件套打包收拾。
| 错误 | 一句话症状 | 根因 | 后果 | 检测手段 |
|---|---|---|---|---|
| 悬空指针 | delete 后还访问 | 忘了指针已失效 | UB:垃圾值 / 崩溃 | ASan:heap-use-after-free |
| 内存泄漏 | 内存只增不减 | new 了不 delete | 长期运行内存耗尽 | valgrind / macOS leaks |
| 双重 delete | 同一指针删两次 | 释放权不清 | 堆损坏 / 崩溃 | ASan:double-free |
| 越界访问 | 写入超出对象 | 边界计算错误 | 堆损坏 / 安全漏洞 | ASan:heap-buffer-overflow |
把本章压缩成一张图:栈对象 = 编译器管生管死,快、自动、默认选择;堆对象 = 程序员管生管死,慢、灵活、责任自负。栈用作用域换安全,堆用手动管理换自由。所谓"内存管理",说到底就是回答三个问题:对象在哪创建?谁负责析构?析构什么时候发生?——本章各小节各答一个。
现在把问题反过来,第一性原理的最后一步:能不能强制一个类,只能诞生在其中一个地方?比如某个类要求"绝不泄漏",能不能让编译器在有人试图 new 它时直接报错?或者某个类要求"必须自己管理内存"(比如支持 delete this),能不能禁止它在栈上创建?答案是:能。机关就藏在"编译器不敢碰的访问权限"里——把析构函数设为私有,编译器没法在栈上销毁它,于是拒绝栈上创建;把 operator new 设为私有,谁都没法 new 它。试试编译器会说什么(真实编译错误):
// ex6_heaponly.cpp —— 只在堆上创建:析构函数设为私有
#include <cstdio>
class HeapOnly {
public:
HeapOnly() { std::printf("HeapOnly 构造 @%p\n", (void*)this); }
void destroy() const { delete this; } // 通过成员函数"自杀"
private:
~HeapOnly() {} // 析构私有:外部无法调用
};
int main() {
HeapOnly h; // 想栈上创建?编译器:不行!
return 0;
}
# 编译(真实报错):
ex6_heaponly.cpp:13:14: error: variable of type 'HeapOnly' has private destructor
13 | HeapOnly h; // 想栈上创建?编译器:不行!
| ^
1 error generated.
// ex6_stackonly.cpp —— 只在栈上创建:operator new 设为私有
#include <cstdio>
#include <cstddef>
class StackOnly {
public:
StackOnly() { std::printf("StackOnly 构造 @%p\n", (void*)this); }
private:
static void* operator new(std::size_t); // 私有:外部无法 new
static void operator delete(void*);
};
int main() {
StackOnly s; // 栈上创建:合法
StackOnly* p = new StackOnly; // 想 new?编译器:不行!
return 0;
}
# 编译(真实报错):
ex6_stackonly.cpp:15:20: error: 'operator new' is a private member of 'StackOnly'
15 | StackOnly* p = new StackOnly; // 想 new?编译器:不行!
| ^~~~~~~~~~~~~
2 errors generated.
| 关键词 | 一句话结论 |
|---|---|
| 栈对象 | 编译器管生管死,快、自动,默认选择 |
| 堆对象 | 程序员管生管死,慢、灵活,责任自负 |
| new / delete | 各自都是两步走:分配+构造 / 析构+释放 |
| RAII | 资源随对象生命周期走,异常安全 |
| 悬空指针 | delete 后还访问 = 未定义行为 |
| 内存泄漏 | new 了不 delete,长期运行会耗尽内存 |
这一章的"费曼一句话":栈是编译器的承诺,堆是你自己的承诺。承诺有自动兑现(RAII)和手动兑现(delete)两种方式;而"只在栈上 / 只在堆上"这类限制,本质是把承诺写进类的访问权限里,让编译器替你执法。下一个问题顺理成章:这两种"执法"各有什么坑(继承、多态怎么办)?——那是第 15 章的战场。
注意:别急着在今天的生产代码里用"私有析构 / 私有 operator new"这两招——它们各有代价(比如带继承、虚析构的场景需要保护级而非私有级),用错反而制造新坑。先把本章的"为什么"想透,第 15 章《限制创建位置:只在堆上 / 只在栈上》会把这些机关的原理、边界和实战用法一次讲完。
下面的对象分别创建在哪个区域(栈 / 堆 / 其他)?① 函数内的 Foo f;;② Foo* p = new Foo;;③ 文件作用域的全局对象 Foo g;;④ std::vector<int> v(1000); 中那 1000 个 int 所在的内存。
回顾 14.1 的地址实测:栈在高位、堆在低位;new 出来的东西在堆上。注意区分"对象本体"和"对象管理的资源"。
① 栈(局部对象,随作用域析构);② 堆(new 分配,需手动 delete);③ 都不是——全局对象在静态存储区(程序启动时构造、退出时析构,即第 6 章布局图里的"程序代码和数据"区);④ 堆——vector 对象本身在栈上(一个小壳子),但它内部用 new 在堆上分配了存那 1000 个 int 的缓冲区。这就是"对象本体"与"对象管理的资源"的区别,也是 RAII 发挥作用的地方。
不运行代码,写出下面程序的输出顺序:
struct T {
const char* name;
explicit T(const char* n) : name(n) {}
~T() { std::printf("~%s\n", name); }
};
int main() {
T a("a");
{ T b("b"); T c("c"); }
T d("d");
}
同一作用域内多个对象按声明顺序构造;离开作用域时按后进先出析构。嵌套作用域先清内层。
输出顺序:~c、~b(内层作用域先结束,c 比 b 后构造所以先析构)、~d、~a(离开 main 时 d 后构造先析构,a 最后)。一句话:构造顺序 = 声明顺序,析构顺序 = 声明顺序的逆序。
下面的代码有一个典型问题——process() 里 new 出来的 Player 在 if 提前返回时会泄漏。请用 RAII 思想修复(提示:可以直接用 std::unique_ptr)。
void process(bool ok) {
Player* p = new Player(100);
if (!ok) { return; } // 泄漏!
delete p;
}
让"释放"发生在析构函数里,让对象本体是栈对象——编译器保证任何路径都会析构它。回想 14.3 的 FileGuard。
std::unique_ptr<Player> p(new Player(100)); 一行搞定——它的析构函数负责 delete,任何路径离开作用域都会释放,这正是 RAII 的标准实现。自己写也一样:把 delete p_ 放进包装类的析构函数(如同本章的 FileGuard)。核心思想一句话:用栈对象的生命周期,去兜底堆对象的释放。
写一个程序:new 一个对象、delete 它、然后故意访问它。先普通编译运行观察现象,再用 -fsanitize=address 重新编译运行,对比两次的差别。再写一个循环泄漏 10 块内存的程序,用 macOS 的 leaks --atExit -- ./程序(Linux 用 valgrind --leak-check=full ./程序)验证泄漏。最后用一句话解释:为什么 14.5 的实测里 p->value 打印的不是 42?
普通编译下"看起来正常"恰恰是 UB 最危险的地方;ASan 会给出 heap-use-after-free 和源码行号。macOS 上 leaks 附加进程前可能需要 codesign -s - 程序。
普通编译可能打印垃圾值(实测打印了 2)甚至崩溃——delete 后那块内存已被分配器收回,printf 内部的临时分配恰好复用了它并写入了新数据,把 42 覆盖了。ASan 版本则在访问瞬间终止并报告 heap-use-after-free,附上 new / delete / 访问三处的源码行号。一句话答案:delete 之后对象已不存在,读到的只是"恰好还躺在那里"的旧字节或被别的代码改写过的数据——这就是未定义行为,任何结果都可能发生。