生命周期
Rust 通过生命周期来强制执行这些规则。生命周期是代码中引用必须保持有效的命名区域。这些区域可能相当复杂,因为它们对应于程序中的执行路径。这些执行路径中甚至可能有“洞”,因为只要在再次使用引用之前重新初始化它,就可以使其无效。包含引用(或假装包含)的类型也可以用生命周期标记,以便 Rust 可以防止它们被无效化。
在我们的多数示例中,生命周期将与作用域重合。这是因为我们的示例很简单。下面将描述生命周期不重合的更复杂情况。
在函数体内部,Rust 通常不允许你显式命名涉及的生命周期。这是因为在局部上下文中讨论生命周期通常不是真正必要的;Rust 拥有所有信息,可以尽可能优化地处理一切。为了让你的代码正常工作,通常会引入许多匿名的作用域和临时变量,否则你必须自己编写它们。
然而,一旦跨越函数边界,你就需要开始讨论生命周期。生命周期用一个撇号表示:'a,'static。为了初步了解生命周期,我们将假装实际上允许我们用生命周期标记作用域,并对本章开头的示例进行去糖化(desugar)。
最初,我们的示例在作用域和生命周期周围使用了激进的糖化(甚至可以说是高果糖玉米糖浆),因为显式写出所有内容是极其繁琐的。所有 Rust 代码都依赖于对“显而易见”事物的激进推断和省略。
一个特别有趣的糖化是每个 let 语句都隐式引入了一个作用域。在大多数情况下,这并没有什么影响。然而,对于相互引用的变量,它确实很重要。作为一个简单的例子,让我们完全去糖化这段简单的 Rust 代码:
#![allow(unused)] fn main() { let x = 0; let y = &x; let z = &y; }
借用检查器总是试图最小化生命周期的范围,因此它很可能去糖化为以下形式:
// NOTE: `'a: {` and `&'b x` is not valid syntax!
'a: {
let x: i32 = 0;
'b: {
// lifetime used is 'b because that's good enough.
let y: &'b i32 = &'b x;
'c: {
// ditto on '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: {
// Must use 'b here because the reference to x is
// being passed to the scope '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 引用 originate 的作用域中或更早的地方找到一个 str。这有点难以实现。
然后我们继续计算字符串 s,并返回它的引用。由于函数的契约说引用必须比 'a 活得更长,所以我们将这个生命周期推断给引用。不幸的是,s 是在作用域 'b 中定义的,所以只有当 'b 包含 'a 时,这才是合理的——这显然是错误的,因为 'a 必须包含函数调用本身。因此,我们创建了一个生命周期比其被引用物(referent)更长的引用,这字面上是我们说的引用不能做的第一件事。编译器理所当然地在我们面前“爆炸”了。
为了让这一点更清晰,我们可以扩展这个例子
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: {
// An anonymous scope is introduced because the borrow does not
// need to last for the whole scope x is valid for. The return
// of as_str must find a str somewhere before this function
// call. Obviously not happening.
println!("{}", as_str::<'d>(&'d x));
}
}
}
糟糕!
当然,编写这个函数的正确方法如下:
#![allow(unused)] fn main() { fn to_string(data: &u32) -> String { format!("{}", data) } }
我们必须在函数内部生成一个拥有的值(owned value)才能返回它!我们能够返回 &'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 is as big as we need this borrow to be
// (just need to get to `println!`)
let x: &'b i32 = Index::index::<'b>(&'b data, 0);
'c: {
// Temporary scope because we don't need the
// &mut to last any longer.
Vec::push(&'c mut data, 4);
}
println!("{}", x);
}
}
这里的问题更微妙、更有趣。我们希望 Rust 因为以下原因拒绝这个程序:当我们尝试获取 data 的可变引用以进行 push 时,我们持有一个活跃的共享引用 x,它指向 data 的一个后代。这将创建一个带有别名的可变引用,从而违反引用的第二条规则。
然而,Rust 完全不是这样推断这个程序是错误的。Rust 不理解 x 是指向 data 的子路径的引用。它完全不理解 Vec。它确实看到的是,x 必须为 'b 生命周期而活,才能被打印。随后,Index::index 的签名要求我们对 data 获取的引用必须在 'b 生命周期内生存。当我们尝试调用 push 时,它看到我们尝试创建一个 &'c mut data。Rust 知道 'c 包含在 'b 中,并拒绝了我们的程序,因为 &'b data 必须仍然存活!
在这里我们看到,生命周期系统比我们实际关注的引用语义要粗粒度得多。在大多数情况下,这完全没问题,因为它使我们不必整天向编译器解释我们的程序。然而,这也意味着一些根据 Rust 真正语义而言完全正确的程序会被拒绝,因为生命周期系统不够聪明。
生命周期涵盖的区域
引用(有时称为借用)从它被创建的地方到它最后一次使用的地方都是活跃的。被借用的值只需要比活跃的借用活得更久。这看起来很简单,但有一些微妙之处。
以下代码片段可以编译,因为在打印 x 之后,它就不再需要了,所以无论它是否悬空或有别名(即使变量 x 技术上一直存在到作用域的最后)都不重要。
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; let x = &data[0]; println!("{}", x); // This is OK, x is no longer needed 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); // Here, the destructor is run and therefore this'll fail to compile. }
让编译器相信 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); // This is the last use of `x` in this branch data.push(4); // So we can push here } else { // There's no use of `x` in here, so effectively the last use is the // creation of x at the top of the example. data.push(5); } }
一个生命周期中可以有一个暂停。或者你可以把它看作是两个不同的借用恰好绑定到同一个局部变量。这种情况经常发生在循环周围(在循环结束时写入变量的新值,并在下一次迭代开始时最后一次使用它)。
#![allow(unused)] fn main() { let mut data = vec![1, 2, 3]; // This mut allows us to change where the reference points to let mut x = &data[0]; println!("{}", x); // Last use of this borrow data.push(4); x = &data[3]; // We start a new borrow here println!("{}", x); }
历史上,Rust 会将借用保持到作用域结束,因此这些例子在旧的编译器中可能无法编译。此外,仍然存在一些角落情况,Rust 未能正确缩短借用的活跃部分,即使看起来应该编译,也无法编译。这些问题将随着时间得到解决。