1.5 生命周期:怎样保证引用指向的数据有效
上一节我们为模型设计出了剥离释放责任的引用(&T):真正负责释放堆内存的是原变量 s(String),而引用 &s(&String)只在栈上保存 s 的 8 字节地址,离开作用域时绝不释放堆。
上一节末尾留了一个追问:既然引用自己不管释放,那块堆什么时候释放完全听原变量的——万一负责释放的原变量先一步离开作用域、把堆内存释放掉了,而某个引用还留在外面的变量手里,会发生什么?
原变量先退场:引用手里的地址瞬间悬空
Section titled “原变量先退场:引用手里的地址瞬间悬空”不用复杂的函数,只要一个内层代码块 {},就能把这个漏洞构造出来:
{ r; // 外层先声明一个变量 r,准备存放引用 { x = alloc_heap("hello") r = &x // 把内层变量 x 的栈地址交给外层的 r } // ← 内层结束,x 在这里离开作用域 print(r) // ← 顺着 r 里的地址去读,会发生什么?}按我们已经立下的模型规则,一步步推演这段代码的执行过程:
- 在内层代码块里,变量
x申请了堆上的"hello",并负责在离开内层{}时释放它; r = &x把x在栈上的地址抄给了外层变量r。因为引用剥离了释放责任,释放责任依然留在x身上;- 走到内层
}闭合:x离开作用域,触发「离开作用域就释放」——x在栈上的三格控制头消失,它指向的那块堆资源"hello"被当场归还给分配器; - 回到外层代码块:变量
r还没离开作用域,它栈上的 8 个字节里,依然原封不动地存着刚才x的地址。下一行print(r)顺着这个地址摸过去,访问到的是一块已经失效的内存。
这就是引用自带的安全漏洞——悬垂引用(Dangling Reference)。
注意它和第三节 Move 之后再用旧变量的区别:Move 之后是原变量自己已经失效了;而这一次,引用变量 r 自己还在作用域里,失效的是它指着的那个原变量 x。
诊断:为什么运行时拿它没有办法?
Section titled “诊断:为什么运行时拿它没有办法?”面对 print(r) 这一行即将发生的内存错误,程序在运行时能顺着 r 发现异常并拦住它吗?
看一眼 r 在内存里的物理表示就明白了:在运行时,引用 r 在栈上只是孤零零的 8 个字节(一个内存地址)。
这 8 个字节里只存着目标地址,没有任何一位记录着「我指向的那个 x 还在不在」。当 CPU 执行到 print(r) 时,它只管顺着这个地址去内存里取字节,根本无从分辨那个地址上的数据是活着的,还是刚刚随着内层 } 闭合被回收的。
如果非要在运行时防止这次非法读取,只有两条老路:
- 因为
r还指着,就不让x在}处释放数据:等什么时候r也不用它了再释放——这等于把释放时机重新交回给运行时(也就是 GC 或引用计数),直接推翻了我们从第一节立下的「非 GC」前提; - 给每块内存额外维护一张运行时有效状态表:每次顺着引用读数据之前,先在运行时查表核对目标内存有没有被释放——这意味着每一次最普通的读取都要付出额外的运行时检查开销,性能底线同样不允许。
既不能延后释放,又不能在运行时加检查指令,运行时的路都走不通。
结论很直接:在不增加运行时开销的前提下,悬垂引用无法在运行时拦截,必须在程序运行之前——也就是编译期——由编译器发现风险并拒绝编译这段代码。
编译期的解法:自动比对两段有效范围
Section titled “编译期的解法:自动比对两段有效范围”如果让编译器在编译期替我们把关,它需要看什么才能发现上面这段代码的漏洞?
其实非常直观——编译器只需要顺着源码算出两段范围,然后做一次最简单的比对:
- 原变量
x的存活范围:从x被创建的那一行开始,到它所在代码块闭合的}为止; - 引用
r的使用范围:从r = &x拿到地址开始,到最后一次使用r(即print(r))的那一行为止。
把这两段范围画成横条,安全与否一目了然:
- 左图(合法):引用
r的使用范围,完全落在原变量x的存活范围之内——只要还在用r,x就肯定还没离开作用域,读取绝对安全; - 右图(悬垂):引用
r的使用范围,伸出了原变量x的终点之外——x已经离开作用域把堆释放了,r在后面还要去读,编译器当场拒绝编译。
规则只有一句话:引用的使用范围,绝对不能超出原变量的存活范围(引用不能活过它指向的数据)。
在 Rust 里,这套由编译器在编译期追踪并核对「有效范围」的机制,就叫生命周期(Lifetime)。
基础模型已经合拢
Section titled “基础模型已经合拢”停下来看一眼,补上编译期的有效范围检查之后,我们从第一节出发搭起来的这套自动内存回收模型,把关键缺口补齐了:
- 栈变量离开作用域(
}),自动释放它管理的堆资源——不用手写free,也不用运行时 GC,不会因为漏写而内存泄漏; - 带着释放责任的值赋值走 Move 语义,旧变量当场失效——保证同一块堆同一时刻只由一个变量负责释放,消除了双重释放;
- 引入剥离了释放责任的引用(
&T),并由编译器在编译期核对它的有效范围(生命周期)——既能让函数和多处代码按 Copy 语义共享读取数据,又在编译期拦住了悬垂引用。
下一节是本章的最后一节。我们把这五节推出来的逻辑链收拢成一张对照表,看看这套机制在真实的 Rust 语言里分别叫什么名字,并做一个综合推演练习。