卷 II · 所有权CH 08深度 08/24

生命周期:'a 不是魔法,是「活得够久」

尖括号里那个撇号加字母,可能是劝退你的最后一根稻草。但我要用一张图证明给你看:生命周期什么高深的都不是 —— 它就是一句大白话「借条不能比书活得久」,被编译器算出来、偶尔让你确认一下而已。绝大多数时候你根本不用写它。

生命周期悬垂引用省略规则'static

先把「生命周期」这个词祛魅

「生命周期」听起来像个动词、像要你去管理什么。不是。一个引用的生命周期,就是它保持有效的那段时间 —— 从它被创建,到它指向的东西还活着的那一刻为止。它是一个名词、一个既成事实,不是你要去做的操作。

而生命周期检查要保证的,是上一章那条约束的展开,一句话:

◆ 生命周期不变式

一个引用,永远不能比它所指向的值活得久。

借条不能比书活得久。书销毁了,借条就是一张指向虚空的废纸(悬垂引用)—— 这正是 C 里段错误、C++ 里 use-after-free 的来源。Rust 让这种借条根本编译不出来

就这么简单。你觉得它难,是因为教程往往一上来就甩 fn foo<'a>(...) 的语法,却没先讲清它在维护的就是这一句话。我们反过来 —— 先看图。

▶ 用生命线看悬垂引用

下面每个例子都画出每个绑定的「生命线」:锈色是值本身(它活多久),青 / 紫是引用(它想指多久)。规则用眼睛就能看:只要某条引用的横条右端,越过了它所指那个值的横条右端,就是悬垂,编译器喊 E0597

三个例子对照着看,一切都清楚了:

  • 悬垂引用 ✗x 活在内层作用域,x 一销毁,指着它的 r(在外层)就悬空了。生命线上,青条伸出了锈条的右边。E0597x 活得不够久。
  • 活得够久 ✓xr 在同一层,x 一直活到 r 用完之后。青条完全罩在锈条之内。通过。
  • 借着就想还 ✗xr 借着,你却想 drop(x) 提前销毁它 —— 借用还没结束就想毁掉主人,E0505。借用检查器和 drop 是一伙的。

那么,'a 到底是干嘛的

如果编译器自己就能算生命周期,为什么有时候还要我们写 <'a>?看这个函数 —— 返回两个字符串里较长的那个:

fn longest(x: &str, y: &str) -> &str {   // ✗ 编译器:返回的引用活多久?
    if x.len() > y.len() { x } else { y }
}

编译器卡住了,但不是因为它笨,是因为这里真的有歧义:返回的那个引用,是借了 x、还是借了 y?运行时才知道(取决于谁更长)。那它到底能活多久 —— 跟着 x 还是跟着 y?编译器没法替你拍板,只能请你表态。你表态的方式,就是那个撇号标签:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

'a 翻译成人话:「我给 xy 和返回值贴上同一个标签 'a,意思是 —— 返回的引用,不会比 xy 里活得更短的那个活得更久。」你不是在指定一个具体时长,你是在声明它们之间的关系:返回值的命,跟这俩输入绑定。有了这个承诺,编译器就能在调用处检查:你拿到的返回引用,有没有被用得比 xy 还久。

◆ 一句话记住 'a

<'a> 不是让你「创造」或「管理」生命周期。它是让你把「哪个引用得比哪个引用活得久」这层关系,说给编译器听 —— 当函数签名本身说不清这层关系时。签名说得清的时候(绝大多数时候),你根本不用写。

为什么你平时几乎不用写它:省略规则

你可能已经写过不少带引用的函数,却从没标过生命周期 —— 因为编译器有一套省略规则(elision),能自动补上最常见的情况。规则就三条,你不用背,但知道有它,你就明白「为什么有时要写、有时不用」:

  1. 每个引用参数,各自分一个生命周期;
  2. 如果只有一个引用参数,返回值的生命周期就跟它(fn first(s: &str) -> &str 不用标,因为没歧义);
  3. 如果有 &self,返回值默认跟 self(方法里几乎从不用标)。

上面的 longest 之所以要你手动标,正是因为它破了规则二:有两个引用参数,编译器不知道返回值该跟哪个。需要你标生命周期的场合,本质都是「有多个候选,编译器无法自动选一个」。一旦你这么理解,那个撇号就再也不吓人了。

'static:活得和整个程序一样久

有一个特殊的生命周期你会常遇到:'static,意思是「这个引用能活到程序结束」。字符串字面量就是 &'static str —— 因为 "hi" 这样的字面量被编进了二进制,程序在它就在:

let s: &'static str = "我活到最后一刻";   // 字面量天生 'static

你会在两个地方撞见它:一是错误提示里(编译器有时建议某处需要 'static),二是把数据搬进线程 / 存进长期存活的结构时。第 18 章讲并发时会再见到它 —— 一个搬进新线程的闭包,通常被要求 'static,因为编译器无法保证它不会比外面借的东西活得久。

结构体里存引用,就得带上标签

最后一个常见场景:结构体想存一个引用(而不是拥有的值),它就必须声明生命周期参数 —— 因为编译器要确保这个结构体不会比它借的那个东西活得久

struct Excerpt<'a> {
    part: &'a str,     // 我借了别人的一段字符串
}
// 含义:一个 Excerpt 不能比它 part 借的那个字符串活得久

这也解释了一个新手常有的困惑:「为什么我的结构体一存引用,就冒出一堆生命周期语法?」因为你让结构体持有了一张借条,编译器必须追踪这张借条别过期。如果嫌烦,最简单的办法往往是:让结构体拥有数据(存 String 而不是 &str —— 拥有就没有借条要追踪,生命周期语法一下全消失。这是个非常实用的取舍:用一点内存(拥有一份),换掉一堆生命周期心智负担。

回流

⟲ 回流 · 悬垂引用,别的语言怎么处理

C:「返回一个指向局部变量的指针」是经典 bug —— 函数一返回,栈帧销毁,指针指向垃圾。C 编译器顶多给个警告,然后就让你上线了,某天它随机崩溃或被利用。Rust 的生命周期检查,就是把这一整类 bug 在编译期彻底消灭。你在 demo 里看到的「悬垂引用 ✗」,就是这个 C 经典坑,在 Rust 里连编译都过不去。

Java / Kotlin / Go:它们没有悬垂引用问题 —— 但代价是有 GC。只要还有任何引用指着一个对象,GC 就不回收它,所以你永远不会拿到指向「已销毁对象」的引用。换句话说,它们用「运行时追踪所有引用(GC)」解决了这个问题,Rust 用「编译期追踪所有引用(生命周期)」解决了它 —— 同一个目标,一个花运行时,一个花编译期的你。生命周期,就是 Rust 为了不要 GC 而付的那笔「智力税」。这一章之后,这笔税你应该觉得没那么贵了。

这一章的一句话

生命周期就一句话:引用不能比它指向的值活得久。<'a> 不是魔法参数,是你在签名说不清关系时,帮编译器把「谁得比谁久」讲明白 —— 而绝大多数时候,它自己就算出来了。

卷 II 只剩最后一块拼图了。我们已经反复提到「拥有 vs 借看」,下一章把它落到你天天要用的东西上:为什么字符串有 String&str 两个类型,到底该用哪个。