所有权和生命周期不是新东西:编译器接管内存管理的五十年
第一次写 Rust 的人,大多卡在同一个地方。你写了一个看起来人畜无害的函数,返回一个引用,或者把一个变量传进函数后又用了一次,编译器甩回来一堆 does not live long enough、borrow of moved value、cannot borrow as mutable。你开始怀疑自己是不是不会编程了------这些规则从哪冒出来的,为什么别的语言都没有,为什么偏偏 Rust 要给你加这么多负担。
这篇文章想说明一件事:所有权和生命周期没有发明任何新负担。 编译器管理内存这件事,从 C 开始就一直在做,只是做得不彻底。Rust 做的,是把这件事从栈推广到堆,再补上一道过去只能靠人脑完成的检查。
要看清这一点,得先回到没有这些概念的年代,然后一级一级往上走。每往上一级,你会看到编译器替你多扛了一点内存管理的工作。等走到 Rust,你会发现所谓"新概念",不过是这条线走到尽头时,本来就该出现的东西。
第一级 · 汇编:只有字节,没有"生命"
最底层没有类型,也没有语言层面的内存生命周期。
需要先把一个常见的说法纠正过来:汇编不是 没有栈。栈是实打实存在的------rsp 寄存器指向栈顶,push/pop 移动它,call/ret 依赖一套调用约定(ABI)在栈上保存返回地址和寄存器。硬件和指令集都为栈提供了直接支持。
但栈在这一层,只是一个指针的加减 。CPU 执行 sub rsp, 16,它知道的全部是"栈顶指针往下挪了 16 字节"。它不知道这 16 字节里装的是一个 int 还是一个结构体,不知道这块空间"属于某个局部变量",更不知道这个变量"什么时候该死"。所谓"局部变量的生命周期",在汇编层面根本不存在------存在的只有"某个时刻 rsp 指到哪里"。
你可能会立刻反驳:函数返回(ret)时,栈帧不就被回收了吗?没错,这个回收动作在汇编/硬件层面就有。 call 压入返回地址、ret 弹出它,配合 rsp 的调整,栈帧确实随着函数返回而"消失"。这一点要先讲清楚,因为它正是后面所有层级的基础。
但关键在于看清这个回收是什么 :它纯粹是 rsp 指针的机械抬升,回收的是"一段字节",不是"一个生命到期的局部变量"。汇编不知道被抬掉的这块里有个叫 x 的变量、它的生命到此为止、有没有别的指针还指着它。而且在手写汇编里,这个抬升是你自己负责写 的------你 sub 了多少,就得记得 add 回去,CPU 不替你平衡。所以这一层的真相是:栈回收的机械动作底层就有;但"这次 rsp 抬升对应某个变量的生命终点"这个含义,以及"自动、不用你手写"这件事,都还不存在。
用一张图看这一层有多"裸":
ruby
内存是一整片无类型的字节,栈不过是 rsp 指针在其中上下移动:
地址 内容
0x7ff8 ?? ?? ?? ?? ← 这 4 个字节是 int?是指针?是半个 double?
0x7ff4 ?? ?? ?? ?? CPU 不知道,也不需要知道
0x7ff0 ?? ?? ?? ?? ← rsp(栈顶就在这)
...
sub rsp, 16 "分配" 16 字节 ------ 其实只是 rsp 减了 16
add rsp, 16 "释放" 它 ------ 其实只是 rsp 加了 16,字节原地没动
没有类型,就没有"这块是什么";没有语言级的生命周期,就没有"这块什么时候该死"。两者都得程序员自己记着。
那堆呢?在这一层,连"堆"这个东西都还不存在。
先把容易混的说清楚:汇编里没有 mmap,也没有 malloc。汇编(CPU 指令集)只有一条 syscall 指令(x86-64;老式 x86 是 int 0x80,ARM64 是 svc #0),作用就是"陷入内核"。mmap 是内核 提供的系统调用,在 x86-64 Linux 上编号是 9(编号随平台和架构而不同)------你把编号放进 rax、参数放进约定寄存器,再执行 syscall,内核才去帮你映射内存页。所以从汇编你能够到 mmap,但 mmap 本身是操作系统的抽象,不是一条汇编指令,它待在裸 CPU 之上、紧挨着的那一层。
而且它给你的也不是"堆",只是几页裸地址空间:"这段地址现在归你用了",仅此而已。内核不认识 malloc/free,也没有"这块 256 字节是一次分配、那块是另一次"的概念。
c
CPU / 汇编: 只有 syscall 指令(陷入内核),仅此一条
│
▼
内核(系统调用): mmap / brk ------ 把一片裸地址页映射给你
│ 没有"对象""分配单元"概念
▼
C 运行时库: malloc / free ------ 在裸页之上切小块、记账
└─ 这一层才是我们平时说的"堆"
所以我们平时说的"堆"------那个 malloc(256) 要一小块、free 还回去、自动维护哪块空闲哪块占用的分配器------是 C 运行时库在内核给的裸页之上搭出来的。mmap 是内核的,malloc/free 和"堆"是 C 库的;汇编这一层两样都没有,只有一条"陷入内核"的指令。这正好是本文那条线的起点:连"堆"都是上层一点点给你造出来的,底层只有裸字节、裸地址,和一条求内核帮忙的指令。
所以这一层的特征很清楚:内存管理的全部智力负担都在程序员这边。 哪块内存什么时候分配、什么时候释放、释放后还有没有人引用,这些判断没有任何工具替你做,全部存在你脑子里和注释里。这一层没有任何东西替你扛。
C 出现之后,编译器第一次替我们扛走了其中很大的一块。而且很多人没意识到,它扛走的恰恰是哪一块。
第二级 · C:栈的生命周期,编译器已经全自动接管了
这是全文的枢纽,所以请慢一点读。
C 引入了类型。类型让编译器第一次知道了三件事:一块内存有多大 、要求怎样的对齐 、里面的字节该怎么解释 。int 是 4 字节、按 4 对齐、补码整数;struct 有确定的布局和成员偏移。这是后面一切的前提------编译器要管理内存,首先得知道内存里装的是什么。
但 C 真正关键、又最容易被忽视的贡献,是另一件事:
给"栈帧回收"这个底层早就有的机械动作,赋予了"局部变量生命周期"的语义,并让它全自动发生。
回想第一级:ret 时栈帧被回收,这个动作汇编层就有,但那只是 rsp 的抬升,而且手写汇编时得你自己记着抬。C 在这个动作上叠了两层东西。第一,语义 :借助类型和作用域,C 把"这次 rsp 抬升"理解成"这几个有类型的局部变量,生命周期到此结束"。第二,自动 :你只管按作用域写代码,编译器自动生成对应的栈帧分配和回收指令,你一行 sub/add 都不用写。
看这段代码:
c
void demo() {
int x = 42;
char buf[256];
// ... 用 x 和 buf
} // 到这里,x 和 buf 占用的栈空间被自动回收
你从来不需要 对 x 或 buf 调用任何"释放"操作,甚至不需要像手写汇编那样自己去平衡 rsp。函数返回、作用域的 } 一到,它们占用的栈空间就被回收了。这件事有三个性质,请记牢,因为 Rust 会原样继承它们:
- 自动 :你不写任何释放代码(连
add rsp, N都不用手写),编译器在生成ret之前自动收回栈帧。这就是 C 相对汇编多给你的------回收动作底层本就有,C 让它不用你操心。 - 确定性:回收发生的时刻是精确的、可预测的,就是作用域结束那一刻,不是"某个未来的时间点由垃圾回收器决定"。
- 零运行时开销 :回收本质上只是
add rsp, N,一条指令,没有运行时的标记、扫描或引用计数。
把这三个词连起来:自动、确定性、零开销的栈内存管理,C 早在五十年前就给你了。 注意是"管理",不是"回收动作"本身------回收动作(rsp 抬升)硬件一直有,C 的贡献是把它和"局部变量的生命周期"绑定、并自动化。我们只是太习惯,以至于从不把"局部变量会自己消失"称为"内存管理"。但它就是。这是自动内存管理最朴素、最古老的形态------只不过它只对栈生效。
进入 f 时,栈帧是这样的(静态):
lua
栈(高地址在上,向下增长)
+------------------------+
| 返回地址 / 调用者帧 |
+------------------------+
x → | 42 | 4 字节
+------------------------+
buf →| 256 字节缓冲区 |
| ...... |
+------------------------+ ← rsp(栈顶)
x 和 buf 都坐落在 f 的栈帧里。函数一返回,整个栈帧被一次性回收(变化):
lua
return 之前 return 之后(} 一到)
+----------------+ +----------------+
| 返回地址 | | 返回地址 |
+----------------+ +----------------+
| x: 42 | ──→ | | ← 栈帧整体弹出
+----------------+ | (rsp 上移) | x、buf 一并消失
| buf[256] | | | 没写一行释放代码
+----------------+ +----------------+
这一步没有任何运行时代价,本质就是 rsp 上移一格。编译器在 } 这个位置,确定性地、自动地、零开销地回收了栈------这就是栈的生命周期管理。
堆就是另一回事了。堆内存的生命周期,C 完全没管:
c
char* p = malloc(256);
// ... 用 p
free(p); // 必须手动释放;忘了就泄漏,放早了或放两次就是 UB
栈享受的"作用域到了就自动收"的待遇,堆一点都没有。malloc 了就得记得 free,这件事重新回到了程序员脑子里。漏掉是内存泄漏,释放后接着用是 use-after-free,释放两次是 double free------这几样,构成了 C 程序几十年来 bug 的主要来源。
同样画出来,堆和栈是分家的两块,栈上只放一个指向堆的指针(静态):
lua
栈 堆
+-----------+ +------------------------+
p | ●--------+------------→ | malloc 来的 256 字节 |
+-----------+ +------------------------+
8 字节指针 这块谁来收?没人自动收
关键在出作用域那一刻------栈那半自动回收了,堆那半纹丝不动(变化):
sql
} 一到
+-----------+ +------------------------+
| (p 弹出) | | 那 256 字节还在! | ← 没人 free
+-----------+ +------------------------+ = 内存泄漏
栈指针自动没了 堆内存成了孤儿,地址也丢了
栈上的指针 p 跟着作用域自动消失了,可它指向的堆内存不会跟着消失。指针没了、内存还在 = 泄漏;反过来先 free 再用 = use-after-free;free 两次 = double free。栈的自动回收管不到堆,这就是 C 留下的第一个窟窿。
所以 C 这一级,留下了两个没解决的问题。记住它们,因为后面两级正好各接走一个:
- 堆没有栈那样的"作用域自动回收"。 释放堆内存仍然是纯手动的。
- 栈虽然自动回收,但编译器不拦你把指向已回收栈帧的指针带出去。 它确定性地销毁了局部变量,却不检查"是不是还有人指着这块已经没了的内存"。
C++ 先看到了第一个问题,并且用一个非常朴素的想法把它解决了。
第三级 · C++ RAII:把堆的释放挂到栈对象上(但不强制)
C++ 的核心机制叫 RAII(Resource Acquisition Is Initialization),名字起得很糟,但思想极其简单:析构函数 + 作用域。
C 已经保证了:一个局部对象在离开作用域时会被确定性地、自动地销毁。C++ 在这个保证上加了一句话------销毁的时候,自动调用它的析构函数 。于是只要你把"释放堆内存"这件事写进析构函数,堆内存的释放就被挂到了栈对象的生命周期上:栈对象一死,它的析构函数跑,顺手把它名下的堆内存也 free 掉。
cpp
void scope() {
std::unique_ptr<int> p = std::make_unique<int>(42); // 堆上分配
std::vector<int> v = {1, 2, 3}; // 堆上分配
// ... 用 p 和 v
} // 作用域结束:p 和 v 是栈上对象,被自动销毁
// 它们的析构函数自动跑,自动 free 掉各自管理的堆内存
这里发生的事,本质是用栈的确定性回收作为杠杆,撬动堆内存的回收 。unique_ptr、vector、std::string、锁、文件句柄------所有这些,都是把一份堆上的(或系统的)资源,绑定到一个栈上对象的生命周期里。栈对象一死,资源自动还。
和上面 C 的图对照着看,差别只在出作用域那一步。进入 scope 时(静态):
lua
栈 堆
+-----------+ +------------------------+
p | ●---------+------------→ | int: 42 |
+-----------+ +------------------------+
v | ●---------+------------→ | 1, 2, 3 |
+-----------+ +------------------------+
栈上对象持有堆指针 堆内存由栈对象"拥有"
作用域结束时,栈对象照常被回收------但这次回收会先调用析构函数,把堆一并带走(变化):
sql
} 一到:栈对象销毁 → 自动触发析构 → 析构里 free 掉堆
+-----------+ +------------------------+
| (p 销毁) | ~unique_ptr→ | 已释放 ✔ |
+-----------+ +------------------------+
| (v 销毁) | ~vector ───→ | 已释放 ✔ |
+-----------+ +------------------------+
栈照旧自动回收 堆搭了便车,跟着一起回收
对比 C 那张"堆内存成了孤儿"的图:同样是出作用域,C 的堆没人管,C++ 的堆被析构函数顺手收了。栈对象的回收本身和 C 没区别,新增的是"析构函数",它让堆释放挂靠在这次栈回收上------C 的第一个窟窿,在这里被补上了。
到这一步,需要明确归功:所谓"所有权",思想原型就是 RAII。 "一个资源由一个对象负责,这个对象销毁时资源被释放"------这句话描述的既是 C++ 的 unique_ptr,也是 Rust 的所有权。Rust 的 Drop trait 是 C++ 析构函数的直系继承。所有权不是 Rust 发明的,这一点后面还要再强调。
但要说清楚一个常见的误解:C++ 有所有权的工具,却没有所有权的系统。 unique_ptr(独占)、shared_ptr(共享)、move 语义,这些让你能够表达 "谁拥有这块资源";可是表达归表达,编译器并不追踪也不强制 这些约定。unique_ptr 被 move 走之后,源指针变成空,但你照样可以解引用它------编译通过,运行时才炸。所有权在 C++ 里是库提供的工具 + 程序员自觉遵守的约定 ,而不是语言强制的不变量。这正是它和 Rust 的分界线:Rust 把所有权做成了语言内建、编译器强制的系统;C++ 把它留在了"全靠你自觉"的层面。
而这种"全靠自觉",带来的就是 C++ 那个致命的短板:它不强制正确性。
RAII 给了你自动回收的能力,但它不阻止你犯错。下面这些,在 C++ 里全部能通过编译:
cpp
int& dangle() {
int x = 42;
return x; // 返回局部变量的引用------悬垂引用,编译器顶多警告
}
std::vector<int> v = {1, 2, 3};
int& r = v[0];
v.push_back(4); // 扩容:v 把数据搬到新缓冲区,旧缓冲区被释放
// r 还指着那块旧的、已释放的内存------悬垂,编译器不拦
auto p = std::make_unique<int>(1);
auto q = std::move(p);
*p; // p 已被 move 走,解引用空指针------编译器不拦
这些都是 use-after-free 的不同变体。RAII 解决了"什么时候释放",但"释放之后还有没有人在引用它"这个问题,C++ 仍然留给程序员的脑子。编译器不替你检查。
于是,经过 C 和 C++ 两级,问题精确地收敛成了一句话:
自动回收已经有了(栈靠 C,堆靠 RAII)。但"一个引用会不会比它指向的数据活得更久"这件事,仍然只能靠人脑检查。
Rust 做的,就是把这道检查,交给编译器。
第四级 · Rust:把所有权和借用,从"约定"升级成"系统"
C++ 已经把所有权的工具备齐了(unique_ptr、move),引用也有。它缺的只是一件事:把"所有权该怎么转移、引用怎么用才安全"这些规则,交给编译器强制执行,而不是全靠程序员自觉。Rust 补的就是这一件事------没有发明新机制,只是把 C++ 留在"全靠自觉"层面的东西,写进语言、交给编译器盯。
Rust 用来盯的东西,可以拆成三件,正好对应几个让初学者头疼的词:
- 所有权(ownership):这块内存归谁、谁负责释放。
- 借用规则 (共享
&/ 可变&mut):在不拥有它的前提下用它时,同一时刻谁能碰、能不能改。 - 生命周期(lifetime,
'a):借来的引用,会不会比它指向的数据活得还久。
第一件管"释放",后两件管"引用怎么用才安全"------后两件合起来就是大家常说的"借用检查"。下面一件件来,先看所有权。
所有权:谁拥有,谁释放
所有权就三条规则:
- 每个值有且只有一个所有者(持有它的那个变量)。
- 同一时刻只能有一个所有者;把值赋给别人、传进函数,所有权就 move(转移) 走了,原来的变量随即失效。
- 所有者离开作用域时,值被
drop(自动释放)。
把这三条和 C++ 的 unique_ptr 摆一起,你会发现规则几乎一模一样------区别只在谁来执行 。C++ 里 unique_ptr 被 move 走后,源指针变空,但你解引用它照样编译通过、运行时才炸;Rust 里,原变量在 move 之后被编译器标记为失效,你再碰它,编译期直接报错(就是后面例子二要演示的 E0382)。
所以 move 的本质,是把"将来由谁负责 drop"这个责任转交出去 。任意时刻只有一个所有者,就意味着只有一次 drop------double free 在 Rust 里不是"别犯的错误",而是根本无法表达的东西 ;手动 free 也随之消失,因为释放被绑定到了"所有者离开作用域"那一刻。这一块,接走的正是 C 留下的"堆靠手动释放"那个问题。
借用:不夺走所有权地用一下,但带上一条规则
所有权解决了"释放",但有个现实问题:不可能事事都转移所有权。我只想读一下你的字符串,难道还得把它的所有权拿走、用完再原样还回来?太笨了。于是有了借用(borrow) ------用 & 拿到一个引用,临时访问数据,但不获得所有权,也就不负责释放它。所有者还在,你只是借看一眼。
到这一步,听起来和 C/C++ 的指针/引用没区别------int* p = &x 不就是"借用 x、不拥有它"吗?对,借用这个动作,C/C++ 一直在做 。Rust 的不同不在"能借",而在它给借用加了一条 C/C++ 从来没有的规则。
借用分两种,对应你想干什么:
rust
let r = &x; // 共享借用(shared):只读,不能改
let m = &mut x; // 可变借用(mutable):可读可写
规则就一句话,但威力极大------对同一个数据,要么允许任意多个共享借用(大家一起读),要么只允许一个可变借用(独占地写),两者不能同时存在。 简称"共享与可变互斥"(shared XOR mutable)。
rust
// 合法:多个只读借用并存
let r1 = &x;
let r2 = &x; // OK,大家都只是读
// 非法:两个可变借用
let m1 = &mut x;
let m2 = &mut x; // error[E0499]: cannot borrow `x` as mutable more than once at a time
// 非法:一边可变、一边共享
let m = &mut x;
let r = &x; // error[E0502]: cannot borrow `x` as immutable
// because it is also borrowed as mutable
(这三段的报错都是 rustc 真实输出,后面例子三会完整演示其中一种。)
为什么是这条规则:别名 + 可变,是一大类 bug 的共同根源
这条"共享与可变互斥"的规则,初学者最容易觉得是 Rust 没事找事。但它精确地命中了 C/C++ 里一大类 bug 的共同根源:多个指针同时指向同一块数据(别名,aliasing),其中又有人能改它。 一旦"别名"和"可变"同时成立,灾难就有了温床:
- 迭代器/引用失效 :就是 C++ 那个
vector例子------r别名着v的内部数据,v.push_back又通过v改了它(扩容搬家)。别名 + 可变。(它同时还撞上"引用比数据活得久",所以下一节讲生命周期时还会再遇到它------一个 bug 同时踩中两条线,正说明这两条线是配合的。) - 数据竞争:两个线程各持一个指针指向同一数据,一个读一个写,且没有同步。别名 + 可变 + 并发。
- 隐蔽的逻辑错误:函数 A 改了某块数据,却不知道函数 B 正攥着同一块数据的指针、还以为它没变。别名 + 可变。
C 和 C++ 对别名是完全放任 的:你想要多少个指针指向同一个 int、其中几个能写,语言一概不拦------int *p = &x; int *q = &x; *p = 1; *q = 2; 合法得很。方便,但上面三类 bug 的门也就一直开着,全靠程序员自己保证"我没在别人读的时候偷偷改"。
Rust 的规则,本质是把"别名 + 可变不能同时发生"这条过去靠程序员自觉的纪律 ,变成编译器强制的不变量:读的时候可以有任意多个别名(反正都不改,安全);要改,就必须独占,这一刻不许有第二个引用存在。 迭代器失效、数据竞争这些 bug 因此在编译期就被连根拔掉------不是"小心就能避免",而是"写不出来"。这也是 Rust 敢宣称"无数据竞争"的底气所在。
生命周期:借用还活着吗?跨函数时得给编译器一个说法
借用规则管的是"同一时刻谁能借",还有另一半问题:借来的引用,会不会比它指向的数据活得更久? 这就是 C++ vector 那个悬垂的本质------引用还在用,数据先没了。在一个函数内部,编译器自己就能算清楚谁先死;但当借用要跨越函数边界 时,编译器没法只看一个函数体就推断引用能活多久,需要你给它一点信息。'a 就是这点信息:
rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'a 不是运行时的数据 ,它不占内存、不参与计算,编译后被完全擦除。它只是一个名字 ,标记"一段引用被保证有效的区间"。这行签名在对编译器说:返回的引用,有效期不会超过 x 和 y 里较短的那个。编译器拿着这个承诺,在每个调用点检查"实参真能满足吗",检查完就把 'a 扔掉,零运行时开销------和 C 的栈回收是同一个性质。
说白了,生命周期标注就是把"这个引用能活多久"从程序员的脑内假设,变成写在类型签名里、编译器能逐点核对的契约。借用检查 + 生命周期合起来,接走的正是 C 留下的第二个问题:指向已回收内存的引用。
三者怎么配合
理一下这一节出现的三个词,分工其实很干净:
- 所有权决定"什么时候释放"------每块内存恰好被释放一次,且不用手动。
- 借用规则(共享 XOR 可变)决定"同一时刻谁能碰它"------杜绝别名 + 可变同时发生。
- 生命周期决定"借用没有比数据活得更久"------杜绝指向已释放内存的引用。
所有权管释放本身,后两者一起管"引用在任何时刻都安全":一个看空间 (同时有几个引用、能不能写),一个看时间(引用会不会活过数据)。三者合起来,把 C/C++ 留下的窟窿一个个堵上。下面三个例子分别演示:例子一(栈)打生命周期,例子二(堆)打所有权,例子三打借用规则------看 Rust 拦下的,正是 C/C++ 默默放过的同一类事。
而这一切的代价,全部前置到了编译期------这也是它和 C++ 唯一的、却是决定性的区别。
例子一(栈)· 返回局部变量的地址
c
int* dangle() {
int x = 42;
return &x; // x 的栈空间在 return 时被回收 ------ 返回的是悬垂指针,UB
}
回到第二级讲的内容:C 编译器在这里自动且确定地 回收了 x 的栈空间------这正是栈生命周期管理在起作用,完全符合预期。问题不在回收,回收是对的。问题在于,编译器不拦你 把一个指向"马上要被回收的栈帧"的指针,带出函数。返回值 &x 指向的那块内存,在 dangle 返回的瞬间就不再属于你了。
画出来就一目了然。dangle 还没返回时,&x 指向自己栈帧里的 x(静态):
lua
dangle 的栈帧 调用者
+-----------+ +-----------+
x | 42 | ←──┐ | |
+-----------+ │ +-----------+
└────── return &x(指向 dangle 帧内的 x)
dangle 一返回,它的栈帧被回收,可那个指针被带了出去------指向了一块已经不属于任何人的内存(变化):
ini
dangle 返回后:它的栈帧没了,指针却还攥在调用者手里
+- - - - - -+ +-----------+
' x: 已回收 ' ←┄┄┄┄┄┄┄┄ | p = &x | p 指向已失效的栈帧
+- - - - - -+ 悬垂! +-----------+ 读它 = 未定义行为(UB)
虚线 = 这块栈随时被下一个 C 不拦你;Rust 在编译期拦
函数调用覆盖写
换到 Rust。先按直觉写,连生命周期都不标:
rust
fn dangle() -> &i32 {
let x = 42;
&x
}
编译器的第一反应不是骂你借用错了,而是根本不知道这个引用该从哪借:
rust
error[E0106]: missing lifetime specifier
--> src/main.rs:1:16
|
1 | fn dangle() -> &i32 {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value,
but there is no value for it to be borrowed from
注意这条提示:"返回类型里有一个借来的值,但没有任何值供它借用"。函数没有入参引用,返回的引用无源可溯。我们顺着编译器的要求,给它补上一个生命周期 'a,把"这个引用能活多久"显式标出来:
rust
fn dangle<'a>() -> &'a i32 {
let x = 42;
&x
}
这下生命周期检查有了可检查的对象,于是精确地报出那道 C 默默放过的错:
sql
error[E0515]: cannot return reference to local variable `x`
--> src/main.rs:3:5
|
3 | &x
| ^^ returns a reference to data owned by the current function
returns a reference to data owned by the current function------返回了一个指向"本函数所拥有的数据"的引用。这正是 C 那个悬垂指针的精确描述,只不过 C 编译器一声不吭地让它过了,Rust 在编译期把它摁死。
注意,两边的回收行为是完全一样的 ------x 都是栈上的局部变量,都在函数返回时被回收。差别只有一个:Rust 在编译期检查了"回收之后这个引用还有没有人用",而 C 没有。
同一个回收,Rust 只是补上了那道检查。
例子二(堆)· 谁来释放这块内存
栈的例子展示了"指向已回收内存的引用"。堆的例子,展示的是 C 的另一个遗留问题------手动释放------以及它衍生的全部事故。
先看 C。堆内存的释放,从头到尾是你的责任:
c
char* make() {
char* p = malloc(256);
strcpy(p, "hello");
return p; // 所有权"转移"给调用者------但这只是口头约定,类型里没有
}
void use() {
char* p = make();
// ... 用 p
free(p); // 你必须记得 free。漏了 → 泄漏
// free(p); // 再来一次 → double free,UB
// printf("%s", p); // free 之后再用 → use-after-free,UB
}
make 把内存的所有权"转移"给了 use,但这个"转移"只存在于文档和程序员的默契里------类型 char* 本身完全看不出"现在归谁、谁该负责 free"。于是漏 free、double free、use-after-free 全都是合法的 C 代码,编译器一概不管。
再看 Rust。同样的事------堆上分配一段字符串、从一个函数转移到另一个、用完释放:
rust
fn make() -> String {
let s = String::from("hello"); // 堆上分配
s // 所有权转移给调用者------写在类型里
}
fn use_it() {
let s = make();
// ... 用 s
} // 作用域结束:s 是当前所有者,自动 drop,堆内存自动释放
这里没有 free,因为不需要。String 在离开作用域时自动 drop(这是搭了 RAII 的便车,和 C++ 的 unique_ptr 同源)。而"所有权"被写进了类型系统:make 返回 String(转移所有权),use_it 成为新的所有者并最终负责释放。
那如果你 move 之后又去用旧名字呢?在 C 里这是合法的(还指着同一块内存,直到你手动 free);在 Rust 里,编译器直接拦下:
rust
fn main() {
let s = make();
let t = s; // 所有权 move 给 t,s 失效
println!("{}", s); // 想再用 s
}
rust
error[E0382]: borrow of moved value: `s`
--> src/main.rs:4:20
|
2 | let s = make();
| - move occurs because `s` has type `String`,
| which does not implement the `Copy` trait
3 | let t = s;
| - value moved here
4 | println!("{}", s);
| ^ value borrowed here after move
value moved here / value borrowed here after move------move 发生在这里,然后你在 move 之后又借用了它。因为 s 已经不再是所有者,它不会去 drop,也不允许再被使用。use-after-free 和 double free 在这里被同一条规则一起堵死了: 一块内存任意时刻只有一个所有者,只有那个所有者会释放它,而失效的旧名字碰都不能碰。
把这次 move 画出来。let s = make() 之后,s 是栈上的三字段牌子(指针/长度/容量),指向堆上的 "hello"(静态):
ini
栈 堆
+-------------------+ +-----------------+
s | ptr ●─────────────+──────→ | h e l l o |
| len = 5 | +-----------------+
| cap = 5 | 堆上的字符串数据
+-------------------+
(图里 cap = 5 是本机实测值;String 不承诺 cap 精确等于 len,具体由分配器决定,这里不影响后面的结论。)
let t = s 不复制堆数据,只把那块牌子(三个机器字)按位拷给 t,然后把 s 标记为失效(变化):
ini
let t = s 之后:堆数据原地不动,所有权从 s 转给 t
+-------------------+ +-----------------+
s | ✗ 已失效(moved) | | h e l l o | ← 堆数据没动,
+-------------------+ ┌──→ +-----------------+ 也没复制
t | ptr ●─────────────+───┘
| len = 5 | 编译器记下:现在 t 是唯一所有者
| cap = 5 | → 只有 t 会 drop 它,s 碰不得
+-------------------+
堆上的 "hello" 一个字节都没动,更没有第二份拷贝;变的只是"谁拥有它"。s 被画上叉之后,println!("{}", s) 就撞上了 E0382。只有一个所有者 → 只有一次 drop → double free 不可能;旧名字失效 → use-after-free 不可能。 两个老问题,被"唯一所有权"这一条规则一起锁死。
例子三(借用)· 一边改一边读
前两个例子打的是"内存还在不在"。这第三个,打的是另一类更隐蔽的事故:内存一直好好地在,但你一边通过一个指针改它、一边通过另一个指针读它------别名 + 可变同时发生。
先看 C。下面这个函数,把同一个变量的"可写指针"和"只读指针"同时传进去:
c
void process(int *a, const int *b) {
*a += 1; // 通过 a 改
printf("%d\n", *b); // 通过 b 读 ------ 但 a 和 b 可能是同一个东西!
}
int main() {
int x = 5;
process(&x, &x); // 合法。a 和 b 都指向 x
}
a 和 b 别名了同一个 x,process 里一边写一边读。C 编译器完全不拦 ------它甚至不替你区分 a、b 是不是同一块内存。这种"又改又读同一数据"正是真实 bug 的温床:你写 process 时脑子里多半假设 a、b 是两个不相干的东西(改 a 不影响读 b),可调用方一旦传进同一个地址,这个假设就破了,逻辑悄悄错位。更狠的是,如果你为了优化给参数加上 restrict(向编译器承诺"它们不会别名"),却仍传进同一个 x,那直接就是未定义行为。要避开这些,全靠你自己记着"别把同一个东西的可写和只读指针同时交出去"。
C++ 的 vector 失效(前面那个例子)是同一个病的另一副面孔:r 是别名,push_back 是通过 v 这条路径做的可变操作,两者同时存在,r 当场失效。
换到 Rust,同样的"一个可写、一个只读,指向同一个数据":
rust
fn process(a: &mut i32, b: &i32) {
*a += 1;
println!("{}", *b);
}
fn main() {
let mut x = 5;
process(&mut x, &x); // 同时交出 &mut x 和 &x
}
vbnet
error[E0502]: cannot borrow `x` as immutable because it is also borrowed as mutable
--> src/main.rs:8:21
|
8 | process(&mut x, &x);
| ------- ------ ^^ immutable borrow occurs here
| | |
| | mutable borrow occurs here
| mutable borrow later used by call
&mut x(可变借用)和 &x(共享借用)指向同一个 x,撞上了"共享与可变互斥"那条规则。Rust 在调用 process 的那一行就报错------别名 + 可变这个组合,在它能造成任何后果之前就被掐断了。
画出来,C 和 Rust 在这一刻的区别一目了然(静态:调用的瞬间):
markdown
C:同一个 x,两条指针都交出去了,一条还能写
x: 5
↑ ↑
a b a 可写、b 只读 ------ C 放行,bug 温床
(编译器甚至不知道 a、b 是同一个 x)
Rust:同一个 x,&mut 和 & 想同时存在
x: 5
↑ ↑
&mut x &x ✗ 编译器:可变借用在场时,
不许再有任何别的借用 → E0502
变化这一步,C 和 Rust 走向了两个结局(变化:函数体执行 / 编译):
arduino
C:正常编译运行,*a += 1 之后 x = 6,这里碰巧读到 6
但"改 a 会不会影响读 b"全凭调用方有没有传进同一个 x;
你写 process 时的"两者不相干"假设,编译器不替你保证
(若加 restrict 承诺不别名却仍传同一个 x → 未定义行为)
Rust:根本编译不过。代码停在 process(&mut x, &x) 这一行
你被迫改写成"先改完、再读"或"只读不改",别名+可变无从发生
注意,x 自始至终都活得好好的------这里没有任何"内存被释放"的问题。Rust 拦的纯粹是访问方式:同一时刻,对同一数据,"能写的"和"在读的"不能并存。C/C++ 把这条纪律交给你自觉,Rust 把它变成编译期铁律。这就是它和前两个例子互补的地方:前两个管"内存还在不在",这个管"内存还在、但你能不能这样同时碰它"。
对比三个例子,这条线就完整了:
- 栈(例子一):C 自动回收,但不检查"回收后还有没有引用" → Rust 用生命周期补上(E0515)。
- 堆 (例子二):C 连释放都靠手动,所有权信息全在脑子里 → Rust 用所有权 + 自动
drop,把释放变成自动、把所有权信息写进类型(move 后再用 = E0382)。 - 别名(例子三):C/C++ 放任"又改又读同一块数据",埋下失效与竞争 → Rust 用借用规则,让别名 + 可变无法同时成立(E0502)。
三个例子里,Rust 都没有发明新的内存行为。栈还是那个栈,堆还是那个堆,变量还是那个变量,内存的分配、回收、读写和 C/C++ 没有本质区别。Rust 改的只有一件事:把过去靠人脑保证的正确性,变成编译器强制保证的正确性。
收尾 · 你不是在搏斗,是在提前付账
回到开头那个问题:既然 Rust 没发明什么新东西,为什么学起来这么痛?
因为手动堆管理这道题,历史上只有两条逃生路,而你过去走的多半是另一条。
第一条是垃圾回收 (Java、Go、Python 等)。它把"判断内存还有没有人用"这件事,挪到了运行时:程序跑起来之后,由 GC 在后台去标记、清扫、回收。代价是运行时的吞吐损耗、不可预测的停顿,以及回收时机的不确定性。你写代码时确实轻松,因为这笔账被推迟到了运行时去付。
第二条就是 Rust 。它把同一件事挪到了编译期:在代码还没跑起来之前,借用检查器就把"引用会不会比数据活得久"全部查一遍。代价就是你在写代码时和借用检查器较劲的那段时间。
这里要分清两件事,别混:被挪到编译期的是"检查",不是"释放"。 释放(drop、free)在哪套方案里都发生在运行时------代码总得真的跑起来,才能真的把内存还给系统。GC 和 Rust 的区别不在"谁在编译期释放"(没人在编译期释放),而在两点:释放的时机确定不确定,以及要不要运行时扫描去找垃圾。
GC 这边,对象失去引用后并不立刻消失,要等 GC 下一次扫过来才回收(静态:此刻已是垃圾,但还占着):
lua
GC:对象不可达了,但还没被回收
根 / 栈
+-----------+ 堆
| (没有引用) | +-------------+
+-----------+ | 垃圾对象 | ← 没人指它了,
+-------------+ 但内存还占着
| 活对象 | 要等 GC 下次扫到
+-------------+ 才回收(时刻不确定)
lua
变化:某个不确定的未来时刻,GC 启动 → 扫描 → 标记 → 清扫
+-------------+
| 已回收 ✔ | ← 运行时回收,要先花 CPU
+-------------+ 扫一遍堆,还可能伴随停顿
| 活对象 | "什么时候收"你说了不算
+-------------+
Rust 这边,释放同样发生在运行时,但时机在编译期就定死了 ------编译器已经算准"s 在这一行离开作用域",于是直接在那一行插入 drop 调用。运行时不需要任何扫描去找谁是垃圾,执行到那行就释放(变化):
sql
Rust:编译器已算准 s 在 } 处死,直接在那行插入 drop
+-------------+ +-------------+
| (s 出作用域) | ──→ | 已释放 ✔ | ← 仍是运行时释放,但时机
+-------------+ +-------------+ 编译期就钉死,无扫描、无停顿
执行到这一行,drop 就在这里跑,和 C 的栈回收、C++ 的析构同一性质
垃圾对象在两套体系里都在运行时被收,差别在:GC 要在运行时花 CPU 扫描、回收时刻不确定、可能停顿;Rust 的回收点编译期就定好,运行时照着执行即可,不扫描、不停顿、时刻确定。
这两条路解决的是同一道题,只是把"判断内存还有没有人用"这件事放在了不同的时刻------GC 放运行时,Rust 放编译期。所以:
你不是在跟借用检查器搏斗。你只是在编译期一次性付清那笔账------这笔账要么以 GC 停顿的形式在运行时付,要么以段错误的形式在凌晨三点付。Rust 没让内存管理变难,它只是把你付账的那一刻,摆到了明面上。
那些 does not live long enough、borrow of moved value,不是 Rust 在为难你。它在替你跑那套你过去只能靠经验、在脑子里跑、还经常跑漏的检查。从汇编到 C,编译器接管了栈的生命周期;从 C 到 C++,自动回收延伸到了堆;从 C++ 到 Rust,这套回收终于配上了编译期的强制检查。
这是一条走了五十年的线。看懂它,所有权和生命周期就不再是"要背的规则",而是"本来就该如此"。