标了 'a 也救不了悬垂引用:Rust 生命周期到底约束了什么

Rust 的生命周期是编译器给“一个引用在多长的代码区间内有效”起的抽象名字,'a 只是可以代入具体区间的泛型参数。它只存在于类型检查阶段,编译后会被完全擦除,既不会延长任何值的存活时间,也不带来运行时开销。最常见的误解是把标注当成续命指令,或者以为 T: 'static 要求值活到程序结束;实际上 String、Vec 这类拥有所有权的类型都满足 'static。本文从函数签名、省略规则、结构体借用与型变讲清约束如何传递,并给出读报错、拆借用、选拥有还是借用的实用判断。

是什么

Rust 的生命周期(lifetime)是编译器给“一个引用在多长的代码区间内有效”起的抽象名字。它描述的不是值本身活多久,而是借用凭证的有效期:只要凭证还在被使用,出借方就不能先消失。直觉上可以把它想成图书馆的借书卡——卡上写着还书期限,但期限不会让书多印一页,只是保证你拿走期间书还在架上。

每个引用都带生命周期,只是绝大多数时候由编译器推断,写出来才需要标注。典型场景有三类:函数从多个参数里挑一个借用返回;结构体直接存 &str 这类引用;以及 trait object、迭代器、async 块这些把借用藏进类型里的地方。语法上 'a 是泛型参数,只不过它的取值是“代码里的一个区域”,不是类型。

核心原理

标注是约束,不是指令。 Rust 官方文档写得很直白:生命周期标注不改变任何引用的实际存活时间,它只描述多个引用之间的有效期关系。fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 读作:存在某个区域 'a,两个入参都至少活到 'a,返回值也只在 'a 内有效。调用时 'a 被代换成两个实参生命周期的交集,也就是较短的那个。所以哪怕函数实际返回的是长命的那一个,调用方拿到的类型仍然被短命的那个卡住。

为什么非得写进签名。 Rust 是分开检查的:函数体在定义处检查,调用点在别处检查,调用方看到的只有签名。签名不写清“返回值借自哪个参数”,编译器既不能替定义方证明安全,也不能替调用方判断借用何时结束。这就是 fn pick(x: &str, y: &str) -> &str 报 E0106 的原因——不是编译器笨,而是它拒绝猜。反过来,把关系写进签名后,报错能精确落在“谁活得不够长”上。

// 两个输入一个输出,省略规则无法确定返回值借自谁,会报 E0106:
// fn pick(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } }

// 显式标注后:返回值的有效期是两者中较短的那个
fn pick<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let long = String::from("long string");
    let out;
    {
        let short = String::from("short");
        out = pick(&long, &short);
        assert_eq!(out, "long string"); // 值来自 long,类型仍被 short 约束
    }
    // println!("{out}"); // E0597:short 已释放,而 out 的借用还没结束
}

省略规则只是常见模式的语法糖。 三条规则:每个省略的输入生命周期各成一个独立参数;只有一个输入生命周期时,它被赋给所有省略的输出;方法签名里若接收者是 &self 或 &mut self,接收者的生命周期赋给输出。这就是 fn first_word(s: &str) -> &str 和 fn name(&self) -> &str 能省略的原因。两条以上输入且没有 self 时规则用尽,必须手写。

结构体把约束带进类型。 struct Excerpt<'a> { part: &'a str } 表示实例不能比它借用的数据活得更久。这个 'a 会传染:任何持有 Excerpt<'a> 的类型、函数、trait 实现都要处理它。这不是“零拷贝所以更高级”,而是把借用关系扩散到了整条调用链。

型变决定能不能“缩短”借用。 &'static str 可以当 &'a str 用,因为 'static 比任何 'a 都长,这叫协变。&'a mut T 对 'a 协变,但对 T 是不变的:不能把 &mut &'long str 当成 &mut &'short str,否则你就能通过这个可变引用,把一个短命引用写进一个自称能活很久的位置,之后读出来就是悬垂指针。函数参数位置则相反,是逆变的。

