深入浅出生命周期:认识生命周期
很多人学 Rust 第一座大山就是生命周期,甚至因为它直接放弃,所以我打算出一个系列文章讲 Rust 的生命周期,从基础概念到进阶,带你一篇一篇带你吃透它。
生命周期是干什么的
生命周期的核心作用是:让编译器明确引用的存活范围,以及引用之间的生命周期关系,在编译期就杜绝悬垂引用这个内存安全问题。
先从一个最简单的例子开始,Rust 编译器其实自己就能识别垂悬引用:
rust
fn main() {
let borrow;
{
let s = String::from("hello rust");
borrow = &s; // 将 s 的引用赋值给 borrow
} // 这里 s 被销毁(释放内存),borrow 成了悬垂引用
println!("borrow: {}", borrow); // 试图使用已经失效的引用,编译器直接报错
}
这种单作用域内的简单场景,Rust 的借用检查器能轻松推断出引用的存活范围,直接拦截错误。
但是,当涉及到引用跨函数、跨作用域传递时,编译器就无法自动推断引用的生命周期关系了。它无法判断函数返回的引用,到底和哪个入参的生命周期绑定,也就没法判断返回的引用会不会变成垂悬引用。
比如这个 Rust 生命周期最经典的入门示例,直接编译会报错:
rust
// 报错:missing lifetime specifier(缺少生命周期标注)
fn longer_str(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
这个函数逻辑很简单:传入两个字符串切片,返回更长的那个。但编译器不知道:返回的引用,到底是跟着 x 的生命周期走,还是跟着 y 的生命周期走?
这时候,就需要我们通过生命周期标注,手动告诉编译器引用之间的生命周期关系。
生命周期显式标注
基础语法规则
记住这三个核心语法规则,就能上手标注:
- 生命周期参数以单引号 开头,后跟小写字母(社区惯例用
'a、'b,也可以用有意义的名字如'name); - 生命周期参数必须先在
<>中声明,才能在参数和返回值中使用; - 标注位置固定在
&符号之后,类型之前,比如&'a str、&'a mut i32。
函数中的生命周期标注
函数是生命周期标注最基础、最常用的场景,核心目的只有一个:把返回值的生命周期,和入参的生命周期绑定。
我们给上面报错的 longer_str 示例加上正确的生命周期标注:
rust
fn longer_str<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
在函数中,生命周期标注的含义是:在所有被同名生命周期标注的入参和返回值中,返回值的生命周期等于所有入参生命周期的最小值,在这个示例中也就是:
plaintext
返回值的生命周期 = min(x 的生命周期, y 的生命周期)
为什么是"最小值"呢?这里我们通过一个调用 longer_str 函数的示例来更直观的理解,示例如下:
rust
fn longer_str<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let ret;
let s1 = String::from("hello");
let s1 = s1.as_str(); // - s1
{ // |
let s2 = String::from("world!"); // |
let s2 = s2.as_str(); // - s2 // |
ret = longer_str(s1, s2); // | // |
print!("{}", ret); // | // |
} // - // |
} // -
在这个示例中,我模拟编译器标注出了 s1 和 s2 的生命周期,s2 的生命周期更小,所以 'a 被解析为 s2 的生命周期,编译器也就能推断出 ret 的生命周期为 s2 的生命周期。毕竟 longer_str(s1, s2) 有可能返回 s2,如果 ret 离开 s2 的生命周期,就会立即变成悬垂引用。
最后,有条函数标注的铁则需要谨记:Rust 绝对不允许函数返回内部局部变量的引用,哪怕你加了生命周期标注也不行:
rust
// 绝对报错:返回了局部变量 i 的引用
fn get_num<'a>() -> &'a i32 {
let i = 10;
&i
}
原因很简单:函数执行完,局部变量 i 就会被销毁,返回的引用必然是悬垂引用。这也印证了标注的规则:函数返回的引用,生命周期来自入参,唯一的例外是 'static 静态生命周期(后续系列文章会讲)。
结构体中的生命周期标注
只要你的结构体里包含引用类型的字段,就必须给结构体加上生命周期标注。
rust
// 声明生命周期参数'a,标注所有引用字段
struct User<'a> {
name: &'a str,
desc: &'a str,
}
在结构体中,生命周期标注的含义是:结构体实例的生命周期等于它包含的引用字段的生命周期的最小值,在这个示例中也就是:
plaintext
User 实例的生命周期 = min(name 的生命周期, desc 的生命周期)
换句话说,结构体实例必须"死在"它引用的内容之前,避免内部字段变成悬垂引用。我们看一个反例:
rust
struct User<'a> {
name: &'a str,
desc: &'a str,
}
fn main() {
let user;
let name = String::from("张三");
{
let desc = String::from("Rust 开发者");
user = User { name: &name, desc: &desc };
}
// 报错:desc 已经销毁,user 内部的引用成了垂悬引用
println!("{}: {}", user.name, user.desc);
}
方法中的生命周期标注
给带生命周期的结构体实现方法时,必须在 impl 关键字后声明和结构体一致的生命周期参数,才能在方法中使用。
rust
impl<'a> User<'a> {
// 入参和返回值的生命周期绑定,这里的 Self 等价于 User<'a>
fn new(name: &'a str, desc: &'a str) -> Self {
User { name, desc }
}
// 这里没有标注生命周期,编译器会自动补全(后面省略规则会讲)
fn get_name(&self) -> &str {
self.name
}
}
这里有个关键细节:impl<'a> User<'a> 中,第一个 'a 是生命周期参数的声明,第二个 'a 是给结构体绑定生命周期,二者必须对应。
Trait 中的生命周期标注
Trait 中的生命周期标注,核心作用是把 Trait 方法的返回值生命周期,和实现该 Trait 的类型的生命周期绑定。对于入门阶段,我们只需要掌握最基础的写法即可:
rust
// Trait 声明生命周期参数 'l,绑定返回值的生命周期
trait GetName<'l> {
fn get_name(&self) -> &'l str;
}
// 给 User 结构体实现 Trait,用 'a 统一绑定二者的生命周期
impl<'a> GetName<'a> for User<'a> {
fn get_name(&self) -> &'a str {
self.name
}
}
生命周期省略规则
看到这里你可能会问:我平时写 Rust 代码,很多时候根本没写生命周期标注,也能编译通过,这是为什么?
答案是:Rust 设计了生命周期省略规则(Lifetime Elision Rules),对于常见的简单场景,编译器会自动帮你补全生命周期,不用手动标注。
在讲规则之前,先记住两个核心概念:
- 输入生命周期:函数/方法入参中引用的生命周期
- 输出生命周期:函数/方法返回值中引用的生命周期
省略规则一共三条,按顺序执行,仅适用于函数和方法,结构体的生命周期不能省略。
规则一:每个没有标注的引用入参,自动分配一个独立的生命周期
编译器会给函数中每一个没有标注的引用参数,分配一个互不相同的生命周期参数。
rust
// 你写的代码
fn eq_str(x: &str, y: &str) -> bool {
x == y
}
// 编译器自动补全后的代码
fn eq_str<'a, 'b>(x: &'a str, y: &'b str) -> bool {
x == y
}
这个函数没有返回引用,所以补全输入生命周期就够了,完全不用手动标注。
规则二:如果只有一个输入生命周期,自动把它分配给所有输出生命周期
如果函数只有一个引用入参,编译器会默认返回值的生命周期和这个入参完全一致,这是日常开发中最常用的规则。
rust
// 你写的代码
fn first_char(s: &str) -> &str {
&s[0..1]
}
// 编译器自动补全后的代码
fn first_char<'a>(s: &'a str) -> &'a str {
&s[0..1]
}
规则三:如果有多个输入生命周期,但第一个参数是 & self/&mut self,自动把 self 的生命周期分配给输出生命周期
这条规则是专门为方法设计的,方法的返回值,绝大多数情况都来自 self 的内部字段,所以编译器会自动把返回值的生命周期和 self 绑定。
rust
// 你写的代码
impl<'a> User<'a> {
fn get_name(&self) -> &str {
self.name
}
}
// 编译器自动补全后的代码
运行
impl<'a> User<'a> {
fn get_name<'b>(&'b self) -> &'b str {
self.name
}
}
什么时候必须手动标注
三条省略规则执行完后,如果输出生命周期仍然无法确定,编译器就会报错,这时候必须手动标注生命周期。
总结
本篇文章,我们搞懂了生命周期到底是用来解决什么问题的,学会了显式标注的场景与规则,也掌握了生命周期的省略规则,知道什么时候可以省略,什么时候必须手动标注。
这只是我们生命周期系列的开篇,后续文章里,我们会继续讲解生命周期的进阶特性,比如再借用、静态生命周期,生命周期约束、高阶生命周期等等,一步步带你吃透 Rust 生命周期,彻底攻克这个入门路上的"拦路虎",敬请期待。