《Rust中常见的有关生命周期的误解》学习笔记


概述

众所周知,在 Rust 语法里面,生命周期一直是初学者面临的一道雄关。最近部门里 Rust 预研组长分享了《Rust 中常见的有关生命周期的误解》这篇文章,看完刷新了我对包括生命周期在内的一些 Rust 语法的理解。所以这里分享一下学习笔记。

内容

误解 1:T 只包含所有权类型。

这个误解主要想讲的是泛型T既可以匹配所有权类型,也可以匹配引用类型。所以在写一揽子实现的时候不能同时为T&T(或&mut T)实现 trait,不然就重复了。即:

trait Trait {}
impl<T> Trait for T {}
impl<T> Trait for &T {} // 编译错误
impl<T> Trait for &mut T {} // 编译错误

但这里我更关注的是“Rust 能够给引用类型实现 trait”这个概念。比如我可以这样写:

trait Trait {
    fn f(&self);
}
impl Trait for &i32 {
    fn f(&self) {
        println!("{}", self);
    }
}
let a: i32;
(&a).f();

我一开始一直想不通,为什么能给引用类型实现方法。后来才发现我被过去学习面向对象语言的经验给误导了,总是把实现 trait 当作是接口继承一类的东西。实际上,面向对象里,讲究的是“A 是 B 的一种”这样的关系。比如,“鸭子”类继承“鸟”类,因为鸭子就是鸟的一种。但 Rust 里,实现 trait 更像是一种“A 能做 B 定义的行为”的关系。比如,“有翅膀”是一个 trait,其中包含了“扇翅膀”、“收起翅膀”等行为,那么,“鸭子”结构体实现了这个 trait,它就有了对应的行为,“蝙蝠”结构体也实现了这个 trait,也有对应的行为,但它不是鸟。

反映到上面的代码里,我是让 &i32 能做 f() 这个行为,而不是让 &i32 作为 Trait 的一种。这样逻辑就很顺了。

误解 2、3:如果 T: ’static 那么 T 直到程序结束为止都一定是有效的;&’a T 和 T: ’a 是一回事。

误解 2 和 3 其实是一样的,说的是给泛型参数T而不是&T/&mut T指定生命周期的语义。前面也说了,T 能够匹配所有权类型,也能匹配引用类型,而所有权类型的生命周期并没有什么意义。所以,如果 T 匹配的是所有权类型,那么这个'static或者'a就相当于没用,只有当 T 匹配的是引用类型时,才会要求这个引用类型的参数或者属性需要满足'static或者'a生命周期约束。

误解 4:我的代码里不含泛型也不含生命周期注解。

没啥好说的,有时生命周期注解可以省略,编译器有一套规则决定是否可以省略。

误解 5:如果编译通过了,那么我标注的生命周期就是正确的。

感觉出现这个问题的场景比较极端,只有当结构体里包含引用属性,并且给这个结构体实现方法的时候,参数只有一个&self,且返回这个结构体里的引用属性时才会遇到推断错误的情况。我写了个更简单的例子触发这种场景:

struct s<'a> {
    r: &'a i32 // 结构体里包含引用属性    
}

impl s<'_> {
    fn f(&self) -> &i32 { // 方法里只有一个 &self 参数
        self.r // 返回值是这个引用属性
    }
}

fn main() {
    let a: i32 = 0;
    let r = {
        let ss = s { r: &a };
        ss.f() // `ss` does not live long enough
    };
    println!("{}", r);
}

返回值本应该关联s.r的生命周期,结果编译器让返回值关联到&s自己的生命周期了,进而导致了此错误,改成这样即可:

struct s<'a> {
    r: &'a i32    
}

impl<'a, 'b> s<'a> {
    fn f(&'b self) -> &'a i32 { // 'b 可以不标,让编译器自动推断
        self.r
    }
}

fn main() {
    let a: i32 = 0;
    let r = {
        let ss = s { r: &a };
        ss.f()
    };
    println!("{}", r);
}

拓展:如果我把方法f换成一个独立的函数f,如下所示:

fn f(ss: &s) -> &i32 {
    ss.r
}

这个时候,编译器就不会自动推断,而是要求我显式标注生命周期,并给出推荐:

help: consider introducing a named lifetime parameter
  |
5 | fn f<'a>(ss: &'a s<'a>) -> &'a i32 {
  |     ++++      ++  ++++      ++

这个推荐是有问题的(提前剧透误解 7 了😂)。可以看到这里一股脑地将ssss.r和返回值全部标注为同一生命周期,因此这样写会报和第一个反例一样的错误。解决方法就是按照真实情况,返回值和ss.r标同个生命周期,ss自己标独立生命周期,就对了:

fn f<'a, 'b>(ss: &'a s<'b>) -> &'b i32 {
    ss.r
}

当然,这些问题都是编译期出现的,不懂的话最多编译不过,不会把问题留到运行时。

另外,我也让豆包看了下,我写的第一个反例豆包直接就改对了。而原文中的反例,豆包一开始给了些其他方案,我让它只考虑改生命周期标注,它也能准确地指出问题。所以即使不懂,靠 AI 帮忙应该是一个可行的解决方案。

误解 6:已装箱的 trait 对象不含生命周期注解

