这个实例如何看起来超越了自己的参数生命周期?

34

在遇到下面的代码之前,我一直认为类型生命周期参数中的lifetime将始终超过其自身的实例。换句话说,给定一个foo:Foo<'a>,那么'a将始终超过foo。然后,@Luc Danton(Playground)向我介绍了这个反驳论证的代码:

#[derive(Debug)]
struct Foo<'a>(std::marker::PhantomData<fn(&'a ())>);

fn hint<'a, Arg>(_: &'a Arg) -> Foo<'a> {
    Foo(std::marker::PhantomData)
}

fn check<'a>(_: &Foo<'a>, _: &'a ()) {}

fn main() {
    let outlived = ();
    let foo;

    {
        let shortlived = ();
        foo = hint(&shortlived);
        // error: `shortlived` does not live long enough
        //check(&foo, &shortlived);
    }

    check(&foo, &outlived);
}
hint 创建的 foo 看起来似乎考虑了一个不会活得比它自己更久的生命周期,而且将对它的引用传递给了一个更广泛的范围内的函数,但代码编译时仍然是精确的。取消代码中所述的注释会触发编译错误。或者将 Foo 改为结构体元组 (PhantomData<&'a ()>) 也会导致相同类型的错误,使得代码无法编译(Playground)。

这怎么成为有效的 Rust 代码了呢?编译器的推断原理是什么?


1
哇,那很奇怪!查看两个提示函数的 MIR,似乎当使用 PhantomData<fn(&'a())> 时,Rust 会丢弃 'a 生命周期。不知道这是一个特性还是一个 Bug :D - Grégory OBANOS
我怀疑答案与方差有关,具体来说是那个随意的评论,即fn(T)T中是逆变的--然而,我还没有完全解释为什么。 - trent
2个回答

77

尽管你的 hint 函数可能是出于最好的意图,但它可能没有你预期的效果。但在我们理解发生了什么之前,我们还有很多内容需要涵盖。


让我们从这个开始:

fn ensure_equal<'z>(a: &'z (), b: &'z ()) {}

fn main() {
    let a = ();
    let b = ();
    ensure_equal(&a, &b);
}

好的,在main函数中,我们定义了两个变量ab。由于它们是由不同的let语句引入的,它们具有不同的生命周期。而ensure_equal需要两个具有相同生命周期的引用。但是,这段代码编译通过了。为什么呢?

这是因为,给定'a: 'b(读作:'a'b更长寿),&'a T&'b T的一个子类型

假设a的生命周期为'ab的生命周期为'b。事实上,'a: 'b,因为a先被引入。在调用ensure_equal时,分别将参数类型定义为&'a ()&'b ()1。这里存在类型不匹配的问题,因为'a'b的生命周期不同。但是编译器并没有放弃!它知道&'a ()&'b ()的一个子类型。换句话说,&'a () &'b ()。编译器将强制将表达式&a转换为类型&'b (),以使两个参数都具有类型&'b ()。这解决了类型不匹配的问题。

如果您对具有生命周期的"子类型"的应用感到困惑,那么让我用Java术语重新表述这个例子。我们将&'a ()替换为Programmer,将&'b ()替换为Person。现在假设Programmer是从Person派生出来的:因此,ProgrammerPerson的一个子类型。这意味着我们可以取一个类型为Programmer的变量,并将其作为期望参数类型为Person的函数的参数传递。这就是为什么以下代码将成功编译的原因:编译器将在main中将T解析为Person

class Person {}
class Programmer extends Person {}

class Main {
    private static <T> void ensureSameType(T a, T b) {}

    public static void main(String[] args) {
        Programmer a = null;
        Person b = null;
        ensureSameType(a, b);
    }
}

这种子类型关系的非直觉之处在于,拥有更长寿命的变量是短寿命变量的子类型。但是可以这样理解,在Java中,假设一个程序员是一个是安全的,但你不能假定一个就是一个程序员。同样的,在Rust中假设一个变量具有的生命周期是安全的,但你不能假定具有某个已知生命周期的变量实际上具有的生命周期。毕竟,Rust中生命周期的整个目的是确保你不会访问超过它们实际生命周期的对象。


现在,让我们来谈谈协变性。那是什么?

协变性是类型构造函数相对于其参数所具有的属性。在Rust中,类型构造函数是具有未绑定参数的通用类型。例如,Vec是一个接受一个T并返回一个Vec<T>的类型构造函数。 &&mut是接受两个输入的类型构造函数:一个生命周期和一个指向的类型。

通常情况下,你期望Vec<T>的所有元素具有相同的类型(这里我们不讨论特征对象)。但是协变性可以让我们作弊。

&'a T'aT上是协变的。这意味着无论我们在类型参数中看到&'a T,都可以将其替换为&'a T的子类型。让我们看看它是如何工作的:

fn main() {
    let a = ();
    let b = ();
    let v = vec![&a, &b];
}

我们已经确定了a和b有不同的生命周期,并且表达式&a和&b的类型不同1。那么为什么我们可以用它们来创建一个Vec呢?推理与上面相同,所以我会总结一下:&a被强制转换为&'b(),因此v的类型是Vec<&'b()>。
在Rust中,fn(T)是一个特殊情况,涉及到方差。对于T,fn(T)是逆变的。让我们构建一个函数的Vec!
fn foo(_: &'static ()) {}
fn bar<'a>(_: &'a ()) {}

fn quux<'a>() {
    let v = vec![
        foo as fn(&'static ()),
        bar as fn(&'a ()),
    ];
}

fn main() {
    quux();
}

这个代码可以编译通过。但是在quux函数中,v的类型是什么呢?它是Vec<fn(&'static ())>还是Vec<fn(&'a ())>

我来给你一个提示:

fn foo(_: &'static ()) {}
fn bar<'a>(_: &'a ()) {}

fn quux<'a>(a: &'a ()) {
    let v = vec![
        foo as fn(&'static ()),
        bar as fn(&'a ()),
    ];
    v[0](a);
}

fn main() {
    quux(&());
}

这段代码无法编译。以下是编译器的错误信息:

error[E0495]: cannot infer an appropriate lifetime due to conflicting requirements
 --> <anon>:5:13
  |
5 |       let v = vec![
  |  _____________^ starting here...
6 | |         foo as fn(&'static ()),
7 | |         bar as fn(&'a ()),
8 | |     ];
  | |_____^ ...ending here
  |
note: first, the lifetime cannot outlive the lifetime 'a as defined on the body at 4:23...
 --> <anon>:4:24
  |
4 |   fn quux<'a>(a: &'a ()) {
  |  ________________________^ starting here...
5 | |     let v = vec![
6 | |         foo as fn(&'static ()),
7 | |         bar as fn(&'a ()),
8 | |     ];
9 | |     v[0](a);
10| | }
  | |_^ ...ending here
note: ...so that reference does not outlive borrowed content
 --> <anon>:9:10
  |
9 |     v[0](a);
  |          ^
  = note: but, the lifetime must be valid for the static lifetime...
note: ...so that types are compatible (expected fn(&()), found fn(&'static ()))
 --> <anon>:5:13
  |
5 |       let v = vec![
  |  _____________^ starting here...
6 | |         foo as fn(&'static ()),
7 | |         bar as fn(&'a ()),
8 | |     ];
  | |_____^ ...ending here
  = note: this error originates in a macro outside of the current crate

error: aborting due to previous error

我们试图使用一个 &'a () 参数调用向量中的一个函数。但是 v[0] 期望一个 &'static (),并且不能保证 'a'static,因此这是无效的。因此,我们可以得出结论,v 的类型是 Vec<fn(&'static ())>。正如您所看到的,逆变是协变的相反:我们可以用一个更长的生命周期替换一个较短的生命周期。
哇,现在回到你的问题。首先,让我们看看编译器对 hint 调用的处理结果。 hint 具有以下签名:
fn hint<'a, Arg>(_: &'a Arg) -> Foo<'a>

Foo 是针对 'a 逆变的,因为 Foo 包装了一个 fn(或者更确切地说,通过 PhantomData 假装包装了一个 fn,但是在讨论协变性时这并没有什么区别;两者具有相同的效果),fn(T) 针对 T 逆变,并且这里的 T&'a ()

当编译器尝试解析对 hint 的调用时,它只考虑了 shortlived 的生命周期。因此,hint 返回一个带有 shortlived 生命周期的 Foo。但是当我们尝试将其分配给变量 foo 时,就会出现问题:类型上的生命周期参数总是比类型本身更长寿,而 shortlived 的生命周期不会比 foo 的生命周期更长寿,因此,显然,我们不能使用那种类型来代替 foo。如果 Foo 针对 'a 协变,那就没戏了,你会得到一个错误。但是 Foo 是针对 'a 逆变的,因此我们可以将 shortlived 的生命周期替换为一个更大的生命周期。该生命周期可以是任何比 foo 的生命周期更长寿的生命周期。请注意,“更长寿”并不等同于“严格更长寿”:区别在于 'a: 'a'a'a 更长寿)是正确的,但是 'a 严格比 'a 更长寿是错误的(即生命周期被认为比自己更长寿,但它并没有“严格地比自己更长寿”)。因此,我们可能会得到类型为 Foo<'a>foo,其中 'a 正好是 foo 本身的生命周期。

现在让我们看一下 check(&foo, &outlived);(这是第二个)。这个函数编译通过,因为 &outlived 被强制转换,以使其生命周期缩短以匹配 foo 的生命周期。这是有效的,因为 outlived 的生命周期比 foo 更长寿,并且 check 的第二个参数针对 'a 协变,因为它是一个引用。

为什么check(&foo, &shortlived);不能编译?因为foo的生命周期比&shortlived长。check的第二个参数对于'a具有协变性,但是它的第一个参数对于'a来说是逆变的,因为Foo<'a>是逆变的。也就是说,对于这个调用,这两个参数都试图将'a拉向相反的方向:&foo试图扩大&shortlived的生命周期(这是非法的),而& shortlived则试图缩短&foo的生命周期(这也是非法的)。没有生命周期可以统一这两个变量,因此这个调用是无效的。


1 实际上,这可能是一个简化。我认为参考的生命周期参数实际上代表借用处于活动状态的区域,而不是引用的生命周期。在这个例子中,两个借用在包含对ensure_equal的调用的语句中都是活动状态,因此它们具有相同的类型。但是如果你将借用拆分到不同的let语句中,代码仍然可以工作,因此解释仍然有效。也就是说,为了使借用有效,引用必须超出借用的区域的生命周期,因此当我考虑生命周期参数时,我只关心参考对象的生命周期,并将借用单独处理。


15
这可能是Rust标签中最佳的答案之一。这里有很多信息,太棒了。感谢您抽出时间写下这篇文章。(我知道"感谢"评论往往不受欢迎...但看看这个回答吧!) - Simon Whitehead

6
另一种解释是注意到Foo实际上没有持有任何具有'a生命周期的引用。相反,它持有一个接受生命周期为'a的引用的函数。

您可以使用实际函数而不是PhantomData来构造这种行为。 您甚至可以调用该函数:

struct Foo<'a>(fn(&'a ()));

fn hint<'a, Arg>(_: &'a Arg) -> Foo<'a> {
    fn bar<'a, T: Debug>(value: &'a T) {
        println!("The value is {:?}", value);
    }
    Foo(bar)
}

fn main() {
    let outlived = ();
    let foo;
    {
        let shortlived = ();
        // &shortlived is borrowed by hint() but NOT stored in foo
        foo = hint(&shortlived);
    }
    foo.0(&outlived);
}

正如Francis在他出色的答案中所解释的那样,outlived的类型是shortlived类型的子类型,因为其生命周期更长。因此,在foo内部的函数可以接受它,因为它可以被强制转换为shortlived的(更短的)生命周期。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接