rust 生命周期
1. 理解生命周期
生命周期 指的是一个变量在内存中从创建到销毁所经历的时间范围(即变量“存活”的代码区域)。在 Rust 中,每个引用(&T 或 &mut T)背后都关联着一个生命周期,用于保证引用的有效性。
我们先从一个简单的例子开始,没有任何复杂引用。
fn main() {
let x = 5; // ------- x 的生命周期开始
let y = &x; // ------- y 的生命周期开始
println!("{}", y); // |
} // ------- 两者同时结束
x从定义开始存活到main函数结束。y是x的引用,它的生命周期不能超过x的生命周期。这里y在x销毁之前就使用完了,完全安全。
1.1 问题场景:函数返回引用(为什么需要生命周期注解?)
现在考虑一个函数,它接收两个字符串切片,并返回较长的那个。直觉写法可能是:
#![allow(unused)]
fn main() {
fn longer(s1: &str, s2: &str) -> &str {
if s1.len() > s2.len() { s1 } else { s2 }
}
}
这段代码无法编译!编译器报错:缺少生命周期注解。
Rust 编译器在检查这个函数时,需要回答一个关键问题:函数返回的引用,到底应该存活多久?(即它指向的数据什么时候会被释放)
- 如果返回
s1,那么返回引用不能比s1活得更久。 - 如果返回
s2,那么返回引用不能比s2活得更久。
但是,函数体内部使用了 if 条件来决定返回哪一个。编译器在编译函数时,不会去模拟运行那个 if,它只知道返回的引用可能是 s1 也可能是 s2。因此,Rust 无法确定返回值的生命周期应该与 s1 绑定,还是与 s2 绑定。
更危险的是,如果调用者这样使用:
#![allow(unused)]
fn main() {
let result;
{
let s1 = String::from("hello");
let s2 = String::from("world");
result = longer(&s1, &s2);
} // 这里 s1 和 s2 都被销毁
println!("{}", result); // 危险!result 已经变成悬垂引用
}
如果一门语言允许这种函数签名在没有任何生命周期约束的情况下通过编译,调用者就可能写出上面这样的悬垂引用:result 指向的内存已经被释放,却仍然被继续使用。在 C++ 中,这类悬垂引用是典型的未定义行为,可能导致程序崩溃或安全漏洞。
Rust 的设计哲学是:绝对不允许任何悬垂引用通过编译。 所以 Rust 拒绝接受没有生命周期注解的 longer 函数,要求程序员显式标注:返回的引用与 s1 和 s2 到底有什么关系(通常是用同一个生命周期参数表示“返回引用的存活时间不得超过 s1 和 s2 中较短的那个”)。这样编译器就能静态验证所有调用是否安全。
加上生命周期注解后的正确版本:
#![allow(unused)] fn main() { fn longer<'a>(s1: &'a str, s2: &'a str) -> &'a str { if s1.len() > s2.len() { s1 } else { s2 } } }这个注解告诉编译器:返回的引用存活时间不超过
s1和s2中生命期较短的那个。如果调用者试图在某个引用失效后继续使用返回值,编译器就会报错。
2. 学会关注变量的生命周期
2.1 有 GC 语言的视角
在 Java、C#、Go 等带有垃圾回收的语言中,你很少需要操心变量的具体存活时间。GC 会跟踪所有活跃的引用,自动回收不再使用的内存。你只管“用引用”,GC 保证你不访问到已释放的内存(虽然仍可能访问到 null,但那不是内存安全问题)。因此,大多数 GC 语言开发者养成了不关心对象生命周期细节的习惯。
2.2 无 GC 语言的思维转变
在 C、C++ 和 Rust 这类无 GC 的语言中,内存的分配和释放完全由程序员管理(或通过所有权系统自动管理)。如果不主动思考生命周期,极容易产生悬垂指针或use-after-free:
- 函数返回了局部变量的指针/引用 → 调用者访问到已被销毁的内存。
- 一个对象被释放后,另一处仍然持有它的指针。
Rust 的所有权/借用系统强制你在编译期证明引用始终有效。因此,写 Rust 代码时,你需要时刻问自己:
这个引用指向的数据,会不会在我使用它之前就被销毁了?
这种思维方式会伴随你编写所有无 GC 语言的代码,也是写出健壮系统程序的基础。Rust 通过生命周期注解,让这种思考显式地体现在代码中,并由编译器帮你验证。
3. 泛型生命周期注解语法
3.1 功能说明
生命周期注解(Lifetime Annotations)并不改变任何变量的实际存活时间,它只是给引用之间的有效范围关系起名字。比如 fn longest<'a>(x: &'a str, y: &'a str) -> &'a str 表示:返回引用不能比 x 和 y 中较短的那个活得更久。编译器根据这些关系进行静态检查。
3.2 基本语法结构
- 生命周期名称以单引号开头,通常用小写字母,如
'a,'b,'ctx。 - 声明:在泛型参数列表中使用
<'a>。 - 使用:标注在引用类型上,如
&'a T(不可变引用)、&'a mut T(可变引用)。
#![allow(unused)]
fn main() {
// 函数上的生命周期注解
fn 函数名<'a>(参数1: &'a 类型, 参数2: &'a 类型) -> &'a 类型 { ... }
// 结构体上的生命周期注解
struct 结构体名<'a> {
字段: &'a 类型,
}
}
3.3 调用示例
#![allow(unused)]
fn main() {
// 函数示例:明确返回值不能比两个参数中较短的那个活得更久
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
// 结构体示例:存储一个引用,要求被引用数据至少和结构体实例一样长
struct Excerpt<'a> {
part: &'a str,
}
// 使用(片段)
let novel = String::from("Call me Ishmael...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let excerpt = Excerpt { part: first_sentence };
}
生命周期省略规则:在常见模式下(如只有一个输入引用,或
&self等),编译器能自动推断,不需要显式写注解。比如fn first_word(s: &str) -> &str实际等价于fn first_word<'a>(s: &'a str) -> &'a str。
4. 几个问题
4.1 什么时候需要显式标注?
Rust 的生命周期省略规则(Lifetime Elision Rules)能覆盖大多数简单情况,但在以下场景中,你必须手动标注生命周期:
-
函数返回引用,且参数中有多个引用编译器无法推断返回的引用到底来自哪个参数,因此必须标注关联关系。
#![allow(unused)] fn main() { // 必须标注:因为有两个输入引用 fn choose<'a>(a: &'a str, b: &'a str, selector: bool) -> &'a str { if selector { a } else { b } } } -
函数返回引用,但参数没有可借出的引用返回的引用不能指向函数内的局部变量,必须来自某个仍然有效的数据源。普通关联函数如果没有引用参数,通常不能返回普通引用,除非返回的是
&'static;方法中的fn get_ref(&self) -> &T则由生命周期省略规则覆盖,返回值默认和self的借用关联。 -
结构体持有引用 任何包含引用字段的结构体定义,都必须为每个引用字段标注生命周期。
#![allow(unused)] fn main() { struct Holder<'a> { data: &'a i32, // 必须标注 } }
4.2 何时使用同一个生命周期参数?
当你希望建立多个引用之间的有效范围关联时,使用相同的生命周期参数。它不是要求所有引用“实际活得一样久”,而是告诉编译器:这些引用在当前函数或类型签名中共享同一个约束,最终可用范围不能超过其中最短的那个。最常见的情况:
- 函数返回的引用来源于多个输入引用之一,且返回后还要继续使用。你要求输出引用同时受多个输入引用约束,这样返回的引用不会超出任何一个输入引用的有效范围。
#![allow(unused)]
fn main() {
// 返回值不能比两个参数中较短的那个活得更久
fn max<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if x > y { x } else { y }
}
}
- 结构体持有多个引用,且这些引用指向同一块数据的不同部分(或多个数据但要求必须同时有效)。通常使用同一个生命周期,表示结构体实例不能活得比任何一个引用所指向的数据更长。
#![allow(unused)]
fn main() {
struct SlicePair<'a> {
first: &'a [u8],
second: &'a [u8],
}
}
4.3 何时使用不同的生命周期参数?
当两个引用彼此独立,没有相互约束时,应该使用不同的生命周期参数。常见场景:
- 函数接收两个独立的引用,返回的值只依赖其中一个,与另一个无关。那么返回值生命周期只需与相关参数一致,另一个参数可以有自己的生命周期。
#![allow(unused)]
fn main() {
// 返回的是 x,与 y 无关;y 可以有自己的生命周期 'b
fn take_first<'a, 'b>(x: &'a str, y: &'b str) -> &'a str {
x
}
}
- 函数执行的操作不涉及返回引用,或者返回不依赖某些引用时,让各引用有独立的生命周期,避免不必要的约束。
#![allow(unused)]
fn main() {
// 只比较长度,不返回引用,两个生命周期可以不同
fn compare_len<'a, 'b>(x: &'a str, y: &'b str) -> bool {
x.len() > y.len()
}
}
- 结构体中有多个引用,但某些引用是独立于其他引用的(例如一个用于内部缓冲区,另一个用于外部数据)。此时分别标注可以更精确地描述生命周期关系。
#![allow(unused)]
fn main() {
struct TwoSources<'a, 'b> {
internal: &'a str,
external: &'b str,
}
}
一句话总结:
- 相同生命周期 → 建立共同约束,返回值或结构体实例不能超过相关引用中最短的有效范围。
- 不同生命周期 → 各引用生命周期无关,可以自由缩短/延长,不互相影响。
5. 综合示例
// 定义一个存放字符串片段的结构体,它的生命周期参数 'a 表示:
// Excerpt 实例不能存活得比它内部引用的原始数据更长
struct Excerpt<'a> {
text: &'a str,
start: usize,
end: usize,
}
impl<'a> Excerpt<'a> {
// 创建一个新的 Excerpt,要求传入的引用至少和结构体一样长
fn new(text: &'a str, start: usize, end: usize) -> Self {
Excerpt { text, start, end }
}
// 返回片段本身(一个引用),生命周期与结构体内的引用一致
fn as_str(&self) -> &'a str {
&self.text[self.start..self.end]
}
}
// 一个函数,接收两个字符串引用,返回其中较长的那个。
// 生命周期注解表明:返回值不能比两个参数中较短的那个活得更久。
fn longer<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
// 外部数据:一个拥有所有权的字符串
let whole = String::from("Rust programming language");
// 创建 Excerpt 结构体,借用 whole 的一部分
let excerpt = Excerpt::new(&whole, 0, 4); // 取 "Rust"
println!("Excerpt: {}", excerpt.as_str()); // 安全,因为 whole 在 excerpt 之后才销毁
// 演示 longer 函数
let s1 = String::from("hello");
let s2 = "world";
let result;
{
let s3 = String::from("rust");
// 注意:result 的生命周期将限定在 s1 和 s3 中较短的那个(即 s3 的作用域内)
result = longer(&s1, &s3);
println!("Longer inside block: {}", result);
} // s3 销毁,result 在这里之后不能再被使用
// 下面这行如果取消注释,会编译错误,因为 result 的生命周期已在上面块结束后失效
// println!("Longer outside: {}", result);
// 正确用法:s2 是字符串字面量,拥有 'static 生命周期(永久存活)
let long = longer(&s1, &s2);
println!("Longer with static: {}", long);
}
5.1 代码讲解
- 结构体
Excerpt<'a>:它存储了一个字符串引用text,以及两个索引。生命周期'a表示text指向的数据必须至少和Excerpt实例活得一样久。Rust 会确保你不会让Excerpt超出text的存活范围。 impl<'a>块:为特定生命周期'a实现方法。new和as_str中的&'a str确保返回的引用与结构体内的引用具有相同的生命周期。longer函数:最常见的生命周期场景:两个输入引用,一个输出引用。注解<'a>将三个引用的生命周期关联起来,表示输出引用的存活时间不超过两个输入引用中存活时间较短的那个。main函数中的演示:excerpt借用whole,在whole被销毁(main 结束)之前使用,安全。- 嵌套作用域中调用
longer,result的生命周期被限制在s3的作用域内,之后无法使用,编译器阻止了潜在悬垂。 - 字符串字面量
"world"的类型是&'static str,生命周期为整个程序运行期间,因此总是安全的。
通过这个例子,你可以看到生命周期注解如何帮助编译器验证引用的有效性,从而杜绝内存错误。
6. 为什么要有显式生命周期注解
在 C++ 中,返回局部变量的引用或指针是非常容易犯的错误:
int& getLocal() {
int x = 42;
return x; // 返回局部变量的引用 – 未定义行为!
}
调用者得到的是一个悬垂引用,指向已被销毁的栈内存。更隐蔽的是,当函数接收多个引用并返回其中一个时,C++ 编译器完全不会检查返回的引用是否有效:
const string& longer(const string& a, const string& b) {
return a.size() > b.size() ? a : b;
}
// 看起来没问题,但调用者可能传入临时对象,导致悬垂。
Rust 的设计目标:将所有内存安全问题消灭在编译期,而不是留到运行时崩溃或未定义行为。
- 显式生命周期注解迫使程序员明确表达引用之间的关系。
- 编译器根据这些关系进行静态分析,拒绝任何可能产生悬垂引用的代码。
- 零运行时开销:注解只用于编译检查,不会影响生成的机器码。