4403 字
22 分钟
Too many lifetime marks
2024-09-10
CAUTION

error[E0597]: xxx does not live long enough

这个错误就和segment fault一样,写rust迟早会遇到的,有时候出现得往往让人摸不着头脑,这或许也是 rust 的特色,人和变量至多只能有一个 live long enough。

生命周期的必要性不必赘言,被 c/c++ 折磨过的都能体会。但常常听人说生命周期宛如古神,不可名状,其实又过于夸大其词了,如果从实现出发其实不难理解。

严格讲 生命周期生命周期标注 是不一样的,前者是指占据某个资源的变量固有的存续时间,如 StringBox 这类owned类型也是有生命周期的,它指明这类变量什么时候被销毁,所占有的资源什么时候被释放。而生命周期标注往往是对引用类型,或者含有引用的类型的一种注解规则,标注指明这个引用需要满足的要求。令人感到烦恼的一般是生命周期标注,所以后文的“生命周期”都是指标注。。

与编译器的约定#

假设我们写了这样的代码

fn return_ref<'a>(val: &'a str) -> &'a str {
    val
}

它告诉编译器,这个函数“返回的引用存续时间应该与传入的引用一样长”。这实际上是一种约定,毕竟在编程中一个广为人知的原则是“约定比实现重要”。当我们与编译器作出这个约定时,意味着三件事:

  1. 我们在使用这个函数时,不能违背这个规定。如果传入值引用的变量被销毁了,返回的引用也应当不再被使用。
  2. 我们在实现这个函数时,不能违背这个规定。我们不能把一个与 val 生命周期不同的变量当作返回值传出去。
  3. 编译器负责检查我们有没有按照约定使用和实现,如果没有就抛出错误,编译器自己不能修改约定。

可以说,凡有引用的地方皆有生命周期标注,不是程序员来写,就是编译器来写。当然,编译器只能按一些预设的原则写,当它写的与程序员想要的不同时,往往就意味着一个生命周期错误。一些编译器自动标注的规则在 the book 中已有介绍,此处不再赘述。

当遇到生命周期标注的错误时,手动把编译器帮忙加的生命周期标注写出来能够帮助我们理解错误所在。

生命周期标注是泛型参数#

rust 尚没有一个书面的标准来确定生命周期的实现方式,这个命题实际上应该算目前 rustc 的理解。

我们来看这段代码

let owned: i32 = 5;
let ref_1: &i32 = &owned;
drop(ref_1);
let ref_2: &i32 = &owned;

提问:ref_1ref_2 的类型一样吗?

按照 c++ 的习惯,它们当然是一样的,都是 &i32。但是在 rustc 看来,它们其实是两个类型 &'_1 i32&'_2 i32'_1到第3行结束,'_2则开始于第4行。这是 rustc 处理生命周期问题的诀窍:把不同生命周期的引用视为不同的类型,我们就可以用类型检查来处理生命周期不匹配的问题了。

一个例子能很好地说明这一点

struct Buffer<'a> {
    inner: &'a mut [i32],
}

impl<'a> Buffer<'a> {
    // 这个函数会报错
    fn sub_slice(&mut self) -> Self {
        Buffer {
            inner: &mut self.inner[1..],
        }
    }

    fn real_sub_slice<'b>(&'b mut self) -> Buffer<'b> {
        Buffer {
            inner: &mut self.inner[1..],
        }
    }
}

sub_slice 里,Self 指代的是 Buffer<'a> 而不是 Buffer,亦即,生命周期参数也是这个类型的一部分。

既然生命周期标注也是类型的一部分,很自然地,我们会问这个众所周知的示例函数是怎么回事:

fn longest<'a>(a: &'a str, b:&'a str)-> &'a str {
    if a.len() >= b.len() { a } else { b }
}

fn main() {
    let a = "hello".to_string();
    let b = "world".to_string();
    let ref_a: &str = &a; // '_1 开始
    let ref_b: &str = &b; // '_2 开始
    let r = longest(ref_a, ref_b); // '_3
    drop(ref_a); // '_1 结束
    println!("{ref_b}"); // '_2 结束
}

ref_a 的类型是 &'_1 strref_b 的类型是 &'_2 strlongest 又要求两个传入的参数具有相同的生命周期,为什么编译器还坐得住?

答案是仅有生命周期标注不同的类型是一组特别的类型,它们是 rust 中唯一谈得上“继承”关系的类型。当只有生命周期不同时,生命周期较长的类型是生命周期较短的类型的“子类型”,所有父类型(生命周期较短)能用的地方,都可以完美地使用子类型(生命周期较长)。这比c++通常而言的继承更强,因为父子类型的所有方法都是完全一样的。