要理解这个误解,首先要理解dyn Trait + 'static是什么意思。dyn Trait这玩意其实和泛型类型T有点像的,都是既可以指代所有权类型,也可以指代引用类型。所以,dyn Trait + 'static的含义就和T: 'static类似,即要求dyn Trait这个类型的实例,如果是所有权类型,那就没限制;如果是引用类型,那么生命周期就要满足'static限制。像下面这个例子,就会报错:

trait Trait {}

impl Trait for &i32 {}

fn main() {
    let v: i32 = 0;
    let b: Box<dyn Trait + 'static> = Box::new(&v); // `v` does not live long enough
}

而这个误解说的是,直接声明Box<dyn Trait>,编译器就会自动推断为Box<dyn Trait + 'static>。与之对应的,如果声明&dyn Trait,那么编译器就会自动推断dyn Trait的声明周期和这个引用本身的声明周期一致。即

// 展开前
type T3<'a> = &'a dyn Trait;
// 展开后,&'a T 要求 T: 'a, 所以推导为 'a
type T4<'a> = &'a (dyn Trait + 'a);

其实这也很合理,因为编译器推断生命周期,总得有个依据。而Box是拥有所有权的指针,它不是借用,因此是没有生命周期的,所以你让编译器推Box<dyn Trait>的生命周期,它也只能推成默认的'static。如果你指定了 Trait 内部的生命周期,编译器有依据了,就不会推成'static了:

// 展开前
type T7<'a> = Box<dyn GenericTrait<'a>>;
// 展开后
type T8<'a> = Box<dyn GenericTrait<'a> + 'a>;

误解 7:编译器的报错信息会告诉我怎样修复我的程序

这个前面也说过了,编译器一般会推荐一股脑地将所有参数、参数内部、返回值全部标注为同一生命周期,这种推荐很多时候不满足用户需求。此外如误解 6 所示,编译器有时会自动推断生命周期,那么它给出的推荐也往往是根据前面自动推断的生命周期来的,如果自动推断的生命周期本来就不满足用户需求,那给出的推荐当然就更不满足用户需求了。

误解 8:生命周期可以在运行时动态变长或变短

生命周期推断比较粗糙,在编译期直接在所有可能接受的引用中选一个最早被释放的。并且这种推断是在各类编译优化之前。

误解 9:将独占引用降级为共享引用是 safe 的

b = &*(&mut a)会导致b存活的时候&mut a也一直存活,在此期间不能再获取&a。同时如果原来的&mut a绑定了变量(如let c = &mut a; let b = &*c;),那b的存活期间c也不能用来给a赋值了(不能执行*c = XXX)。所以原文说这是一个“反模式”,意思就是这个用法没有禁止但百害无一利。

另外注意原文中的误解 5 也涉及了这个用法,而且更隐蔽。原文中的next方法:

fn next(&mut self) -> Option<&u8> {
    if self.remainder.is_empty() {
        None
    } else {
        let byte = &self.remainder[0];
        self.remainder = &self.remainder[1..];
        Some(byte)
    }
}

前面说了编译器会将返回的&u8推断为和&mut self相同的生命周期,那么,当执行let byte = &self.remainder[0];时,根据DeRef隐式转换,实际上执行的是let byte = &(*self.remainder)[0];,又因为生命周期推断,所以在byte(返回的&u8)存活期间,不是self.remainer一直存活,而是&mut self一直存活,而第二次调用next方法时会又获取一次&mut self,导致独占引用重复获取错误。

误解 10:对闭包的生命周期省略规则和函数一样

意思就是匿名函数的自动生命周期推断规则和声明方式都和普通函数不一样。总结下来原文给出的合法方案就两种:

// 可以不用堆分配而直接创建一个 'static 引用
let identity: &dyn Fn(&i32) -> &i32 = &|x: &i32| x;

    // 上一行去掉语法糖 :)
let identity: &'static (dyn for<'a> Fn(&'a i32) -> &'a i32 + 'static) = &|x: &i32| -> &i32 { x };

挺麻烦的,可能 Rust 设计初衷就希望我们在用匿名函数的时候引用尽量通过闭包而不是参数来获得。

误解 11:’static 引用总能被强制转换为 ’a 引用

总结起来,就是'static引用能当'a引用使用,但是返回'static引用的函数不能当返回'a引用的函数使用。

总结

在以上所有误解中,有好几个都是因为 Rust 编译器的生命周期自动推断机制导致的,这其实是一个两难问题:自动推断少,虽然会减少排查编译错误的困难,但会提高编程复杂度,同时也让 Rust 本就不低的学习门槛被进一步抬高;但自动推断多,有些细节被隐藏,导致初学者在学习的时候很难学透,导致一些比较刁钻的编译错误(所幸现在有 AI 能在一定程度上解决这类问题)。要是有一个能自动显示出所有被省略的生命周期的工具就好了,看了下 rust-analyzer 插件没这功能,Cargo/rustc 好像也没这种选项,问豆包也答错了,不知道有没有人能开发一个。

此外,感觉 Rust 很需要一本“Effect Rust”这样的书,通过各种“条款”让初学者能够深入理解 Rust 里的一些细节。原文章虽然能起到一部分的作用,但它的门槛还是比较高,比基础教程往上一大截,还有很多例子太复杂了,重点不够突出,因此对刚学完基础教程的我来说,学习难度也比较大,需要做很多实验才能理解。

不过,不管怎么说,这些误解所产生的后果也不过是编译错误,不会像 C/C++ 一样写个 UB 导致各种花样百出的 BUG 来,这点还是比较令人安心的。