trait object 的默认寿命要单独记。 写 Box<dyn Trait> 时,若上下文推不出更短的约束,它默认等价于 Box<dyn Trait + 'static>;想把一个借用塞进 trait object,必须显式写 Box<dyn Trait + 'a>。很多“明明只借了一会儿,却被要求 'static”的报错,源头就在这个默认值。

运行时没有这个东西。 生命周期在类型检查后、代码生成前被完全擦除,不占空间、不参与单态化、没有运行时检查。真正落进二进制的只有借用检查通过后的普通指针和长度。另一个容易忽略的点是 NLL(非词法生命周期):借用的结束点是最外层最后一次使用,而不是词法作用域的右花括号,所以“把变量放进内层块”并不总是能解决问题。

常见考点与误区

  • 误区一:加 'a 能延长生命周期。 不能。返回函数内部创建的 String 的切片,写多少个 'a 都是 E0515;标注只能描述关系,不能改变所有权和释放点。此时要么返回拥有所有权的值,要么把缓冲区交给调用方。
  • 误区二:T: 'static 表示值活到程序结束。 它只表示 T 里不包含短于 'static 的借用。String、Vec<u8>、i32 都满足,Vec<&'a str> 不满足。面试常把 &'static T 与 T: 'static 混为一谈,后者宽得多。
  • 误区三:'a 要求两个参数活得一样长。 它是下界:两个参数都至少覆盖 'a,而 'a 可以取较短的那个。真正被“拉平”的是返回值的有效期,不是入参的实际寿命。
  • 误区四:生命周期是运行时机制、有性能开销。 它是纯编译期的区域推断,擦除后零成本;把 Rust 的生命周期类比成 GC 或引用计数,是把两套机制搞混了。
  • 考点五:省略规则与 '_。 被问到 fn f(x: &str, y: &str) -> &str 为什么编译不过,要能背出三条规则并指出“两个输入、无 self,输出无法归属”;在路径里写 Thing<'_> 是显式请求推断,语义与省略一致。
  • 考点六:&mut 的型变。 能说清“对 'a 协变、对 T 不变”,并给出“短借用被写进长位置”的反例,基本就答到了借用检查器的安全边界。

实践建议

  • 先改结构,再动标注。 报 E0597 时先问“谁要求这个借用活到这么久”,把使用点提前、把返回值改成索引/范围/拥有所有权的值,往往比调参数更快。标注是最后一步,不是万能药。
  • API 边界优先拥有,内部优先借用。 公共函数返回 String 比返回 &str 少一串生命周期参数,调用方也更自由;确实要零拷贝时再用 &str 或 Cow<'a, str>,并接受约束外溢。
  • 不要用 'static 消错。 它常把“借用太短”变成“类型不匹配”,错误信息更远。需要跨线程或 spawn 时,正确做法是把数据 move 进去或用 Arc,而不是给引用硬贴 'static。
  • 结构体只在真的存引用时加生命周期参数。 没有引用就没有 'a;能存 String 或 Arc<str> 就别存 &str,尤其是要进缓存、进全局表、进 async 任务的结构体。
  • 把签名当契约看。 多标一个 'a 可能让本来能编译的调用方突然不满足约束;这也是为什么“运行时零成本”不等于“改标注无成本”——它改变的是类型的可通过性。
  • 能返回索引就别返回切片。 解析、分词这类代码返回 Range<usize> 或一个小的枚举,结构体就不必带 'a,调用方也不会被借用跨度长期绑住;切片留给确实免拷贝的热路径。

参考:Rust Book 第 10.3 节、生命周期省略规则、子类型与型变。

一句话总结:生命周期标注不续命、不运行,只是把“谁能借多久”写进类型,让编译器在越界时拒绝你。

相关阅读

← 返回文章志