在这段代码中,rustc 在处理调用 longest 的代码时,会找出一个最短可用的生命周期,在这里就是返回值的 '_3,它晚于 '_2开始,早于 '_1结束。于是它看到:

  1. '_3 能满足 r 的生命周期需要,check
  2. '_3'_1 完全覆盖,&'_1 str&'_3 str 的子类型,所以传 ref_a 是可行的,check
  3. '_3'_2 完全覆盖,&'_2 str&'_3 str 的子类型,所以传 ref_b 是可行的,check

也就是说,longest 被实例化成了 fn longest(a: &'3 str, b:&'3 str)-> &'3 str,由于&'_1 str&'_2 str&'_3 str的子类型,所以我们可以安全地传 ref_aref_b

协变、逆变和不变#

我们知道了仅有生命周期不同的类型间可以讨论继承关系,于是就有了协变、逆变和不变之说。它们的意思非常简单:

假设我们有一个接收类型 T 的泛型类型 C<T>,那么

  1. 协变:如果 T1T2 的子类型,我们可以断言 C<T1> 也是 C<T2> 的子类型
  2. 逆变:如果 T1T2 的子类型,我们可以断言 C<T2>C<T1> 的子类型
  3. 不变:无论 T1T2 的关系为何,我们都不能断定 C<T1>C<T2> 的关系

这里的“泛型类型”外延比我们习惯称呼的那些要大得多:

  • 常见的 struct Foo<T> 当然算泛型类型
  • &mut T 也算一个接收 T 的泛型类型,这里的 C 可以看作 &mut
  • fn() -> T 是一个返回 T 的泛型类型

总而言之,任何需要提供一个 T 补完的类型都可以看作“泛型类型”。

对不同的泛型类型 rust 有不同的变化规则

  1. 标准库的容器类型 Box<T>Vec<T> 是协变的。举个例子,如果 T 分别是 &'a _&'b _,且'a: 'b,那么 Box<&'a _> 也是 Box<&'b _> 的子类型,前者可以用于任何需要后者的地方。可以简单理解为给生命周期套个容器的壳,它们之间的大小关系不变。

  2. fn(T)-> _ 是逆变的,或者说“函数对入参是逆变的”。如果一个地方要求函数的入参是 'a,那么任何入参要求小于 'a 的函数也能用。一个极端点的例子是如果调用者允许函数签名是fn(&'static _),那么给它一个 fn<'a>(&'a _) 也可以,毕竟前者对入参的要求比后者强得多。

  3. fn(_)-> T 是协变的,或者说“函数对返回值是协变的”。当调用这希望函数是 fn<'a>() -> 'a _ 时,给出一个 fn() -> 'static _ 是安全的。因为返回值上生命周期更长的话,函数对调用者的约束就更少,如果调用者能适应更强的约束(即更短的返回值生命周期),那么当然可以给一个更长生命周期的返回值。

  4. &T 是协变的,&'a &'long _&'a &'long _ 的子类型,这应该是显然的。

  5. &mut T 是不变的。无论 'a'b 的关系为何,&mut &'a _&mut &'b _ 都无法互相替代。 首先,它肯定不能是逆变,不然把 &mut &'short 当成 &mut &'long 一解引用程序和工作可能一起没了。 而如果是协变的话,我们可以用可变引用给一些 'long 的类型塞一些 'short 的东西进去:

fn evil(v: &mut Vec<&'static str>) {
    let short: &mut Vec<&'_ str> = v; // 既然是协变,那么 &mut Vec<&'static _> 代替 &mut Vec<&'short _>传给 short 很合理吧
    short.push("temp".to_string()); // 这安全吗?
}
  1. 基于和4/5类似的理由,*const TT 是协变的,*mut T/UnsafeCell<T>T 是不变的。

用户自定义类型的情况取决于其字段是什么,规则是:

  1. 如果所有使用 T 的字段对 T 都是协变的,那么整个类型对T是协变的
  2. 如果所有使用 T 的字段对 T 都是逆变的,那么整个类型对T是逆变的
  3. 其余情况均为不变

高阶生命周期#

高阶生命周期(Higher-Rank Trait Bounds)是为了解决生命周期作为模板参数时存在的局限性。我们知道 rust 会将模板函数或类型进行单例化,但当生命周期作为模板参数时,会导致一种奇怪的结果

let pass = |_: &i32| ();
let reject = |_| ();
{
    let x = 1i32; // 假设 x 的生命周期是 '1
    pass(&x);
    reject(&x);
}
let y = 2i32; // 假设 y 的生命周期是 '2
pass(&y);
// 取消下面这行的注释,会得到一个错误
// reject(&y);

rust-analyzer会提示 passreject 的类型都是 impl Fn(&i32),但是 reject 就是无法用两次。

区别在于生命周期标注其实是不一样的:

  • pass: for<'a> Fn(&'a i32)
  • reject: Fn<'a>(&'a i32)

