第14章:栈与堆:对象创建的两种方式

一行 Foo f; 与一行 Foo* p = new Foo; 之间,隔着的不是语法差异,而是两种完全不同的生命周期契约——本章把分配、析构、性能与事故逐一实测给你看

🔬

本章导师:费曼

核心方法论:第一性原理

「先别背'栈上快、堆上慢'这种结论。我们要问的是最底层的问题:对象到底住在一台计算机的哪里?谁负责把它造出来、谁负责把它拆掉?把这两件事亲手用日志打出来、用计时器量出来、用汇编看出来,'该用栈还是该用堆'就不再是需要查资料的选项,而是一种本能。」

14.1 两种方式:栈上直接创建 vs 堆上 new

用第一性原理问一个问题:一个 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 的泄漏事故里反复出现——先把它钉死在脑子里。

14.2 生命周期差异:栈自动析构 vs 堆手动 delete

对象的生命周期(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 我们会亲眼看到泄漏长什么样。

14.3 构造与析构的时机:作用域与 RAII 思想

栈对象自动析构,但多个栈对象之间是什么顺序?答案是严格的后进先出(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 的主角之一。

14.4 性能差异:栈分配快、堆分配慢

从第一性原理看,两种"创建"的分配成本根本不在一个量级。栈分配:一条指令。函数开头把栈指针往下挪 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 友好)一般(地址分散,碎片化)
耗尽时栈溢出(进程崩溃)newstd::bad_alloc
费曼提示

把性能直觉压缩成一句:"栈几乎免费,堆按次收费"。写代码时的默认选择永远是栈;只有当对象需要活得比作用域久、大小运行时才知道、或者体型巨大时,才买堆这张"贵一点的票"。热路径(每秒执行几百万次的循环)里出现 new,几乎一定是个性能事故——第 15 章讲性能优化时会回来算这笔账。

注意:栈虽快,但不是无底洞。每个线程的栈默认只有几 MB(Linux 上通常 8 MB),放得下小对象,放不下大数组。递归太深、栈上声明超大的数组,都会撞上栈底——栈溢出(stack overflow)通常直接段错误崩溃,连 bad_alloc 都不给你。大块数据(几 MB 的缓冲区)请走堆,或者用 std::vector 这类自带堆内存的容器。

14.5 常见错误:悬空指针与内存泄漏

堆对象把生命周期交到你手里,手一抖就是两类经典事故。第一类:悬空指针(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

14.6 章节小结与悬念:能不能强制限定对象只能出现在一个地方?

把本章压缩成一张图:栈对象 = 编译器管生管死,快、自动、默认选择;堆对象 = 程序员管生管死,慢、灵活、责任自负。栈用作用域换安全,堆用手动管理换自由。所谓"内存管理",说到底就是回答三个问题:对象在哪创建?谁负责析构?析构什么时候发生?——本章各小节各答一个。

现在把问题反过来,第一性原理的最后一步:能不能强制一个类,只能诞生在其中一个地方?比如某个类要求"绝不泄漏",能不能让编译器在有人试图 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 章《限制创建位置:只在堆上 / 只在栈上》会把这些机关的原理、边界和实战用法一次讲完。

章末练习

练习 1:对象住在哪 入门

下面的对象分别创建在哪个区域(栈 / 堆 / 其他)?① 函数内的 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 发挥作用的地方。

练习 2:手推构造析构顺序 进阶

不运行代码,写出下面程序的输出顺序:

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 最后)。一句话:构造顺序 = 声明顺序,析构顺序 = 声明顺序的逆序

练习 3:RAII 改造 进阶

下面的代码有一个典型问题——process() 里 new 出来的 Playerif 提前返回时会泄漏。请用 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)。核心思想一句话:用栈对象的生命周期,去兜底堆对象的释放

练习 4:亲手抓一次"现行犯" 挑战

写一个程序: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 之后对象已不存在,读到的只是"恰好还躺在那里"的旧字节或被别的代码改写过的数据——这就是未定义行为,任何结果都可能发生