编译器心里一直在算什么?
上一章编译器拦下了「返回指向局部变量的引用」。它凭哪条规则拦的?答案是:每当你递出一张纸条(引用),编译器都要核验一件事——这张纸条被使用的期间,它指向的东西必须一直活着。这个「活着的期间」就叫生命周期(lifetime)。
绝大多数时候编译器自己算得清,你根本感觉不到它。只有一种情况它会举手求助:当一张纸条的去留涉及多个来源、它算不出「这张纸条到底跟谁同寿」时,就要求你手动标注。看经典例子:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
编译器拒编译。为什么?这个函数收两张纸条、返回其中一张——返回的到底是 x 那张还是 y 那张?编译器不知道(它不看函数体里的 if 逻辑来做这个判断),于是它没法核验「调用者拿着返回值用的时候,指向的东西还活着」。比如调用者完全可能这样写:
let a = String::from("很长很长的一段");
let result;
{
let b = String::from("短");
result = longest(a.as_str(), b.as_str());
} // b 在这里被销毁
println!("{}", result); // 如果 longest 返回的是 b 那张,这就是悬垂纸条!
编译器需要你说清楚:返回值的生命周期跟两个参数中较短的那个一致。说法就是生命周期标注:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
<'a> 声明一个叫 'a 的生命周期名字(约定从 a 开始起名),三处 &'a str 的意思是:x 的纸条、y 的纸条、返回的纸条,它们的有效期都跟 'a 挂钩。调用时 'a 自动取「x、y 中较短的寿命」,返回值就被锁死在这个较短的期限里——上面那段坏代码因此编译不过,悬垂纸条发不出来。
类比:两张电影票,一张 3 点场、一张 5 点场,检票员要把其中一张转交给别人。他不需要知道转交的是哪张,只需要一个承诺:「转交出去的这张,失效时间按更早的那张算。」标注 'a 就是这个承诺——它把模糊地带变成编译器能核验的等式。
最重要的观念:标注是「描述事实」,不是「下达命令」
新手最大的误解是以为 'a 能「延长」什么。不能。生命周期标注一个比特都不改变程序的运行行为——变量什么时候销毁、内存什么时候归还,由所有权规则决定,跟标注毫无关系。标注只是把你代码里本来存在的寿命关系写给编译器看,让它能核验。如果代码本身逻辑上就站不住(比如真想返回局部变量的引用),你再怎么标 'a 也救不回来。
它是仪表,不是方向盘。仪表告诉你油够不够,但你不能把油表针拨到「满」就当加过油了。
为什么大多数时候不用写:三条省略规则
回看上一章的 fn length(text: &String) -> usize——收了纸条却没标注,为什么编译器不问?因为有一组生命周期省略规则(elision rules)在罩着最常见的模式。三条里最有用的两条:
- 每个引用参数,默认各自获得独立的生命周期;
- 如果只有一个引用参数,返回值自动与之同寿——因为返回的纸条只可能来自它,没有歧义。
(第三条:&self 方法里返回值与 self 同寿,下一章讲方法时你就见过了。)只有像 longest 这样「多个引用进来、一个引用出去、来源不唯一」的情况,规则兜不住,才轮到你手写。所以别被生命周期吓住:它是偶尔要填的表格,不是日常要背的咒语。
'static:活满全场的特例
你偶尔会见到 &'static str。'static 表示「跟整个程序同寿」。字符串字面量 "你好" 就是 &'static str——它的内容直接烙在程序的二进制文件里,程序活多久它活多久,所以永远不会悬垂。
心脏部分回顾
第三到六章是 Rust 的心脏:变量默认不可变(确定性)、一个值一个主人(谁来归还)、借用有排他规矩(不许边读边改)、生命周期(纸条不许比货物活得长)。四条合起来,第一章那个承诺——「不要 GC 也能担保内存安全」——已经全部兑现,而且全部发生在编译期,运行时零开销。从下一章开始是日常装备篇:结构体、枚举、错误处理,你会发现它们用着顺手,正是因为地基已经打牢。