for<'a> 表示对于“所有可能的 'a”,相对地,reject 的类型表示就是“只针对特定的'a”。其区别正如代码所展示的: 在花括号内,rust 会确定 reject'a 正是 '_1 (注意 reject 是个“变量”,所以它应该有具体的类型而不是什么模板),因而 reject 的类型就被固定为 Fn(&'_1 i32)。 第二次传入了一个 &'_2 i32 且由于此类型不是 &'_1 i32 的子类型,当然无法编译成功。

相反,pass 的类型允许传入“所有可能的” 'a,从而解决了 reject 的问题。rustc 在这里使用的技术称为“晚绑定”(later binding),顾名思义,就是在第一次被调用时先不急着确定其具体类型,在后面再来确定。 之所以省略类型标注就变成 reject 的类型了,也是因为无法保证类型推断发生的时间比晚绑定更早,因而需要做完整类型推断的类型不会参与晚绑定(不完整的类型推断是指 |_:&_|() 这样的声明方式)。类似的事情除了晚绑定,还会发生在 coerce 身上,不过那和生命周期关系不大,暂且不表。

对于程序员而言,可能用到HRTB的地方也往往涉及到带引用的闭包:

fn call_closure<'a, F>(callback: F) where F: Fn(&'a str, &'a str)->&'a str {
    let s1 = "Hello".to_string();
    let s2 = "World".to_string();
    callback(&s1, &s2);
}

这里 F 的类型要求手动标注生命周期,但是 'a 这个标注出现在函数的模板参数里,意味着它是一个比 call_closure 长的生命周期,函数内的 s1s2 的生命周期则不能超出 call_closure,因而rust会拒绝把 s1s2 传递给 callback。 这时就需要改成

fn call_closure<F>(callback: F) where F: for<'a> Fn(&'a str, &'a str)->&'a str {
    let s1 = "Hello".to_string();
    let s2 = "World".to_string();
    callback(&s1, &s2);
}

手操生命周期#

’static#

'static作为生命周期讲的时候意味着整个程序运行期间都存活。显然,&'staitc T是任意 &T 的子类型。

'static出现在trait中时,则意味着实现该trait的类型要么不含引用,要么所有引用都是'static的。

擦除生命周期#

Rust中的指针是不含生命周期的,这意味着一个&'a T转成*const T后可以继续转为&'static T,无疑这是一个相当容易不安全的操作。

在下一代借用检查器Polonius就绪之前,却可以利用这个性质小小地开个洞

fn get_default<'r, K: Hash + Eq + Copy, V: Default>(
    map: &'r mut HashMap<K, V>,
    key: K,
) -> &'r mut V {
    // 转成指针绕过None分支里的借用检查
    let p_map = map as *mut _;
    match map.get_mut(&key) {
        Some(value) => value, 
        None => {
            let map: &mut HashMap<K, V> = unsafe { &mut *p_map };
            map.insert(key, V::default());
            map.get_mut(&key).unwrap()
        } 
    } 
}

Polonius之前的借用检查器会认为mapSome分支里已经被借用了,所以在None分支里不能再用。而因为指针是不携带生命周期的,在None分支里可以用它再转回引用。

PhantomData#

既然指针不含生命周期,那么下面这样的结构就很容易被用出问题

struct Slice<T> {
    start: *const T,
    end: *const T,
}

作为一个Slice,它理应是对某段数据的引用,具有某个特定的生命周期,然而这样的类型声明里并没有体现出这一点,完全被视为了Owned类型。但是它确实每个字段上又不需要生命周期标注,我们不能加一个没用过的生命周期标注上去。

这时就轮到PhantomData出场了,PhantomData的作用是让这个结构体“好像有这个字段一样”。

struct Slice<'a, T> {
    start: *const T,
    end: *const T,
    _phantom: PhantomData<&'a T>,
}

