blog.dopana

Back

无垃圾回收(GC)下的内存安全是 Rust 的最大卖点。为了实现这一目标,编译器使用了一个名为 借用检查器(Borrow Checker) 的工具,用来确保所有的引用都始终指向有效的内存。

借用检查器用来预防 悬垂引用(Dangling References)(即引用指向了已被释放的数据)的核心机制就是 生命周期(Lifetimes)。

在本文中,我们将深入剖析 Rust 的生命周期,理解编译器为什么需要它们,学习如何编写生命周期标注,并探索其底层的直观思想模型。

问题所在:悬垂引用#

当程序试图访问已经被释放的内存地址时,就会发生悬垂引用错误。以下是 Rust 会直接拒绝编译的一个简单示例:

fn main() {
    let r;

    {
        let x = 5;
        r = &x; // ❌ x 在该作用域结束时被释放(drop)
    }

    println!("r: {r}"); // ❌ r 指向了已被释放的空间!
}
rust

如果你尝试编译这段代码,Rust 会抛出错误:x does not live long enough(x 存活的时间不够长)。编译器会对比变量的作用域(Scopes),以确保引用不会比它们所指向的拥有者存活得更久。

gantt
    title x 和 r 的作用域生命周期
    dateFormat  X
    axisFormat %s
    section 变量 x
    x 作用域 : active, 0, 2
    section 引用 r
    r 作用域 : active, 0, 4

在这里,r 存活的范围 is 04,而 x 只在 02 之间存活。因为 rx 存活得更久,所以该引用变得无效。

什么是生命周期?#

生命周期是指引用保持有效的范围(作用域)。在大多数情况下,生命周期是隐式的,可以由编译器自动推导。然而,当函数或结构体中引用的来源和相互关系存在歧义时,我们必须进行手动的生命周期标注。

[!IMPORTANT] 生命周期标注 不会 改变任何值实际存活的时间。相反,它们只是描述了多个引用之间生命周期的对应关系,以此向编译器证明不会发生任何非法内存访问。

函数中的泛型生命周期#

让我们来看一个返回两个字符串切片中较长者的函数:

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

编译器拒绝编译此函数,因为它无法确定返回的引用究竟是来自 x 还是 y。如果 x and y 拥有不同的生命周期,返回的引用就有可能超出其中某个参数的生命周期,从而引发悬垂指针。

为了解决这个问题,我们需要引入泛型生命周期参数:

// ✅ 编译成功!
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
rust
  • <'a> 声明了一个名为 'a 的泛型生命周期参数。
  • x: &'a stry: &'a str 指定了两个输入引用的存活时间至少要与生命周期 'a 一样长。
  • -> &'a str 承诺了返回的引用其存活时间也至少与 'a 一样长。

在实际使用中,返回值被赋予的生命周期 'a 将等于输入参数 xy 的生命周期中的 较短者。

通俗比喻(ELI5):房屋转租#

把生命周期想象成房屋租赁合同:

  • 房东(所有者):拥有实际数据的变量。
  • 租客(引用):借用并使用数据的用户。
  • 转租合同(生命周期):借用契约。
房东的主租约(数据存在):  ====================
转租合同(借用范围):       ==========      (有效) ✅
转租合同(借用范围):       ====================== (无效!) ❌
text

你的转租合同(引用生命周期)绝对不能超过房东的主租约期限(拥有者的作用域)。如果在房东的租约到期后你仍然留在公寓里,你就是在非法侵占(访问无效内存)!

包含引用的结构体#

如果你定义的结构体中保存的是引用而不是拥有所有权的类型,你必须在结构体定义上声明生命周期标注。这能保证结构体实例的存活时间不会超出它所持有的引用:

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("找不到 '.'");
    
    // 结构体实例的生命周期与 first_sentence 绑定
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}
rust

这里,ImportantExcerpt 的实例其存活时间无法超出存储在其 part 字段中的引用的寿命。

生命周期省略:无声的规则#

你可能会注意到,在过去我们编写过许多返回引用的函数,却不需要进行任何标注,例如:

fn first_word(s: &str) -> &str { ... }
rust

为什么这段代码不需要 'a 就能通过编译呢?

Rust 团队发现,开发者在编写代码时经常重复使用相同的生命周期模式。为了提升编码的便利性,他们将三条确定性的推导规则硬编码到了编译器中(称为 生命周期省略规则):

  1. 每个作为引用的参数(输入)都会获得自己独立的生命周期参数(例如:fn foo<'a, 'b>(x: &'a i32, y: &'b i32))。
  2. 如果正好只有一个输入生命周期参数,该生命周期会被分配给所有的输出引用(例如:fn foo<'a>(x: &'a i32) -> &'a i32)。
  3. 如果有多个输入生命周期参数,但其中一个是 &self&mut self(即它是一个方法),那么 self 的生命周期会被分配给所有的输出引用。

只有当编译器应用了这些规则后,仍旧无法推断出返回值的生命周期时,才会抛出编译错误,要求你手动标注。

静态生命周期#

Rust 中有一个特殊的生命周期 'static。它表示引用可以在程序的整个运行期间都保持有效。

所有的字符串字面量都拥有 'static 生命周期,因为它们的数据直接被硬编码到了程序的二进制文件中:

let s: &'static str = "我拥有静态生命周期。";
rust

[!WARNING] 虽然 'static 很实用,但请避免将其作为绕过借用检查器报错的“偷懒偏方”。将本应是临时的引用强行指定为 'static 会在以后的设计中带来更严重的编译麻烦。

总结#

  • 生命周期 是命名范围,用来告知编译器引用在多久范围内保持有效。
  • 它们通过确保借用不会超出所有者的寿命,来预防 悬垂引用。
  • 生命周期省略 规则允许你在多数常见场景下不用手动书写生命周期。
  • 包含引用的结构体必须声明生命周期边界,以保证内存安全。

在下一篇文章中,我们将在这些知识的基础上探索 智能指针(Smart Pointers),学习如何自定义堆上的引用 management。

参考资料#