生命周期
Rust 通过生命周期来执行相关的规则。生命周期是指一个引用必须有效的代码区域,这些区域可能相当复杂,因为它们对应着程序中的执行路径。这些执行路径中甚至可能存在空洞(译者注: 空洞是指一个引用的生命周期可能不是一个连续的代码区域,中间可能有跳跃),因为我们可能会先使一个引用失效,之后再重新初始化并使用它。包含引用(或假装包含)的类型也可以用生命周期来标记,这样 Rust 就可以防止它们也被失效。
在我们大多数例子中,生命周期将与作用域重合,这是因为我们的例子很简单。下面将介绍它们不重合的更复杂的情况。
在一个函数体中,Rust 通常不需要你明确地命名所涉及的生命周期。这是因为一般来说,在本地环境中谈论生命周期是没有必要的;Rust 拥有所有的信息,并且可以尽可能地以最佳方式解决所有问题。Rust 还会引入许多匿名作用域和临时变量, 你不必显式写出它们, 代码也可以跑通。
然而,一旦你跨越了函数的边界,你就需要开始考虑生命周期了。生命周期是用撇号表示的:'a
、'static
。为了尝试使用生命周期,我们将假装我们被允许用生命周期来标记作用域,并尝试手动解一下本章开头例子的语法糖。
我们之前的例子使用了一种激进的语法糖——甚至是高果糖玉米糖浆——因为明确地写出所有东西是非常繁琐的。所有的 Rust 代码都依赖于积极的推理和对“显而易见”的东西的删除。
一个特别有趣的语法糖是,每个let
语句都隐含地引入了一个作用域。在大多数情况下,这其实并不重要。然而,这对那些相互引用的变量来说确实很重要。作为一个简单的例子,让我们对这段简单的 Rust 代码进行完全解糖:
#![allow(unused)] fn main() { let x = 0; let y = &x; let z = &y; }
借用检查器总是试图最小化生命周期的范围,所以它很可能会脱糖为以下内容:
// NOTE: `'a: {` 和 `&'b x` 不是有效的语法,这里只是为了说明 lifetime 的概念
'a: {
let x: i32 = 0;
'b: {
// y 的生命周期为 'b,因为这已经足够好
let y: &'b i32 = &'b x;
'c: {
// 'c 同上所示
let z: &'c &'b i32 = &'c y; // "a reference to a reference to an i32" (with lifetimes annotated)
}
}
}
哇,这真是……太可怕了!让我们花点时间感谢 Rust 让这一切变得简单。
实际上,传递一个引用到外部作用域将导致 Rust 推断出一个更大的生命周期。
#![allow(unused)] fn main() { let x = 0; let z; let y = &x; z = y; }
'a: {
let x: i32 = 0;
'b: {
let z: &'b i32;
'c: {
// y 的生命周期一定为 'b,因为对 x 的引用被传递到了 'b 这个作用域
let y: &'b i32 = &'b x;
z = y;
}
}
}
例子:超出所有者生命周期的引用
让我们看看之前的那些例子:
#![allow(unused)] fn main() { fn as_str(data: &u32) -> &str { let s = format!("{}", data); &s } }
解语法糖后:
fn as_str<'a>(data: &'a u32) -> &'a str {
'b: {
let s = format!("{}", data);
return &'a s;
}
}
as_str
的这个签名接收了一个具有某个生命周期的 u32 的引用,并返回一个可以存活同样长的 str 的引用。我们已经大致能猜到为什么这个函数签名可能是个麻烦了,这意味着我们要找的那个 str 要在 u32 的引用所处的作用域上,或者甚至在更大的作用域上。这要求有点高。
然后我们继续计算字符串s
,并返回它的一个引用。由于我们的函数的契约规定这个引用必须超过'a
,这就是我们推断出的引用的生命周期。不幸的是,s
被定义在作用域'b
中,所以唯一合理的方法是'b
包含'a
,这显然是错误的,因为'a
必须包含函数调用本身。因此,我们创建了一个引用,它的生命周期超过了它的引用者,这正是我们所说的引用不能做的第一件事。编译器理所当然地直接报错。
为了更清楚地说明这一点,我们可以扩展这个例子:
fn as_str<'a>(data: &'a u32) -> &'a str {
'b: {
let s = format!("{}", data);
return &'a s
}
}
fn main() {
'c: {
let x: u32 = 0;
'd: {
// 这里引入了一个匿名作用域,因为借用不需要在整个 x 的作用域内生效,
// 这个函数必须返回一个在函数调用之前就存在的某个字符串的引用,事实显然不是这样
println!("{}", as_str::<'d>(&'d x));
}
}
}
当然,这个函数的正确写法是这样的:
#![allow(unused)] fn main() { fn to_string(data: &u32) -> String { format!("{}", data) } }
我们必须在函数里面产生一个拥有所有权的值才能返回! 我们唯一可以返回一个&'a str
的方法是,它在&'a u32
的一个字段中,但显然不是这样的。
(实际上我们也可以直接返回一个字符串字面量,作为一个全局的字面量可以被认为是在堆栈的底部;尽管这对我们的实现有一点限制)。
示例:别名一个可变引用
来看另一个例子:
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; let x = &data[0]; data.push(4); println!("{}", x); }
'a: {
let mut data: Vec<i32> = vec![1, 2, 3];
'b: {
// 'b 这个生命周期范围如我们所愿地小(刚好够 println!)
let x: &'b i32 = Index::index::<'b>(&'b data, 0);
'c: {
// 这里有一个临时作用域,我们不需要更长时间的 &mut 借用
Vec::push(&'c mut data, 4);
}
println!("{}", x);
}
}
这里的问题更微妙、更有趣。我们希望 Rust 拒绝这个程序,理由如下:我们有一个存活的共享引用x
到data
的一个子集,当我们试图把data
的可变引用传给push
时。这将创建一个可变引用的别名,而这将违反引用的第二条规则。
然而,这根本不是 Rust 认为这个程序有问题的原因。Rust 不理解x
是对data
的一个子集的引用。它根本就不理解Vec
。它看到的是,x
必须在'b
范围内保持存活才能被打印;接下来,Index::index
的签名要求我们对data
的引用必须在'b
范围内存活。当我们试图调用push
时,它看到我们试图构造一个&'c mut data
。Rust 知道'c
包含在'b
中,并拒绝了我们的程序,因为&'b data
必然还存活着!
在这里我们看到,和我们真正想要保证的引用规则语义相比,生命周期系统要粗略得多。在大多数情况下,这完全没问题,因为它使我们不用花整天的时间向编译器解释我们的程序。然而,这确实意味着有部分程序对于 Rust 的真正的语义来说是完全正确的,但却被拒绝了,因为 lifetime 太傻了。
生命周期所覆盖的区域
一个引用(有时称为borrow)从它被创建到最后一次使用都是存活的。被 borrow 的值的生命周期只需要超过引用的生命周期就行。这看起来很简单,但有一些微妙之处。
下面的代码可以成功编译,因为在打印完x
之后,它就不再需要了,所以它是悬空的还是别名的都无所谓(尽管变量x
技术上一直存活到作用域的最后):
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; let x = &data[0]; println!("{}", x); // 这是可行的,因为不再使用 x,编译器也就缩短了 x 的生命周期 data.push(4); }
然而,如果该值有一个析构器,析构器就会在作用域的末端运行。而运行析构器被认为是一种使用——显然是最后一次使用。所以,这将会编译报错:
#![allow(unused)] fn main() { #[derive(Debug)] struct X<'a>(&'a i32); impl Drop for X<'_> { fn drop(&mut self) {} } let mut data = vec![1, 2, 3]; let x = X(&data[0]); println!("{:?}", x); data.push(4); // 编译器会在这里自动插入 drop 函数,也就意味着我们会访问 x 中引用的变量,因此编译失败 }
让编译器相信x
不再有效的一个方法是在data.push(4)
之前使用drop(x)
。
此外,可能会有多种最后一次的引用使用,例如在一个条件的每个分支中:
#![allow(unused)] fn main() { fn some_condition() -> bool { true } let mut data = vec![1, 2, 3]; let x = &data[0]; if some_condition() { println!("{}", x); // 这是该分支中最后一次使用 x 这个引用 data.push(4); // 因此在这里 push 操作是可行的 } else { // 这里不存在对 x 的使用,对于这个分支来说, // x 创建即销毁 data.push(5); } }
生命周期中可以有暂停,或者你可以把它看成是两个不同的借用,只是被绑在同一个局部变量上。这种情况经常发生在循环周围(在循环结束时写入一个变量的新值,并在下一次迭代的顶部最后一次使用它)。
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; // x 是可变的(通过 mut 声明),因此我们可以修改 x 指向的内容 let mut x = &data[0]; println!("{}", x); // 最后一次使用这个引用 data.push(4); x = &data[3]; // x 在这里借用了新的变量 println!("{}", x); }
Rust 曾经一直保持着借用的生命,直到作用域结束,所以这些例子在旧的编译器中可能无法编译。此外,还有一些边界条件,Rust 不能正确地缩短借用的有效部分,即使看起来应该这样做,也不能编译。这些问题将随着时间的推移得到解决。