深入理解 Rust 生命周期:从悬垂引用到生命周期标注
前言
在学习 Rust 的过程中,所有权(Ownership)和借用(Borrowing)是两个绕不开的核心概念。
通过前面的学习,我们已经知道:
- 所有权决定谁拥有数据。
- 借用允许我们在不转移所有权的情况下访问数据。
- 共享引用
&T允许多个读取者,可变引用&mut T要求独占访问。
但是,当我们进一步学习函数、结构体时,会遇到一个新的问题:
如果一个引用指向的数据已经被销毁了,但是程序仍然试图通过这个引用访问数据,该怎么办?
Rust 通过生命周期(Lifetime)机制解决这个问题。
例如,下面的代码无法通过编译:
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("{r}");
}
为什么编译器会拒绝这段代码?
变量 r 引用了 x,但是 x 在内部作用域结束时就已经被销毁了。此时,如果允许通过 r 访问 x,就可能产生悬垂引用(Dangling Reference)。
为了避免这种情况,Rust 引入了生命周期机制,在编译阶段检查引用是否始终指向有效的数据。
生命周期是 Rust 类型系统的重要组成部分。它并不是一套独立于所有权和借用之外的机制,而是对引用有效性及引用之间关系的进一步描述。
本文将从最基本的问题出发,循序渐进地理解 Rust 生命周期。
一、为什么需要生命周期?
要理解生命周期,首先需要理解一个问题:
引用为什么会失效?
1.1 引用与被引用数据的关系
先看一段正常的代码:
fn main() {
let x = 5;
let r = &x;
println!("{r}");
}
这里:
x拥有数据5。r是一个引用,借用了x。r通过引用访问x所拥有的数据。
可以将它们的关系表示为:
x:拥有数据
│
▼
5
r:引用 x
只要 x 仍然有效,r 就可以安全地访问它。
但是,如果 x 已经被销毁,而 r 仍然试图访问它,就会产生问题。
1.2 什么是悬垂引用?
来看前面的错误代码:
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("{r}");
}
我们按照执行顺序分析。
第一步:声明引用变量
let r;
声明变量 r,暂时还没有给它赋值。
第二步:进入内部作用域
{
let x = 5;
r = &x;
}
创建变量 x,然后让 r 引用 x。
此时引用关系是:
r ──────> x = 5
第三步:内部作用域结束
}
变量 x 离开作用域,其值被销毁。
但是,外部的 r 仍然试图引用 x。
r ──────> x = 5
↑
数据已经被销毁
如果 Rust 允许这样的代码,就可能发生悬垂引用。
因此,编译器拒绝编译。
1.3 Rust 如何判断引用是否合法?
Rust 编译器通过借用检查器(Borrow Checker)分析引用与被引用数据之间的关系。
我们可以给两个变量的有效范围分别起一个名字:
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // --+-- 'b |
r = &x; // | |
} // --+ |
// |
println!("{r}"); // |
} // ---------+
这里:
'a表示r需要有效的范围。'b表示x所拥有的数据有效的范围。
显然,'b 比 'a 短。
当内部作用域结束时,x 已经被销毁,但是 r 仍然需要使用。
这就违反了引用安全规则。
我们可以得到一个非常重要的结论:
被引用的数据必须在引用的有效使用期间始终存在。
这就是 Rust 引入生命周期机制的根本原因。
1.4 什么是生命周期?
生命周期(Lifetime)可以简单理解为:
引用能够安全使用的有效期间。
在 Rust 中,每一个引用都有生命周期,只不过大多数情况下,编译器可以自动推断,不需要程序员显式标注。
例如:
fn main() {
let x = 10;
let r = &x;
println!("{r}");
}
这里 r 的借用不会超过 x 所拥有的数据的有效范围,因此是合法的。
需要特别注意:
生命周期不是让数据活得更久,而是确保引用不会在被引用数据失效后继续使用。
另外,生命周期也不完全等于变量的词法作用域。
例如:
fn main() {
let mut s = String::from("hello");
let r = &s;
println!("{r}");
s.push_str(" world");
}
这段代码可以编译。
虽然 r 仍然处于 main 的大括号作用域中,但它已经完成最后一次使用,因此共享借用可以结束。
这就是非词法生命周期(Non-Lexical Lifetimes,NLL)机制所支持的行为。
因此,需要区分:
- 变量的作用域:变量名可以在哪些位置使用。
- 借用的有效范围:某次借用需要保持有效的程序区域。
现代 Rust 编译器会根据实际使用情况分析借用的有效范围,而不是简单地让借用一直持续到变量所在的大括号结束。
二、生命周期标注:告诉编译器引用之间的关系
前面我们知道了为什么需要生命周期。
接下来需要理解一个新的问题:
既然每个引用都有生命周期,为什么有时候编译器能够自动推断,有时候却要求我们手动标注?
要理解这个问题,需要先认识编译器的能力边界。
2.1 编译器能够追踪引用关系,但不一定知道返回引用来自哪里
考虑下面的代码:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
这个函数的逻辑非常简单:
- 如果
x更长,就返回x。 - 否则,返回
y。
但是,这段代码无法编译。
为什么?
因为函数返回的是一个引用,而这个引用可能来自 x,也可能来自 y。
对于编译器而言,它需要知道:
返回值的生命周期究竟与哪个输入参数有关?
假设:
x引用的数据可以有效 10 秒。y引用的数据只能有效 5 秒。
那么,函数究竟返回哪个引用?
如果返回 x,返回值可以在较长的期间内保持有效。
如果返回 y,返回值最多只能在较短的期间内保持有效。
由于两个分支都可能执行,编译器不能随意假定返回值来自其中一个参数。
因此,我们需要明确告诉编译器:返回引用与输入引用之间存在什么样的生命周期关系。
这就是生命周期标注的作用。
2.2 生命周期标注的语法
Rust 使用 'a 这样的形式表示生命周期参数。
例如:
&i32
&'a i32
&'a mut i32
它们分别表示:
| 写法 | 含义 |
|---|---|
&i32 |
对 i32 的共享引用,生命周期由编译器推断 |
&'a i32 |
生命周期满足 'a 约束的共享引用 |
&'a mut i32 |
生命周期满足 'a 约束的可变引用 |
生命周期参数的名称通常以单引号开头,使用小写字母。
例如:
'a
'b
'c
它们就像泛型类型参数 T 一样,是编译期使用的参数。
例如:
fn foo<T>(x: T) {
}
这里 T 是类型参数。
而:
fn foo<'a>(x: &'a str) {
}
这里 'a 是生命周期参数。
2.3 生命周期标注究竟意味着什么?
这里有一句非常重要的话:
生命周期标注不会改变任何引用实际的生命周期,它只是告诉编译器不同引用之间应该满足什么样的生命周期关系。
例如:
fn foo<'a>(x: &'a str) {
}
这并不是要求 x 指向的数据必须活到程序结束,也不是强行延长了数据的生命周期。
它只是表示:调用这个函数时,传入的引用必须满足生命周期 'a 的约束。
可以把生命周期标注理解成一种函数接口契约。
函数通过生命周期参数告诉编译器:
- 输入引用需要满足什么条件。
- 输出引用与输入引用有什么关系。
- 哪些引用可以在同一个生命周期范围内安全使用。
这与泛型类型参数的思想很相似。
三、函数中的生命周期:彻底理解 longest
longest 函数是 Rust 生命周期最经典的例子。
理解它,就基本掌握了函数生命周期标注的核心思想。
3.1 为 longest 添加生命周期参数
原始代码:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
现在,我们为它添加生命周期参数:
fn longest<'a>(
x: &'a str,
y: &'a str
) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
我们逐个分析。
第一处:
<'a>
声明生命周期参数 'a。
第二处:
x: &'a str,
y: &'a str
表示两个输入引用都必须满足生命周期 'a 的约束。
第三处:
-> &'a str
表示返回的引用也必须满足生命周期 'a 的约束。
因此,整个函数签名表达的是:
输入 x:&'a str
\
\
返回值:&'a str
/
/
输入 y:&'a str
也就是说,返回引用与两个输入引用的共同有效期间相关联。
3.2 两个参数使用相同的生命周期,意味着它们活得一样久吗?
这是初学者最容易误解的地方。
答案是:不意味着它们原本的实际生命周期完全相等。
例如:
x原本可以有效 10 秒。y原本可以有效 5 秒。
它们完全可以共同满足一个 5 秒的生命周期 'a。
可以理解为:
x 的有效范围:
|-------------------- 10 秒 --------------------|
y 的有效范围:
|---------- 5 秒 ----------|
共同有效范围:
|---------- 'a ----------|
生命周期 'a 表示编译器为这次调用选取的一个共同有效范围。
因此,两个参数可以拥有不同的实际生命周期,只要它们都满足 'a 的要求即可。
3.3 为什么返回值受到较短生命周期的限制?
来看一个完整例子:
fn longest<'a>(
x: &'a str,
y: &'a str
) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(
string1.as_str(),
string2.as_str()
);
println!("{result}");
}
}
这段代码可以正常编译。
因为:
string1在外层作用域中。string2在内层作用域中。result的有效使用范围没有超过两者的共同有效范围。
现在,我们把 println! 移动到内部作用域之外:
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(
string1.as_str(),
string2.as_str()
);
}
println!("{result}");
}
这次无法编译。
为什么?
因为内部作用域结束时,string2 已经被销毁。
但 result 仍然需要使用。
此时,返回引用可能指向已经失效的数据。
因此,编译器拒绝这段代码。
这里需要特别理解:
虽然我们从字符串长度上能够判断,string1 更长,所以函数实际会返回 string1。
但是,编译器不会依赖运行时字符串长度来确定函数返回引用的生命周期。
因为函数签名允许返回 x,也允许返回 y。
所以,编译器必须保证无论返回哪一个引用,返回值都不会超出两个输入引用的共同有效范围。
这就是为什么返回值的生命周期会受到较短输入生命周期的限制。
需要注意,这是一种安全的保守约束,不代表函数每次实际返回的那个字符串一定只存活这么久。
3.4 返回引用不一定与所有参数都有关联
再看一个例子:
fn first<'a>(
x: &'a str,
y: &str
) -> &'a str {
x
}
这里为什么只有 x 标注了 'a?
因为函数始终返回 x。
返回值只与 x 的生命周期有关,而与 y 没有关系。
因此,没有必要把 y 也关联到 'a。
这说明:
生命周期标注不是把所有参数强行绑定在一起,而是准确描述输入引用和输出引用之间实际存在的关系。
3.5 为什么不能返回函数内部创建的局部变量引用?
例如:
fn create_string() -> &str {
let s = String::from("hello");
&s
}
这段代码无法编译。
因为 s 是函数内部创建的局部变量,函数结束时就会被销毁。
如果返回它的引用,调用者就会得到一个指向已失效数据的引用。
即使添加生命周期标注也无法解决:
fn create_string<'a>() -> &'a str {
let s = String::from("hello");
&s
}
仍然无法编译。
原因是生命周期标注只是描述引用关系,并不能延长局部变量的生命周期。
如果确实需要返回字符串,应该返回拥有所有权的 String:
fn create_string() -> String {
String::from("hello")
}
这样调用者获得字符串的所有权,就不存在悬垂引用的问题。
四、结构体中的生命周期
理解函数生命周期后,我们再来看结构体。
你之前遇到过这样的代码:
struct User {
active: bool,
username: &str,
email: &str,
sign_in_count: u64,
}
编译器会提示:
missing lifetime specifier
为什么?
因为结构体中保存了引用。
4.1 为什么结构体保存引用需要生命周期参数?
考虑:
let username = String::from("Ferris");
let user = User {
username: &username,
};
这里 user.username 并不拥有字符串数据,只是借用了 username 所拥有的数据。
因此,编译器必须保证:
User 实例不能在它所借用的数据失效后,继续使用这些引用。
为了明确这个关系,需要为结构体引入生命周期参数。
4.2 正确写法
struct User<'a> {
active: bool,
username: &'a str,
email: &'a str,
sign_in_count: u64,
}
这里:
struct User<'a>
表示结构体具有一个生命周期参数 'a。
而:
username: &'a str,
email: &'a str,
表示两个引用字段都必须满足 'a 的生命周期约束。
例如:
fn main() {
let username = String::from("Ferris");
let email = String::from("ferris@example.com");
let user = User {
active: true,
username: &username,
email: &email,
sign_in_count: 1,
};
println!("{}", user.username);
}
struct User<'a> {
active: bool,
username: &'a str,
email: &'a str,
sign_in_count: u64,
}
这段代码可以编译。
因为 username 和 email 所拥有的字符串数据,在 user 使用期间一直有效。
需要注意,两个字段虽然都标注为 'a,但不代表它们原本的实际生命周期必须完全相同。它们只需要都满足 'a 的要求。
4.3 如果两个字段需要不同的生命周期呢?
也可以这样写:
struct User<'a, 'b> {
username: &'a str,
email: &'b str,
}
这里:
username使用生命周期'a。email使用生命周期'b。
两个字段可以具有不同的生命周期约束。
所以,生命周期参数并不是越多越好。
应该根据实际的引用关系决定是否需要使用相同或不同的生命周期参数。
4.4 结构体不一定需要生命周期
如果结构体直接拥有数据,就不需要为这些字段声明生命周期。
例如:
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}
这里的 username 和 email 都是 String。
结构体自己拥有字符串数据,不需要依赖外部变量提供数据。
因此不需要生命周期参数。
可以记住:
String:拥有字符串数据,通常不需要生命周期标注。&str:借用外部字符串数据,结构体需要声明生命周期参数。
五、方法中的生命周期与 impl
在实际 Rust 开发中,我们经常需要为结构体实现方法。
如果结构体本身带有生命周期参数,那么 impl 中也需要正确声明和使用它。
例如:
struct ImportantExcerpt<'a> {
part: &'a str,
}
这里 ImportantExcerpt 保存了一个生命周期为 'a 的字符串引用。
现在为它实现方法:
impl<'a> ImportantExcerpt<'a> {
fn get_part(&self) -> &str {
self.part
}
}
为什么这里的 &self 和返回值 &str 不需要显式标注生命周期?
因为 Rust 提供了生命周期省略规则。
对于方法,如果存在 &self 或 &mut self,返回引用会关联到 self 的借用生命周期。
上面的代码可以理解为:
impl<'a> ImportantExcerpt<'a> {
fn get_part<'b>(&'b self) -> &'b str {
self.part
}
}
这里有两个不同的生命周期:
'a:结构体字段part所保存引用的生命周期。'b:当前这次对self的借用生命周期。
为什么返回值使用 'b,而不是直接使用 'a?
因为返回的引用不应该在 self 不再有效时继续使用。
因此,返回引用的有效范围被限制在当前对 self 的借用范围内。
这也是生命周期省略规则在方法中的典型应用。
六、生命周期省略规则
到这里,你可能会产生一个疑问:
既然 Rust 中的每个引用都有生命周期,为什么下面的函数没有写任何生命周期参数,却能正常编译?
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
原因是 Rust 提供了生命周期省略规则(Lifetime Elision Rules)。
编译器会根据一些确定的模式自动推断生命周期。
一共有三条规则。
6.1 第一条规则:每个输入引用都有独立的生命周期
例如:
fn foo(x: &i32) {
}
相当于:
fn foo<'a>(x: &'a i32) {
}
如果有两个引用参数:
fn foo(x: &i32, y: &i32) {
}
则相当于:
fn foo<'a, 'b>(
x: &'a i32,
y: &'b i32
) {
}
注意:每个输入引用默认分配独立的生命周期参数。
编译器不会因为两个参数都是引用,就认为它们的生命周期相同。
6.2 第二条规则:只有一个输入生命周期时,输出引用使用它
例如:
fn first_word(s: &str) -> &str {
}
只有一个输入引用。
因此,编译器可以推断返回引用与这个输入引用相关联。
相当于:
fn first_word<'a>(
s: &'a str
) -> &'a str {
}
因此不需要手动标注。
6.3 第三条规则:方法中的输出引用使用 self 的生命周期
例如:
struct User<'a> {
username: &'a str,
}
impl<'a> User<'a> {
fn get_username(&self) -> &str {
self.username
}
}
这里虽然还有结构体自身的生命周期参数 'a,但方法的返回引用仍然会关联到当前 &self 的借用生命周期。
这也是第三条规则。
6.4 为什么 longest 不能省略生命周期?
现在回到:
fn longest(x: &str, y: &str) -> &str {
}
我们按照三条规则分析。
第一条规则:
fn longest<'a, 'b>(
x: &'a str,
y: &'b str
) -> &str
两个输入引用分别获得不同的生命周期。
第二条规则:
不适用,因为存在两个输入生命周期。
第三条规则:
不适用,因为这是普通函数,不是拥有 self 参数的方法。
因此,编译器仍然不知道返回引用应该关联到哪个输入生命周期。
于是报错。
这就是为什么 longest 必须显式标注生命周期。
6.5 三条规则总结
| 规则 | 具体内容 |
|---|---|
| 第一条 | 每个输入引用都有独立的生命周期 |
| 第二条 | 只有一个输入生命周期时,所有输出引用使用该生命周期 |
| 第三条 | 方法中存在 &self 或 &mut self 时,输出引用使用 self 的借用生命周期 |
需要注意,生命周期省略不是编译器可以随意猜测,而是根据确定的规则进行推断。
如果应用完这些规则仍然存在歧义,编译器就会要求程序员显式标注。
七、生命周期约束:描述生命周期之间的长短关系
前面我们主要使用:
'a
现在进一步学习两个生命周期之间的关系。
Rust 使用:
'a: 'b
表示生命周期 'a 至少和 'b 一样长。
也就是:
'a outlives 'b。
7.1 为什么需要生命周期约束?
假设:
- 数据 A 可以被引用 10 秒。
- 数据 B 只能被引用 5 秒。
那么,A 的引用可以被缩短到 5 秒使用。
但 B 的引用不能直接承诺可以安全使用 10 秒。
这就是生命周期长短关系的基本意义。
7.2 'a: 'b 的例子
struct DoubleRef<'a, 'b: 'a> {
r: &'a i32,
s: &'b i32,
}
这里:
'b: 'a
表示 'b 至少和 'a 一样长。
因此:
r的引用满足生命周期'a。s的引用满足生命周期'b。'b必须至少和'a一样长。
可以理解为:
'b:--------------------------------
'a: ----------------
为什么这样的约束有意义?
因为较长生命周期的引用可以在适当情况下被缩短为较短生命周期的引用。
反过来则不行。
7.3 T: 'a 又是什么意思?
除了生命周期之间的约束,还存在类型与生命周期之间的约束:
T: 'a
它表示类型 T 必须满足生命周期 'a 的要求。
更准确地说,如果 T 内部包含引用,那么这些引用必须至少在 'a 期间有效。
例如:
struct Ref<'a, T: 'a> {
value: &'a T,
}
这里要求 T 满足 'a 的生命周期约束。
如果 T 本身是一个引用类型,例如:
T = &'b str
那么要满足 T: 'a,就要求:
'b: 'a
也就是内部引用必须至少活得和 'a 一样久。
如果 T 是 String 这种拥有数据的类型,则它不包含借用外部数据的引用,可以满足任意 'a。
现代 Rust 编译器可以在很多类型定义中自动推导这类必要约束,因此不需要像早期版本那样到处手动书写 T: 'a。
7.4 生命周期约束与泛型 Trait Bound 可以结合使用
例如:
use std::fmt::Display;
fn longest_with_announcement<'a, T>(
x: &'a str,
y: &'a str,
announcement: T
) -> &'a str
where
T: Display,
{
println!("Announcement: {}", announcement);
if x.len() > y.len() {
x
} else {
y
}
}
这里同时使用了:
- 生命周期泛型参数
'a。 - 类型泛型参数
T。 - Trait Bound:
T: Display。
这说明生命周期参数与普通类型泛型可以协同工作。
八、特殊生命周期:'static
在学习 Rust 的过程中,你经常会遇到:
&'static str
以及:
T: 'static
虽然它们都包含 'static,但含义并不完全相同。
8.1 &'static:引用指向的数据在整个程序期间有效
最典型的例子就是字符串字面量:
let s: &'static str = "hello";
为什么字符串字面量可以使用 'static?
因为字符串字面量的数据被存储在程序的二进制文件中,在程序运行期间一直有效。
所以,字符串字面量天然拥有 'static 生命周期。
例如:
fn print_name(name: &'static str) {
println!("{name}");
}
fn main() {
print_name("Ferris");
}
这段代码可以正常编译。
但是,有一个细节必须注意:
'static 描述的是引用所指向的数据能够存活多久,而不是持有这个引用的变量本身能够存活多久。
例如:
fn main() {
{
let s: &'static str = "hello";
println!("{s}");
}
// s 在这里已经不能使用
}
虽然 "hello" 的数据具有 'static 生命周期,但是变量 s 本身仍然受到内部作用域的限制。
8.2 T: 'static:类型满足静态生命周期约束
再来看:
fn print_it<T: 'static>(value: T) {
println!("value");
}
这里的:
T: 'static
表示类型 T 必须满足 'static 约束。
准确地说,T 不能包含生命周期短于 'static 的借用。
例如,String 满足这个约束,因为它拥有自己的字符串数据。
fn print_it<T: 'static>(value: T) {
println!("value");
}
fn main() {
let s = String::from("hello");
print_it(s);
}
这段代码可以正常编译。
虽然 s 最终会被销毁,但 String 这个类型本身满足 'static 约束。
再看一个不同的例子:
fn print_it<T: 'static>(value: T) {
println!("value");
}
fn main() {
let x = 10;
print_it(&x);
}
这段代码无法编译。
因为这里传入的 T 实际上是:
&i32
而这个引用指向局部变量 x,其生命周期不是 'static。
因此,T 不满足 'static 约束。
8.3 &'static 和 T: 'static 的区别
| 写法 | 含义 |
|---|---|
&'static str |
引用指向的数据在整个程序期间有效 |
T: 'static |
类型 T 不包含生命周期短于 'static 的借用 |
String: 'static |
String 自己拥有数据,满足该类型约束 |
&'static str: 'static |
引用指向的数据具有 'static 生命周期 |
特别强调:
T: 'static 不意味着 T 的实例必须一直存活到程序结束。
它描述的是类型中可能包含的引用所必须满足的生命周期约束。
这一点在 Rust 多线程、异步编程以及闭包中尤其重要。
九、进阶知识:高阶生命周期与无界生命周期
前面的内容已经覆盖了日常 Rust 开发中最常见的生命周期知识。
如果继续阅读 Rust 语言圣经的进阶章节,还会遇到一些更复杂的概念。
这里做一个简要介绍,帮助建立完整的知识地图。
9.1 高阶生命周期约束(HRTB)
HRTB 的全称是 Higher-Ranked Trait Bounds。
它的典型语法是:
for<'a>
先看一个例子:
fn call_with_any_lifetime<F>(f: F)
where
F: for<'a> Fn(&'a str),
{
let s = String::from("hello");
f(&s);
}
这里:
for<'a> Fn(&'a str)
表示传入的函数或闭包必须能够接受任意生命周期的字符串引用。
为什么需要这样的语法?
因为有时候,我们并不希望一个函数只接受某个固定生命周期的引用,而是希望它能够处理不同生命周期的引用。
例如,某次调用传入一个较短生命周期的引用,另一次调用传入一个较长生命周期的引用。
for<'a> 就可以表达这种要求。
可以简单理解为:
- 普通
'a:当前泛型签名中使用的生命周期参数。 for<'a>:要求某个类型或函数对任意'a都满足约束。
HRTB 在闭包、Trait、函数指针等高级场景中比较常见,初学阶段了解概念即可。
9.2 无界生命周期(Unbound Lifetime)
无界生命周期通常出现在 unsafe 代码中。
例如:
unsafe fn from_ptr<'a, T>(ptr: *const T) -> &'a T {
unsafe {
&*ptr
}
}
这里,原始指针 *const T 本身不携带生命周期。
通过 unsafe 解引用后,程序员却可以为生成的引用声明一个生命周期 'a。
这个生命周期没有从输入参数中获得明确约束,因此属于无界生命周期。
这类代码非常危险。
因为调用者可能传入无效指针,或者要求返回的引用存活得比实际数据更久。
因此,实际开发中应该谨慎处理裸指针和 unsafe,绝不能通过随意添加生命周期参数来绕过借用检查器。
生命周期标注不是让不安全代码变安全的魔法。
它只是描述关系,真正的内存安全仍然需要程序员保证。
十、生命周期与 NLL:两个容易混淆的概念
在学习生命周期时,很多人会把生命周期标注与 NLL 混为一谈。
实际上,它们解决的问题相关,但不是同一件事。
10.1 NLL 解决借用何时结束的问题
例如:
fn main() {
let mut s = String::from("hello");
let r = &s;
println!("{r}");
s.push_str(" world");
}
r 在 println! 之后不再使用,因此共享借用可以结束。
NLL 允许编译器根据实际使用情况确定借用的有效范围。
10.2 生命周期标注描述引用之间的关系
例如:
fn longest<'a>(
x: &'a str,
y: &'a str
) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
这里的 'a 描述的是:
输入引用与输出引用之间的生命周期约束。
它是函数签名的一部分。
10.3 两者的区别
| 对比 | NLL | 生命周期标注 |
|---|---|---|
| 主要解决的问题 | 某次借用什么时候结束 | 不同引用之间有什么生命周期关系 |
| 主要出现的位置 | 代码中的借用分析 | 函数签名、结构体、Trait 等 |
| 是否需要手动书写 | 不需要 | 有歧义时需要 |
| 是否在运行时执行 | 否 | 否 |
两者都是 Rust 在编译阶段保证引用安全的重要机制。
十一、生命周期知识体系总结
经过前面的学习,我们已经建立了比较完整的生命周期知识体系。
可以把它分为三个层次。
第一层:生命周期基础
这是必须掌握的内容:
- 生命周期的作用:防止悬垂引用。
- 生命周期标注:描述引用之间的关系。
- 函数中的生命周期:理解
longest。 - 结构体中的生命周期:理解
User<'a>。 - 生命周期省略规则:掌握三条规则。
- NLL:理解借用可以在最后一次使用后结束。
第二层:常见生命周期约束
进一步掌握:
'a: 'b:生命周期之间的长短关系。T: 'a:类型必须满足生命周期约束。&'static T:引用指向的数据具有静态生命周期。T: 'static:类型满足静态生命周期约束。- 生命周期与泛型、Trait Bound 的结合。
第三层:高级生命周期
了解即可,后续按需深入:
- HRTB:高阶生命周期约束。
- 无界生命周期:常见于 unsafe 裸指针操作。
- 生命周期与型变(Variance)。
- 更复杂的闭包、Trait Object 和智能指针生命周期问题。
十二、最后:建立一套真正清晰的心智模型
学习生命周期,最容易犯的错误就是一上来就记忆大量 'a、'b、'static 等语法。
实际上,生命周期并不是一套孤立的语法规则,而是所有权与借用机制的自然延伸。
我们可以用三个问题理解 Rust 的内存安全体系:
第一个问题:谁拥有数据?
这是所有权(Ownership)解决的问题。
每份数据有自己的所有者,所有者负责管理数据的释放。
第二个问题:谁可以访问数据?
这是借用(Borrowing)解决的问题。
共享引用允许多个读取者,可变引用要求独占访问。
第三个问题:引用能够安全使用多久?
这是生命周期(Lifetime)解决的问题。
引用所指向的数据必须在引用有效使用期间保持存在,而生命周期标注帮助编译器理解不同引用之间的关系。
最终,可以将三者总结为:
Rust 内存安全机制
│
┌──────────┼──────────┐
│ │ │
▼ ▼ ▼
所有权 借用 生命周期
Ownership Borrowing Lifetime
│ │ │
▼ ▼ ▼
谁拥有数据 谁能访问数据 引用能用多久
│ │ │
└──────────┼──────────┘
│
▼
内存安全
因此,面对一段包含生命周期的 Rust 代码时,可以依次思考:
- 这份数据由谁拥有?
- 哪些地方正在借用它?
- 引用之间是否存在生命周期关联?
- 被引用的数据能否覆盖引用的有效使用期间?
只要能够回答这些问题,生命周期就不再是一堆难以理解的特殊符号。
最后,记住这句话:
生命周期不是让数据活得更久,而是让编译器知道引用能够安全地使用多久,以及不同引用之间存在什么样的关系。
理解了这一点,longest<'a>、User<'a>、'a: 'b、&'static str 和 T: 'static 就不再是毫无关联的语法,而是围绕同一个目标展开的不同工具:保证引用始终有效。

浙公网安备 33010602011771号