为什么 Rust 需要明确的生命周期?

Why are explicit lifetimes needed in Rust?

我正在阅读 Rust 书的 lifetimes chapter,我在 named/explicit 一生中遇到了这个例子:

struct Foo<'a> {
    x: &'a i32,
}

fn main() {
    let x;                    // -+ x goes into scope
                              //  |
    {                         //  |
        let y = &5;           // ---+ y goes into scope
        let f = Foo { x: y }; // ---+ f goes into scope
        x = &f.x;             //  | | error here
    }                         // ---+ f and y go out of scope
                              //  |
    println!("{}", x);        //  |
}                             // -+ x goes out of scope

我很清楚编译器阻止的错误是 use-after-free 分配给 x 的引用:在内部范围之后完成后,f&f.x 变得无效,并且不应该分配给 x.

我的问题是,如果不使用 使用 explicit 'a 生命周期,问题可以很容易地被分析掉 通过推断对更广泛范围 (x = &f.x;) 的引用的非法分配。

在哪些情况下实际上需要明确的生命周期来防止释放后使用(或其他一些 class?)错误?

请注意,除了结构定义之外,那段代码中没有明确的生命周期。编译器完全能够推断 main().

中的生命周期

然而,在类型定义中,显式生命周期是不可避免的。比如这里有歧义:

struct RefPair(&u32, &u32);

它们应该是不同的生命周期还是应该相同?从使用角度来看确实很重要,struct RefPair<'a, 'b>(&'a u32, &'b u32)struct RefPair<'a>(&'a u32, &'a u32) 有很大不同。

现在,对于简单的情况,比如您提供的情况,编译器理论上可以elide lifetimes像在其他地方那样,但是这种情况非常有限并且不值得在编译器中增加额外的复杂性,这种清晰度的提高至少值得怀疑。

让我们看看下面的例子。

fn foo<'a, 'b>(x: &'a u32, y: &'b u32) -> &'a u32 {
    x
}

fn main() {
    let x = 12;
    let z: &u32 = {
        let y = 42;
        foo(&x, &y)
    };
}

在这里,明确的生命周期很重要。之所以编译,是因为 foo 的结果与其第一个参数 ('a) 具有相同的生命周期,因此它可能比第二个参数长寿。这由 foo 签名中的生命周期名称表示。如果您将调用中的参数切换为 foo,编译器会抱怨 y 寿命不够长:

error[E0597]: `y` does not live long enough
  --> src/main.rs:10:5
   |
9  |         foo(&y, &x)
   |              - borrow occurs here
10 |     };
   |     ^ `y` dropped here while still borrowed
11 | }
   | - borrowed value needs to live until here

书中的案例在设计上非常简单。生命周期的话题被认为是复杂的。

编译器无法轻松推断具有多个参数的函数的生命周期。

此外,我自己的 optional crate 有一个 OptionBool 类型和一个 as_slice 方法,其签名实际上是:

fn as_slice(&self) -> &'static [bool] { ... }

编译器绝对不可能解决这个问题。

其他答案都有要点(),但遗漏了一件关键的事情:当编译器会告诉你你弄错了时,为什么还需要明确的生命周期?

这其实和"why are explicit types needed when the compiler can infer them"是同一个问题。一个假设的例子:

fn foo() -> _ {  
    ""
}

当然,编译器可以看到我返回的是一个&'static str,为什么程序员要输入它呢?

主要原因是虽然编译器可以看到你的代码做了什么,但它不知道你的意图是什么。

函数是防火墙更改代码影响的天然边界。如果我们允许从代码中完全检查生命周期,那么看似无害的更改可能会影响生命周期,这可能会导致远处的函数出错。这不是一个假设的例子。据我了解,当您依赖顶级函数的类型推断时,Haskell 会出现此问题。 Rust 将这个特殊问题扼杀在萌芽状态。

编译器还有一个效率优势——只需解析函数签名即可验证类型和生命周期。更重要的是,它对程序员有效率上的好处。如果我们没有明确的生命周期,这个函数会做什么:

fn foo(a: &u8, b: &u8) -> &u8

如果不检查源代码就无法判断,这将违背大量的编码最佳实践。

by inferring an illegal assignment of a reference to a wider scope

作用域本质上是生命周期。更清楚一点,生命周期 'a 是一个 通用生命周期参数 ,可以根据调用站点在编译时针对特定范围进行专门化。

are explicit lifetimes actually needed to prevent [...] errors?

完全没有。 需要生命周期来防止错误,但需要明确的生命周期来保护程序员的小理智。

以下结构中的生命周期注解:

struct Foo<'a> {
    x: &'a i32,
}

指定 Foo 实例不应超过它包含的引用(x 字段)。

您在 Rust 书中遇到的示例并未说明这一点,因为 fy 变量同时超出范围。

一个更好的例子是:

fn main() {
    let f : Foo;
    {
        let n = 5;  // variable that is invalid outside this block
        let y = &n;
        f = Foo { x: y };
    };
    println!("{}", f.x);
}

现在,f 确实比 f.x 指向的变量长寿。

我在这里找到了另一个很好的解释:http://doc.rust-lang.org/0.12.0/guide-lifetimes.html#returning-references

In general, it is only possible to return references if they are derived from a parameter to the procedure. In that case, the pointer result will always have the same lifetime as one of the parameters; named lifetimes indicate which parameter that is.

如果一个函数接收两个引用作为参数并且 return 是一个引用,那么该函数的实现可能有时 return 第一个引用,有时是第二个引用。对于给定的调用,无法预测哪个参考将被 returned。在这种情况下,不可能推断 returned 引用的生命周期,因为每个参数引用可能引用具有不同生命周期的不同变量绑定。明确的生命周期有助于避免或澄清这种情况。

同样,如果一个结构包含两个引用(作为两个成员字段),则该结构的成员函数有时可能 return 第一个引用,有时是第二个引用。明确的生命周期再次防止这种歧义。

在一些简单的情况下,lifetime elision 编译器可以推断生命周期。

你的例子不起作用的原因很简单,因为 Rust 只有本地生命周期和类型推断。您的建议需要全局推理。每当你有一个不能省略其生命周期的引用时,就必须对其进行注释。

作为 Rust 的新手,我的理解是显式生命周期有两个目的。

  1. 在函数上放置显式生命周期注释会限制可能出现在该函数内部的代码类型。显式生命周期允许编译器确保您的程序按预期运行。

  2. 如果您(编译器)想要检查一段代码是否有效,您(编译器)将不必反复查看每个调用的函数。看一下那段代码直接调用的函数的注解就可以了。这使您的程序更容易为您(编译器)推理,并使编译时间易于管理。

关于第 1 点,考虑在 Python 中编写的以下程序:

import pandas as pd
import numpy as np

def second_row(ar):
    return ar[0]

def work(second):
    df = pd.DataFrame(data=second)
    df.loc[0, 0] = 1

def main():
    # .. load data ..
    ar = np.array([[0, 0], [0, 0]])

    # .. do some work on second row ..
    second = second_row(ar)
    work(second)

    # .. much later ..
    print(repr(ar))

if __name__=="__main__":
    main()

这将打印

array([[1, 0],
       [0, 0]])

这种行为总是让我吃惊。正在发生的事情是 df 正在与 ar 共享内存,因此当 df 的某些内容在 work 中发生变化时,该变化也会感染 ar .但是,在某些情况下,出于内存效率的原因(无副本),这可能正是您想要的。这段代码中的真正问题是函数 second_row 返回第一行而不是第二行;祝调试顺利。

考虑用 Rust 编写的类似程序:

#[derive(Debug)]
struct Array<'a, 'b>(&'a mut [i32], &'b mut [i32]);

impl<'a, 'b> Array<'a, 'b> {
    fn second_row(&mut self) -> &mut &'b mut [i32] {
        &mut self.0
    }
}

fn work(second: &mut [i32]) {
    second[0] = 1;
}

fn main() {
    // .. load data ..
    let ar1 = &mut [0, 0][..];
    let ar2 = &mut [0, 0][..];
    let mut ar = Array(ar1, ar2);

    // .. do some work on second row ..
    {
        let second = ar.second_row();
        work(second);
    }

    // .. much later ..
    println!("{:?}", ar);
}

编译这个,你得到

error[E0308]: mismatched types
 --> src/main.rs:6:13
  |
6 |             &mut self.0
  |             ^^^^^^^^^^^ lifetime mismatch
  |
  = note: expected type `&mut &'b mut [i32]`
             found type `&mut &'a mut [i32]`
note: the lifetime 'b as defined on the impl at 4:5...
 --> src/main.rs:4:5
  |
4 |     impl<'a, 'b> Array<'a, 'b> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^
note: ...does not necessarily outlive the lifetime 'a as defined on the impl at 4:5
 --> src/main.rs:4:5
  |
4 |     impl<'a, 'b> Array<'a, 'b> {
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^

实际上你得到了两个错误,还有一个 'a'b 的角色互换了。查看second_row的注释,我们发现输出应该是&mut &'b mut [i32],即输出应该是对生命周期为'b的引用的引用(第二个的生命周期Array 行)。但是,因为我们要返回第一行(生命周期为 'a),编译器会抱怨生命周期不匹配。在正确的地方。在正确的时间。调试轻而易举。

我认为生命周期注解是关于给定 ref 的合同,仅当它在源范围内保持有效时才在接收范围内有效。在相同的生命周期中声明更多引用会合并范围,这意味着所有源引用都必须满足此合同。 这样的注释允许编译器检查合同的履行。