不可靠的 Rust Lifetime Elision

发布时间 2023-11-05 17:43:12作者: 那阵东风

众所周知,Rust 编译器在分析代码的过程中,会对含有引用参数、返回值的函数、方法进行 lifetime 检查。经历数次版本迭代后 Rust 编译器发展出了一套惯用规则用于隐式推理 lifetime 注解 (lifetime elision),从而减小开发者的编写难度,尽可能省略不必要的 lifetime 注解。由于后文会涉及这个要点,所以回顾三条规则如下:

  1. 对于函数和方法的每一个引用类型的参数,都赋予一个单独的 lifetime 标记;
  2. 对于只有一个引用类型参数的函数或方法,返回值中的全部引用类型都使用与该参数相同的 lifetime 标记;
  3. 如果一个方法的首参是 &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() 的返回结果 stu0stu1 的 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,其中有很多独到的见解。笔者阅读之后,将其观点稍作梳理,终成此文。