“
原文链接: https:// hashrust.com/blog/lifet imes-in-rust/
原文标题: Lifetimes in Rust
公众号: Rust 碎碎念
简介
对于很多 Rust 初学者而言,生命周期(lifetime)是一个很难掌握的概念。在我意识到生命周期对 Rust 编译器履行职责有多重要之前,也曾与其抗争过一段时间。生命周期本质上并不难。只是因为它是一种新的构思,以至于大多数程序员从未在任何其他语言中见过它们。让事情变得更糟的是,人们过度使用生命周期(lifetime) 这个词来讨论许多密切相关的思想。在本文中,我会将这些思想进行区分,并以此使你能对生命周期有清晰的思考。
生命周期的目标(Purpose of lifetimes)
在讨论具体内容之前,让我们先来理解生命周期为什么会存在。它们被用于什么目标?生命周期帮助编译器执行一个简单的规则:引用不应该活得比所指对象长(no reference should outlive its referent)。换句话说,生命周期帮助编译器压制悬垂指针 bug。正如你将会在下面的例子中所见,编译器通过分析相关变量的生命周期来实现这一目标。如果一个引用的生命周期小于其所指对象的生命周期,则代码可以编译,否则就无法编译。
生命周期一词的意义(Meaning of the word lifetime)
生命周期之所以如此令人困惑,其部分原因在于 Rust 的很多写作中,生命周期这个词被宽泛地用来指代三种不同的东西——变量真实的生命周期、生命周期约束和生命周期标注。让我们来一个一个的看。
变量的生命周期(Lifetimes of variables)
这很直观,变量的生命周期是指它活着的时间。这个意思最接近于字典里的定义,即事物存在或有效的持续时间(the duration of a thing's existence or usefulness) 。例如,在下面的代码中,x
的生命周期延续到外部块的结尾,而y
的生命周期在内部块的结尾就结束了。
{
let x: Vec<i32> = Vec::new();//---------------------+
{// |
let y = String::from("Why");//---+ | x's lifetime
// | y's lifetime |
}// <--------------------------------+ |
}// <---------------------------------------------------+
生命周期约束(Lifetime constraints)
变量在代码中的交互方式对它们的生命周期有一定的约束。例如,在下面的代码中,x=&y;
这一行添加了一个约束,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
的一个引用,而y
会在前面的一行被销毁。
记住,约束没有改变真实的生命周期——例如,x
的生命周期,仍然延展到外部块的结尾,它们只是编译器用来禁止悬垂引用的工具。并且在上面的例子中,真实的生命周期没有满足约束:x
的生命周期超出y
的生命周期。因此,这段代码会编译失败。
生命周期标注(Lifetime annotations)
正如在上一节所见,很多次编译器都会生成所有的生命周期约束。但是随着代码愈加复杂,编译器会让程序员手动添加约束。程序员通过生命周期标注来完成这件事。例如,在下面的代码片段里,编译器需要知道print_net
函数返回的引用是借用了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
,请去阅读
这个 StackOverflow 上的回答[1]。想知道什么时候编译器让你省略标注,看下面的
生命周期省略 部分。
然后程序员就对s2
和返回的引用使用'a
进行标注,从而告诉编译器返回值借用自s2
:
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 s1 = print_ret(&some_str, &other_str);
}
我想强调的是,生命周期标注'a
在参数s2
和返回的引用上都出现了,不要把这解释为s2
和返回的引用有完全相同的生命周期,而应该这样解释:使用'a
标注的返回的引用借用自具有相同标注的参数。
因为s2
借用自other_str
,这里的生命周期约束是,返回的引用必须不能活得比other_str
长。代码可以编译是因为生命周期约束得到了满足:
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 |
}// <-----------------------------------------------+-----------------+
在向你展示更多的例子之前,让我简要介绍一下生命周期标注语法。要创建一个生命周期标注,必须先声明一个生命周期参数。例如,<'a>
是一个生命周期声明。生命周期参数是一种泛型参数,你可以把<'a>
读作"对于某生命周期'a..."。一旦一个生命周期参数被声明,它就可以在引用中使用以创建一个生命周期约束。
记住,通过使用'a
标注引用,程序员只是明确表述某些约束;而为满足约束的'a
找到一个具体的生命周期则是编译器的工作。
更多示例(More examples)
接下来,考虑一个函数min
,其作用是找到两个值中最小的那个:
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
生命周期参数标注了参数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 | |
}// <---------------------------+--------------+ |
}// <-------------------------------------------------------------+
一般而言,当相同的生命周期参数标注了一个函数中两个及以上的参数,返回的引用必须不能活得比参数生命周期中最小的那个长。
最后一个例子。许多 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
中没有参数,所以编译器知道,返回的引用必定是借用自一个局部变量,而这是不被允许的。编译器正确地避过了灾难,因为当返回的引用尝试访问局部变量时,该变量已经被清除:
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 elision)
当编译器让程序员省略生命周期标注时,被称为生命周期省略(lifetime elision)。再一次地,生命周期省略被误解——生命周期与变量的产生和销毁有着密不可分的联系,怎么能被省略呢?被省略的不是生命周期,而是生命周期标注以及对应的生命周期约束。在 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
。
生命周期省略减少了代码中的杂乱性,并且有可能在将来,编译器能够对更多的模式推导出生命周期约束。
总结(Conclusion)
许多 Rust 新手发现生命周期这个主题难以理解。但是生命周期本身并不应该被指责,应该指责的是这个概念在很多 Rust 的写作中是如何被呈现的。在本文中,我已经试图讲明被过度使用的生命周期一词的含义。
在编译器在确保代码合理之前,变量的生命周期必须满足被编译器和程序员施加的特定约束。没有生命周期这个设施,编译器将无法保证大多数 Rust 程序的安全性。
参考资料
[1]
这个StackOverflow上的回答: https://stackoverflow.com/a/31612025
禁止转载,谢谢配合。欢迎关注个人公众号: Rust碎碎念