无垃圾回收(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 0 到 4,而 x 只在 0 到 2 之间存活。因为 r 比 x 存活得更久,所以该引用变得无效。
什么是生命周期?#
生命周期是指引用保持有效的范围(作用域)。在大多数情况下,生命周期是隐式的,可以由编译器自动推导。然而,当函数或结构体中引用的来源和相互关系存在歧义时,我们必须进行手动的生命周期标注。
[!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 str和y: &'a str指定了两个输入引用的存活时间至少要与生命周期'a一样长。-> &'a str承诺了返回的引用其存活时间也至少与'a一样长。
在实际使用中,返回值被赋予的生命周期 'a 将等于输入参数 x 和 y 的生命周期中的 较短者。
通俗比喻(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 团队发现,开发者在编写代码时经常重复使用相同的生命周期模式。为了提升编码的便利性,他们将三条确定性的推导规则硬编码到了编译器中(称为 生命周期省略规则):
- 每个作为引用的参数(输入)都会获得自己独立的生命周期参数(例如:
fn foo<'a, 'b>(x: &'a i32, y: &'b i32))。 - 如果正好只有一个输入生命周期参数,该生命周期会被分配给所有的输出引用(例如:
fn foo<'a>(x: &'a i32) -> &'a i32)。 - 如果有多个输入生命周期参数,但其中一个是
&self或&mut self(即它是一个方法),那么self的生命周期会被分配给所有的输出引用。
只有当编译器应用了这些规则后,仍旧无法推断出返回值的生命周期时,才会抛出编译错误,要求你手动标注。
静态生命周期#
Rust 中有一个特殊的生命周期 'static。它表示引用可以在程序的整个运行期间都保持有效。
所有的字符串字面量都拥有 'static 生命周期,因为它们的数据直接被硬编码到了程序的二进制文件中:
let s: &'static str = "我拥有静态生命周期。";rust[!WARNING] 虽然
'static很实用,但请避免将其作为绕过借用检查器报错的“偷懒偏方”。将本应是临时的引用强行指定为'static会在以后的设计中带来更严重的编译麻烦。
总结#
- 生命周期 是命名范围,用来告知编译器引用在多久范围内保持有效。
- 它们通过确保借用不会超出所有者的寿命,来预防 悬垂引用。
- 生命周期省略 规则允许你在多数常见场景下不用手动书写生命周期。
- 包含引用的结构体必须声明生命周期边界,以保证内存安全。
在下一篇文章中,我们将在这些知识的基础上探索 智能指针(Smart Pointers),学习如何自定义堆上的引用 management。