不可靠的 Rust Lifetime Elision
众所周知,Rust 编译器在分析代码的过程中,会对含有引用参数、返回值的函数、方法进行 lifetime 检查。经历数次版本迭代后 Rust 编译器发展出了一套惯用规则用于隐式推理 lifetime 注解 (lifetime elision),从而减小开发者的编写难度,尽可能省略不必要的 lifetime 注解。由于后文会涉及这个要点,所以回顾三条规则如下:
- 对于函数和方法的每一个引用类型的参数,都赋予一个单独的 lifetime 标记;
- 对于只有一个引用类型参数的函数或方法,返回值中的全部引用类型都使用与该参数相同的 lifetime 标记;
- 如果一个方法的首参是
&self
或&mut self
,不论它存在几个引用类型的参数,返回值中全部引用类型的 lifetime 都与self
相同。
基于这三条规则,Rust 编译器如果完成了对所有参数的 lifetime 注解,就可以按照借用规则判断是否发生违规行为,从而决定是否编译通过。进一步,我们可能得出一个错误结论:只要编译通过,那么 lifetime 注解就是正确的。
先看这个例子,这是一段看起来非常正常的结构体定义,以及它的方法:
struct Classroom<'a>(&'a [i32]);
impl<'a> Classroom<'a> {
fn leave(&mut self) -> Option<&i32> {
if self.0.len() > 0 {
let temp = &self.0[0];
self.0 = &self.0[1..];
Some(temp)
} else {
None
}
}
}
接着尝试使用这个结构体:
// main 函数版本一
fn main() {
let mut classroom = Classroom(&[1, 2]);
let stu0 = classroom.leave();
assert_eq!(stu0.unwrap(), &1);
}
// main 函数版本二
fn main() {
let mut classroom = Classroom(&[1, 2]);
let stu0 = classroom.leave();
let stu1 = classroom.leave();
assert_eq!(stu0.unwrap(), &1);
}
// main 函数版本三
fn main() {
let mut classroom = Classroom(&[1, 2]);
let stu0 = classroom.leave();
assert_eq!(stu0.unwrap(), &1);
let stu1 = classroom.leave();
}
分别编译版本一、二、三,会发现只有版本二无法通过编译。既然是同样的结构体代码,那么 lifetime elision 的结果必然是相同的。然而存在有时能编译,有时又无法编译的情况,说明编译器自动 lifetime 推理的结果可能存在问题。
我们先观察编译出错的版本二,看 rustc
提示了什么?
error[E0499]: cannot borrow `classroom` as mutable more than once at a time
--> src\main.rs:13:16
|
12 | let stu0 = classroom.leave();
| ----------------- first mutable borrow occurs here
13 | let stu1 = classroom.leave();
| ^^^^^^^^^^^^^^^^^ second mutable borrow occurs here
14 | assert_eq!(stu0.unwrap(), &1);
| ---- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
对比两次调用 classroom.leave()
的返回结果 stu0
和 stu1
的 lifetime (右侧为代码行数,参见上述错误信息):
─────────── mutable borrow 1 ────── L12 (borrow)
│
stu0 lifetime (at least)
│
│ stu1 ── mutable borrow 2 ────── L13 (borrow)
│
─────────────────────────────────── L14 (last use)
可以观察到,classroom.leave()
方法每次调用都会产生一个对 classroom
的可变借用。而按照上面的最短 lifetime 分析图,显然 stu0
作为首次可变借用的产物,其 lifetime 直到 stu1
产生时仍然有效。因此,产生 stu1
的那次可变借用,将违反“程序中同一时刻只允许存在对可变变量的唯一可变借用”的铁律。
既然找到了问题,我们就要分析为什么 Rust 会作出判断,认为调用结束后首次可变借用 (不是指产物 stu0
,而是 &mut classroom
) 仍然处于合法的 lifetime 内而没有被释放 (drop)?要解决这个疑问,就必须按着编译器的行动路线走一遍。我们按照 lifetime elision 的三条规则逐一标记,最终得到的内容如下:
struct Classroom<'a>(&'a [i32]);
impl<'a> Classroom<'a> {
fn leave<'b>(&'b mut self) -> Option<&'b i32> {
if self.0.len() > 0 {
let temp = &self.0[0];
self.0 = &self.0[1..];
Some(temp)
} else {
None
}
}
}
注意: Rust 指定第一条规则时,会使用未曾出现过的泛型标记,由于 'a
已经存在过了,所以这里考虑 'b
。
重点来了!leave
方法的参数和返回值具有一致的 lifetime,意味着首次调用 leave
方法后,被调者 classroom
产生了一个与返回值 std0
具有一致最短 lifetime的借用。只要 std0
处于合法的 lifetime,Rust 就不允许第二次调用 leave
方法。
但是,按照我们的设计,leave
返回的内容应该具有最短为 'a
的 lifetime,毕竟它就是从这个 &[i32]
类型的切片中取出来的。其 lifetime 与 &mut self
的 lifetime 没有任何关系。也就是: 按照我们的设计,leave
调用产生的可变借用的 lifetime,与返回值没有关系,那么可变借用的 lifetime 就应该尽可能短,再最后一次使用 (self.0 = &self.0[1..];
) 之后立即释放。这样一来就不会出现违反借用铁律的问题了。
于是,我们只需要手动将返回值的 lifetime 注解写为 'a
,就能解决问题了。
这篇短博文的灵感来源于 kirill (pretzelhammer) 的 Rust 博文 Common Rust Lifetime Misconceptions,其中有很多独到的见解。笔者阅读之后,将其观点稍作梳理,终成此文。