每个人的第一个生命周期错误都以同样的方式到来。你写了一个返回引用的函数——比如返回两个字符串中较长的那个——编译器回敬 error[E0106]: missing lifetime specifier,然后你做了每个初学者都会做的事:往签名里到处撒 'a 和 'static,直到红色波浪线消失。有时候它甚至真的编译过了。这恰恰是本末倒置,也是生命周期坏名声的来源:人们把标注当成改变行为的咒语,而它实际上是供编译器检查的声明。给自己加上 'static 不会让数据活得更久,正如宣布自己更高不会真的改变身高。
让本篇变短的是这个重新框定:生命周期不是你能控制的东西。它是程序里一个引用保持有效的区域——而标注语言的存在,是为了让你告诉编译器这些区域之间的关系。第 2 篇给了你借用的规则;本篇给你跨函数边界谈论它们的语法。
标注是描述,从不是指令
看看编译器在这个经典例子里到底需要什么:
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
为什么这里需要标注?因为调用者需要知道返回的引用能活多久——而答案必须只凭签名就能算出来,不许读函数体。这个函数可能返回 x 也可能返回 y,所以诚实的答案是:结果最多只能活到两个输入中较短的那个死去。 'a 说的就是这件事。它给一个区域命名——“x 和 y 里先结束的那个”——并把输出放进这个区域。
三个推论随之落下,它们就是完整的心智模型:
- 标注在运行时不改变任何事。 检查完就被擦除。你的程序有它没有它运行得一模一样——它存在的全部意义是让编译器能证明借用是安全的。
- 输出的生命周期必须能追溯到某个输入(或
'static)。函数不能凭空制造有效性;如果签名表达不出输出来自哪里,那是设计错了,不是语法错了。 - 错误的标注也会被拒绝。 “编译过了,所以我的标注是对的”是一个真实的误区——你完全可以写出比必要更严格的
'a关系,损失调用者本来要的灵活性。标注是一份合同,签字前要看清。
你要写的标注比想象少:省略规则
如果每个引用都要标注,Rust 就没法读了。所以编译器会自动填上三种无歧义的情形——生命周期省略规则:
- 每个输入引用得到自己的生命周期参数。
fn f(x: &i32, y: &i32)私底下是fn f<'a, 'b>(x: &'a i32, y: &'b i32)。 - 如果恰好只有一个输入生命周期,它就是输出的生命周期。
fn first_word(s: &str) -> &str直接可用——一个输入,一个答案,没什么可决定的。 - 在方法里,
&self的生命周期就是输出的生命周期。fn name(&self) -> &str把结果绑在对象上,这几乎总是你想要的意思。
至关重要的性质是:编译器从不瞎猜。 如果三条规则都用完、输出的生命周期仍无法确定——比如有两个输入、没有 self 的 longest——它就停下,用 E0106 来问你。这不是编译器能力弱;这是它拒绝签署一份读不懂的合同。你的大多数函数落在规则 2 和 3 里,这就是为什么 Rust 一直感觉不到标注的存在——直到突然感觉非常强烈的那一刻。
必须动手标注的两个地方
省略规则管函数。其他情况都归你——而“其他情况”基本上就两种形状:
输出来源有歧义的函数——比如 longest。修法永远是同一个动作:把关系命名出来。当输入的生命周期确实不同、而输出只依赖其中一个,就如实说:
fn extract<'a>(ctx: &'a Context, _pattern: &str) -> &'a str {
ctx.data // 跟 pattern 活多久无关——它绑在 ctx 上
}
持有引用的结构体——类型为 &'a str 的字段意味着这个结构体是别的东西的借用视图,结构体实例永远不能比它的源头活得更久:
struct Parser<'a> {
input: &'a str, // 这个 Parser 是借来的;它不拥有数据
}
初学者在这里撞墙:你不能从一个函数里返回 Parser<'a>,如果源字符串是在函数内部创建的——源头在右花括号处死亡,编译器会直说(E0515)。这不是生命周期问题,这是穿着生命周期外衣的所有权事实。修法通常在更上游:把源头作为参数传进来,或者干脆存一个拥有的 String。判断当前情况该用哪种修法,就是这门手艺的大头。
’static 的两副面孔
被误解最深的生命周期,是听起来最简单的那个。'static 在两个位置上是两个意思,而混淆它们是经典的弯路:
&'static T是一个地点——指向“活满整个程序”的数据的引用:字符串字面量、static项、故意泄漏的分配。这 genuinely 很罕见,所以“给它加'static“几乎从不是正确修法。T: 'static是一种性质——一个约束,意思是“这个类型不含任何借用来的引用”。而让所有人意外的部分来了:所有拥有的数据都满足它。String是'static的,Vec<u8>是'static的。当std::thread::spawn要求F: 'static,它要的不是永生数据——它要的是不借用任何东西的数据,好让闭包能比 spawn 它的人活得更久。把一个拥有的String用move ||挪进去就完事了。不需要Box::leak,不需要献祭仪式。
2024 edition 悄悄修好了你未来的错误
2024 edition 有两个与生命周期相邻的变化值得知道,因为它们各自会替你省下一小时。尾表达式临时值现在先于局部变量析构——2021 年会拒绝的那个著名的 RefCell 案例(函数最后一行写 c.borrow().len())现在能编译了。而 if let 的临时值现在先于 else 分支析构——修掉了一个真实的死锁模式:读锁贯穿整个匹配表达式,在 else 里撞上写锁。
同一个变化的另一面:尾表达式临时值现在可能比 2021 允许的更早死亡,所以 let x = { &String::from("1234") }.len();——2021 的临时值续命规则还能救回来——在 2024 里会失败。修法是你本来就想要的习惯:把临时值提升为有名字的 let 绑定。 有名字的绑定有名字、有作用域、没有意外。
练习,以及第 4 篇
- 不带标注写
longest,完整读一遍 E0106。加上上面的'a,然后用两个作用域刻意不同的字符串调用它,确认:结果随较短的那个一起死。在短的那个 drop 之后打印它,看编译器拒绝。 - 拿
extract的例子,拿掉_pattern的独立性:把函数体改成有时返回_pattern。看着编译器逼你把两个输入统一进'a——感受合同收紧,正如它应该的那样。 - 做
'static演练:用move闭包捕获一个拥有的String去 spawn 线程(能编译),再试试捕获&String(不行——它在借用)。现在你知道T: 'static真正要什么了,以后也不会再出于困惑去Box::leak。
第 4 篇从“东西活多久”转向“东西是什么”:enum 即状态机、模式匹配作为 Rust 真正想要的控制流,以及用 Option、Result 和 ? 运算符做错误处理——类型系统的第一笔真正的回报。