第 8 章:生命周期(Lifetimes)——与编译器做个“约定”

恭喜你,坚持到了这里。你已经征服了所有权(Ownership)和借用(Borrowing)。你现在理解了 Rust 如何通过 move& 来保证内存安全。在前面的章节中,编译器似乎总能神奇地知道一个引用是否有效。

但有时,当引用关系变得复杂时,编译器也会“感到困惑”。这时,它就需要你的帮助。生命周期(Lifetimes) 就是你与编译器之间的一种“交流语言”,你用它来向编译器描述和证明你的引用是有效的。

记住一个核心思想:生命周期注解并不会改变任何值能存活多久。它只是描述了多个引用生命周期之间的关系,好让编译器能够对它们进行分析。

为什么需要生命周期?一个悬而未决的问题

让我们来看一个编译器无法自动解决的问题。我们想写一个函数,它接收两个字符串切片(&str),并返回较长的那一个。

// 这段代码无法通过编译!
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

当你尝试编译时,会得到一个非常关键的错误信息,大意是:“无法推断出返回类型的生命周期… 请为返回类型指定一个生命周期参数。”

编译器为什么会困惑?

它知道函数返回了一个 &str,一个引用。但这个引用是从哪里来的?它指向的是 x 所指向的内存,还是 y 所指向的内存?编译器无法得知。

为什么这很重要?想象一下下面的调用场景:

let string1 = String::from("long string is long");
let result;
{
    let string2 = String::from("xyz");
    result = longest(string1.as_str(), string2.as_str());
} // `string2` 在这里被销毁
println!("The longest string is: {}", result); // `result` 可能会指向已经被释放的 `string2`!

如果 longest 函数返回了对 string2 的引用,那么当 string2 在内部作用域结束时被销毁后,result 就会成为一个指向无效内存的悬垂引用

编译器为了防止这种情况,它拒绝编译,并要求你做出一个承诺:“嘿,程序员,请你明确告诉我,返回的这个引用的‘有效期’和谁挂钩?”

生命周期的注解语法:与编译器立下'a之约

生命周期的注解语法本身很简单,它以一个**撇号(')**开头,后面跟着一个小写字母,通常按 'a, 'b, 'c 的顺序命名。

'a 本身没有特殊含义,它只是一个通用的生命周期参数,就像 <T> 是一个通用的类型参数一样。

现在,我们用生命周期注解来“修复”我们的 longest 函数:

// 'a 是一个生命周期参数,我们称之为“生命周期 a”
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

让我们来解读这个签名:

  1. fn longest<'a>:尖括号里的 'a 声明了一个通用的生命周期参数。这表示 longest 函数的签名中,至少有一个生命周期与 'a 相关。
  2. (x: &'a str, y: &'a str):这表示 xy 这两个引用都必须至少活得和生命周期 'a 一样长。
  3. -> &'a str:这才是最关键的“约定”。它告诉编译器:“我从这个函数返回的引用,其生命周期也与 'a 挂钩,它也必将至少活得和生命周期 'a 一样长。”

用大白话来说,这个签名就是在向编译器承诺:“我返回的引用,一定是从 xy 中来的。所以,它的有效时间,不会超过 xy生命周期较短的那一个。”

编译器得到这个承诺后,就会用这个规则去检查所有对 longest 函数的调用。在上面的例子中,编译器会将 'a 的实际生命周期确定为 string1string2 生命周期的交集(即 string2 的较短生命周期)。因此,当 result 试图在 string2 被销毁后继续使用时,编译器会发现 result 的生命周期超出了 'a 的范围,从而报错,完美地防止了悬垂引用。

生命周期在结构体中的应用

另一个需要生命周期注解的常见场景,是在 struct 中存放一个引用。

假设我们要创建一个 ImportantExcerpt(重要节选)结构体,它包含一个字符串切片的引用:

// 同样,这个定义也需要生命周期注解
struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");

    // i 的实例不能比它所引用的数据(first_sentence)活得更久
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

struct ImportantExcerpt<'a> 这个定义是在说:“ImportantExcerpt 的一个实例,其存活时间不能长于它内部 part 字段所引用的那个值的存活时间。”

这保证了 i 这个实例绝不会在 novel 字符串被销毁后还继续存在,从而确保了 i.part 永远是一个有效的引用。

生命周期省略规则(Lifetime Elision Rules)

读到这里你可能会想:“天啊,难道我以后写的每个带引用的函数都要加上这些 'a 吗?太繁琐了!”

好消息是:不用

Rust 团队发现,在实际编程中,生命周期的模式是可预测的。因此,他们在编译器中内置了一套生命周期省略规则。如果你的代码符合这些简单的规则,你就不需要显式地写出生命周期。这也就是为什么我们在前几章写的那么多函数都没有遇到生命周期问题。

这里不深入讲解规则细节,但你需要知道的核心是:

  1. 每个作为输入的引用参数,都会被编译器赋予一个独立的生命周期。
  2. 如果只有一个输入生命周期,那么它会被自动应用到所有输出引用上。
  3. 如果多个输入生命周期中有一个是 &self&mut self,那么 self 的生命周期会被自动应用到所有输出引用上。(这就是为什么我们之前在 impl 块里写的方法都不需要生命周期注解!

只有当编译器根据这三条规则仍无法推断时(就像我们的 longest 函数那样,它有两个输入引用,且没有 self),你才需要手动帮助它。

本章小结

我们今天终于揭开了 Rust 最神秘的面纱——生命周期。现在,让我们重新梳理一下它的本质:

  • 生命周期是一种描述性工具,而非控制性工具。它让你能和编译器沟通,描述引用之间的有效性关系。
  • 生命周期注解通过将不同引数的生命周期关联起来,来确保返回的引用不会比它所引用的数据活得更久
  • struct 中使用生命周期,保证了包含引用的数据结构不会比它所引用的数据活得更久
  • 因为有生命周期省略规则的存在,在大多数情况下,你并不需要手动编写生命周期。

恭喜你!你已经正式征服了 Rust 安全保证的三大支柱:所有权、借用和生命周期。这无疑是学习 Rust 过程中最陡峭、也最关键的爬坡阶段。一旦翻过这座山,前方的道路将豁然开朗。

从这里开始,我们的重心将从“Rust 如何保证你的代码安全”,转向“如何用 Rust 构建强大、高效的程序”。我们的视角也将从微观的语法细节,转向宏观的项目组织和生态系统。