你所不了解的 Rust 生命周期
Rust - 生命周期
译者:韩玄亮(一个热爱开源,喜欢 Rust 的 go开发者)
介绍
对于很多 Rust 的初学者来说,生命周期 (lifetime
) 是一个很难掌握的概念。我也为此挣扎了一段时间,才开始明白它们对 Rust 编译器执行其职责 (move/borrow) 是多么重要。lifetime
本身并不难。只是它们是看似新颖的结构,以至大多数程序员都没在其他语言中见过它。更糟糕的是,人们过多地使用 “lifetime” 这个词来谈论很多密切相关的问题。在本文中,我将把这些想法分开,这样做是为了给你提供清晰地思考 lifetime
的工具。
目的
在讨论细节之前,让我们先了解一下为什么需要生命周期。它们的目的是什么?生命周期可以帮助编译器执行一个简单的规则: 任何引用本身都不能比它引用的对象存活地更久。换句话说,生命周期帮助编译器消除悬空指针错误(注:也就是说在引用的情况下才会出现生命周期标注)。
在下面的例子中你将看到,编译器通过分析相关变量的生命周期来实现这一点。如果引用的 lifetime小于被引用的 lifetime,编译成功,否则编译失败。
“lifetime”
生命周期如此令人困惑的部分原因是在 Rust 的大部分文章中,生命周期这个词被松散地用于指代三种不同的东西:
- 变量的实际生命周期
- 生命周期约束
- 生命周期注释
下面让我们一个一个地来谈谈。
variables 生命周期
代码中变量之间的交互模式对它们的 lifetime 产生了一些限制。例如,在下面的代码中:x = &y;
这一行添加了一个约束,即:x 的 lifetime 应该包含在 y 的 lifetime 内 ( x ≤ y):
//error:`y` does not live long enough
{
let x: &Vec<i32>;
{
let y =Vec::new();//----+
// | y's lifetime
// |
x = &y;//----------------|--------------+
// | |
}// <------------------------+ | x's lifetime
println!("x's length is {}", x.len());// |
}// <-------------------------------------------+
如果没有添加这个约束,x 就会在 println!
代码块中访问无效的内存。因为 x 是对 y 的引用,它将在前一行被销毁。
需要注意的是:约束不会改变实际的生存期 —— 例如,x 的 lifetime 实际上仍然会扩展到外部块的末尾 —— lifetime 只是编译器用来禁止悬空引用的工具。在上面的例子中,实际的生存期不满足约束:x 的 lifetime 已经超出了 y 的 lifetime。因此,这段代码无法编译。
生命周期注释
如上一节所示,很多时候编译器会(自动)生成所有的 lifetime 约束。但是随着代码变得越来越复杂,编译器会要求开发者手动添加约束。程序员通过生命周期注释来实现这一点。例如,在下面的代码中,编译器需要知道 print_ret()
返回的引用是借用了 s1 还是 s2,所以编译器要求程序员显式地添加这个约束:
// error:missing lifetime specifier
// this function's return type contains a borrowed value,
// but the signature does not say whether it is borrowed from `s1` or `s2`
fn print_ret(s1: &str,s2: &str) -> &str{
println!("s1 is {}", s1);
s2
}
fn main() {
let some_str:String= "Some string".to_string();
let other_str:String= "Other string".to_string();
let s1 = print_ret(&some_str, &other_str);
}
📒:
如果您想知道为什么编译器不能看到输出引用是从 s2 中借来的,可以看看这个回答:here。要查看编译器何时可以省略生命期注释,请参阅下面的 lifetime 省略部分。
然后,开发者用 'a'
标记 s2 和返回的引用,用来告诉编译器,返回值是从 s2 中借来的。
fn print_ret<'a>(s1: &str,s2: &'astr) -> &'astr{
println!("s1 is {}", s1);
s2
}
fn main() {
let some_str:String= "Some string".to_string();
let other_str:String= "Other string".to_string();
let s1 = print_ret(&some_str, &other_str);
}
不过我想强调的是,仅仅因为 'a
标记在参数 s2 和返回的引用上,并不意味着 s2 和返回的引用都有完全相同的 lifetime。相反,这应该被理解为:带有 'a 标记的返回引用是从具有相同标记的参数中借用来的。
由于 s2 进一步借用了 other_str
,lifetime 约束是返回的引用不能超过 other_str
的 lifetime。这里满足约束,编译成功:
fn print_ret<'a>(s1: &str, s2: &'a str) -> &'a str {
println!("s1 is {}", s1);
s2
}
fn main() {
let some_str: String = "Some string".to_string();
let other_str: String = "Other string".to_string();//-------------+
let ret = print_ret(&some_str, &other_str);//---+ | other_str's lifetime
// | ret's lifetime |
}// <-----------------------------------------------+-----------------+
在展示更多示例之前,简要介绍一下 lifetime 注释语法。
要创建 lifetime 注释,必须首先声明 lifetime 参数。例如,<'a>
是一个生命周期声明。lifetime 参数是一种通用的参数,您可以将 <'a>
读为 "for some Lifetime 'a…"
。一旦声明了 lifetime 参数,就可以在其他引用中使用它来创建 lifetime 约束。
记住,通过用 'a
标记引用,程序员只是在构造一些约束;然后,编译器的工作就是为 'a
找到满足其约束的具体生存期。
示例
接下来,考虑一个求出两个值的最小值的函数:
fn min<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if x < y {
x
} else {
y
}
}
fn main() {
let p = 42;
{
let q = 10;
let r = min(&p, &q);
println!("Min is {}", r);
}
}
在这里,'a
lifetime 注释标记了参数 x、y 和返回值。这意味着返回值可以从 x 或 y 中借用。而又因为 x 和 y 分别进一步从 p 和 q 中借用,所以返回的引用的生命周期应该包含在 p 和 q 的生命周期中。这段代码也可以编译,满足了约束:
fn min<'a>(x: &'a i32, y: &'a i32) -> &'a i32 {
if x < y {
x
} else {
y
}
}
fn main() {
let p = 42;//-------------------------------------------------+
{// |
let q = 10;//------------------------------+ | p's lifetime
let r = min(&p, &q);//------+ | q's lifetime |
println!("Min is {}", r);// | r's lifetime | |
}// <---------------------------+--------------+ |
}// <-------------------------------------------------------------+
通常,当同一 lifetime 参数标记一个函数的两个或多个形参时,返回的引用不能超过形参最小的lifetime。
最后一个例子,这是许多 C++ 新手开发者犯的一个错误,即返回一个指向局部变量的指针。而在 Rust 中不允许有类似的行为:
//Error:cannot return reference to local variable `i`
fn get_int_ref<'a>() -> &'a i32 {
let i: i32 = 42;
&i
}
fn main() {
let j = get_int_ref();
}
由于 get_int_ref()
没有参数,因此编译器知道返回的引用得从局部变量中借用,而这是不允许的。编译器正确地避免了 bug,因为当返回的引用试图访问它指向的内存时,局部变量将被清理掉。
fn get_int_ref<'a>() -> &'a i32 {
let i: i32 = 42;//-------+
&i// | i's lifetime
}// <------------------------+
fn main() {
let j = get_int_ref();//-----+
// | j's lifetime
}// <----------------------------+
省略的场景
当编译器允许开发者省略 lifetime 注释时,称为 lifetime省略。再说一遍,“生命周期省略”一词也具有误导性 —— lifetime 与变量的产生和销毁有着密不可分的关系,又怎么可能省略 lifetime 呢?
所以被省略的不是 lifetime,而是 lifetime 注释和扩展的 lifetime 约束。在 Rust 编译器的早期版本中,不允许省略,并且需要对每个生命周期进行标注。但随着时间的推移,编译器团队观察到生命周期注释的相同模式被重复,因此修改编译器规则,从而推断它们。
在以下情况下,程序员可以省略标记:
- 当只有一个输入参数引用时:在这种情况下,输入参数的生命周期注释会被赋值给所有输出参数引用。例如:
fn some_func(s: &str) -> &str
被推断为fn some_func<'a>(s: &'a str) -> &'a str
- 当有多个传入参数引用时,但第一个参数是:
&self/&mut self
:在这种情况下,输入参数的生命周期注释也被赋值给所有输出引用。例如:fn some_method(&self) -> &str
等价于fn some_method<'a>(&'a self) -> &'a str
lifetime 标注省略减少了代码中的混乱,未来编译器可能会推断出更多模式的 lifetime 约束。
总结
许多 Rust 新手发现 lifetime 这个话题很难理解。但 lifetime 本身并不是问题所在,而是这个概念在 Rust 很多文章中所呈现的方式。在本文中,我试图梳理出 “lifetime” 这个词的过度使用背后隐藏的含义。
变量的生命周期必须满足编译器和开发者对它们施加的某些约束,然后编译器才能确保代码是合理的。如果没有 lifetime 这种机制,编译器将无法保证大多数 Rust 程序的安全性。
如果想了解更多,可以关注公众号: Databend