因为“好像有这个字段”,所以编译器在做静态分析时会认为Slice真的有&'a T,从而合适地为它分配一个生命周期参数。当然实际上在运行中这个字段没有任何作用,不产生任何运行时开销。

PhantomData的意义就是让类型在静态检查面前表现得像有这个类型。例如

struct NotSend {
    data: i32,
    // 静态检查认为此类型包含*const u8,所以不是Send类型
    // 在不能用!Send标记的时候有用
    _phantom: PhantomData<*const u8>
}

struct Invariant<T> {
    data: Vec<T>,
    // 静态检查认为此类型包含UnsafeCell<T>,所以对T是不变的。
    _phantom: PhantomData<UnsafeCell<T>>
}

may_dangle#

当某个结构体携带了生命周期标注,同时又手动实现了Drop时,可能会发生生命周期错误。

struct RefData<'a> {
    buf:&'a [u8]
}
struct Data<'a> {
    ref_buf:Option<RefData<'a>>
    data: Box<[u8]>
}

fn main() {
    let mut data = Data {ref_buf: None, data: Box::new([1,2,3])};
    data.ref_buf = Some(RefData{buf: &data.data});
}

这是合法的rust代码,DataRefData确实被实例化成了相同的生命周期。然而如果给RefData添加了手动的Drop实现rustc就会立刻报错。

原因是现在的rustc并不会细致地去检查Drop的内部实现,程序员在Drop函数内可以先销毁Data::data再销毁Data::ref_buf,如果在销毁Data::data调用了Data::ref_buf,显然就是一个内存错误。 故而rust采取了“宁杀错勿放过”的态度。如果程序员确认自己在Drop内的实现没有产生生命周期问题,可以用#[may_dangle]标注。

use<‘a>#

Rust的RPIT(Return Position Impl Trait)特性可能会引入一类生命周期错误。

fn process_stream<'a>(ctx:&'a Context, data: Vec<Data>) -> impl Iterator<Item=Result> {
    data.into_iter().map(|datum| ctx.process(datum))
}

这个代码在2024版之前是编不过的,因为闭包捕获了ctx,我们当然应该要求返回类型也具有'a的生命周期,此时编译器会提示我们在返回类型后加上'a。然而问题在于,一旦我们写出impl Iterator<Item=Result> + 'a,rustc会认为Data也需要outlive 'a。在rustc看来,这个返回值类型是从Data的迭代器派生而来的,Data在这里就是impl Iterator<Item=Result>的隐藏类型,任何加在impl Trait上的约束都会施加在Data上。

总之,这种代码的问题在于我们期望返回的迭代器形式应该是

struct Iter<'a> {
    ctx: &'a Context,
    data: Vec<Data>
}

但是默认情况下RPIT不会捕获生命周期参数,它生成的类型类似于

struct Iter {
    ctx: &Context,
    data: Vec<Data>
}

无疑,这个类型是不合法的,需要加上生命周期标注,然而一旦手动写成了impl ... + 'a的形式,+ 后面的生命周期会对整个类型起效:

struct Iter<'a> {
    ctx: &'a Context,
    data: Vec<Data<'a>>
}

这当然并非我们所期望的。

在2024版之后,RPIT将会自动捕获包括生命周期在内的所有泛型参数,而不仅仅是类型泛型参数。这意味着2024版之后我们无需写+ 'a,编译器会自动认为返回类型需要活得比'a长,当然这和Data无关。

这种新的默认行为会带来另一种问题,

fn iter_range<'a>(slice: &'a[u8])-> impl Iterator<usize> {
    0..slice.len()
}

因为默认情况下会捕获所有生命周期泛型参数,这么写的话编译器会认为返回值类型需要 outlive 'a,但实际上并不需要,毕竟我们只是立刻获取了slice的长度信息,在迭代过程中不需要再借用slice了。为此需要使用use语法显示指定需要捕获的泛型参数:

fn iter_range<'a>(slice: &'a[u8])-> impl Iterator<usize> + use<> {
    0..slice.len()
}

因为没有要捕获的参数,所以只写use<>就可以————理想的写法是这样,但编译器尚未支持空use的写法(#130043)。一种临时解决方案是

fn iter_range<'a>(slice: &'a[u8])-> impl Iterator<usize> + 'static {
    0..slice.len()
}

'static意味着返回值类型不捕获任何生命周期泛型参数。

Too many lifetime marks
https://blog.lambdaris.page/posts/rust_lifetime/
作者
Lambdaris
发布于
2024-09-10
许可协议
CC BY-SA 4.0