为什么在 Rust 中需要显式生命周期?

278

我正在阅读 Rust 书籍的 生命周期章节 , 我看到了一个命名/显式生命周期的例子:

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

我很清楚编译器正在防止的错误是对分配给“x”的引用的“使用后释放”:在内部作用域完成后,“f”和因此“&f.x”变得无效,不应该被分配给“x”。我的问题是,问题本可以通过分析而不使用明确的“a”生命周期轻松解决,例如通过推断将引用非法分配到更广泛的范围(“x = &f.x;”)。在哪些情况下需要显式生命周期才能防止使用后释放(或其他类)错误?

1
这篇文章已经cross posted to Reddit - Shepmaster
4
对于未来阅读此问题的读者,请注意它链接到该书的第一版,现在已经有了第二版 :) - carols10cents
11个回答

281

其他答案都有突出的观点(fjh需要显式生命周期的具体示例),但缺少一个关键点:当编译器会告诉你哪里出了问题时,为什么需要显式生命周期呢?

实际上这与“编译器可以推断类型,为什么需要显式类型”是同一个问题。举个假设的例子:

fn foo() -> _ {  
    ""
}

当然,编译器可以看到我返回了一个&'static str,那么为什么程序员还要输入它呢?
主要原因是,虽然编译器可以看到你的代码执行了什么,但它不知道你的意图。
函数是隔离更改代码影响的自然边界。如果我们允许从代码完全检查生命周期,那么看似无害的更改可能会影响生命周期,进而导致远处函数中的错误。这不是一个假设性的例子。据我所知,当你依赖类型推断来进行顶层函数时,Haskell就存在这个问题。Rust解决了这个特定的问题。
对于编译器来说,还有一个效率上的好处——只需要解析函数签名即可验证类型和生命周期。更重要的是,这对程序员也有效率上的好处。如果没有明确的生命周期,那么这个函数会做什么:
fn foo(a: &u8, b: &u8) -> &u8

无法在不检查源代码的情况下确定,这将违反大量编程最佳实践。
通过推断将引用非法分配到更广泛范围中。
作用域本质上就是生命周期。更明显地说,生命周期'a'是一个通用的生命周期参数,可以在编译时基于调用站点使用特定的范围进行专门化。
完全不需要显式生命周期来防止[...]错误。生命周期是必需的以防止错误,但显式生命周期是必需的以保护程序员所拥有的少数理智。

27
假设你在另一个模块中使用了一个没有类型签名的顶级函数 f x = x + 1。如果你以后将定义更改为 f x = sqrt $ x + 1,它的类型会从 Num a => a -> a 变成 Floating a => a -> a,这将在所有调用 f 的地方产生类型错误,例如传入 Int 参数的调用点。拥有类型签名可以确保错误只在本地发生。 - fjh
20
作用域本质上就是生命周期。更明确地说,生命周期'a 是一个通用的生命周期参数,可以在调用时用具体的作用域进行特化。哇,这是一个非常棒、启发性的观点。如果能够这么明确地写入书中就太好了。 - corazza
3
谢谢。如果在添加 sqrt $ 前先明确声明类型,那么即使在更改后仅会出现本地错误,而不是在其他许多地方出现错误(如果我们不想改变实际类型,这将更好),这就是重点,你的理解正确吗? - corazza
7
@jco 没错。不指定类型意味着你可能会意外改变函数的接口。这也是强烈建议在 Haskell 中注释所有顶层项的原因之一。 - fjh
5
如果一个函数接收了两个引用并返回了一个引用,那么它有时会返回第一个引用,有时会返回第二个引用。在这种情况下,无法推断返回的引用的生命周期。显式生命周期有助于避免/澄清这种情况。 - MichaelMoser
显示剩余10条评论

127

让我们来看下面的例子。

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

编译器无法运行函数,也不知道返回的是哪个变量(x还是y),因此编译器无法确定返回值的生命周期。 - towry
4
借用检查器会执行基于分支的程序分析,因此它确实知道返回值的生命周期。如果函数签名与返回的生命周期不匹配,它将引发编译错误。 - Ekrem Dinçel

27

在下面这个结构体中的生命周期注解:

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 指向的变量存在时间更长。

11
请注意,除了结构定义之外,在这段代码中没有明确的生命周期。编译器完全能够推断出main()中的生命周期。
然而,在类型定义中,明确的生命周期是不可避免的。例如,这里存在歧义:
struct RefPair(&u32, &u32);

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

现在,对于像你提供的简单情况,编译器理论上可以省略生命周期,就像其他地方一样,但这种情况非常有限,并且不值得在编译器中增加额外的复杂性,在清晰度方面的收益至少是可疑的。


3
你能解释一下为什么它们之间差异很大吗? - A.B.
2
@A.B. 第二个要求两个引用共享相同的生命周期。这意味着refpair.1不能比refpair.2更长寿,反之亦然 - 因此,两个引用需要指向具有相同所有者的内容。然而,第一个仅要求RefPair的寿命超过其两个部分的寿命。 - llogiq
3
@A.B.,它编译成功是因为两个生命周期被统一了 - 因为局部生命周期比 'static 更小,所以 'static 可以在局部生命周期可以使用的任何地方使用,因此在您的示例中,p 的生命周期参数将被推断为 y 的局部生命周期。 - Vladimir Matveev
6
@A.B. RefPair<'a>(&'a u32, &'a u32) 的意思是 'a 将会是两个输入生命周期的交集,也就是在这种情况下是 y 的生命周期。 - fjh
1
@llogiq “要求RefPair的生命周期超过其两个部分”?我认为恰恰相反……一个&u32可以在没有RefPair的情况下仍然有意义,而一个带有死引用的RefPair会很奇怪。 - qed
@llogiq 这是一个例子:https://gist.github.com/kindlychung/0641fd3a380768fe47a515a0f9541815 - qed

9
如果一个函数接收两个引用作为参数并返回一个引用,那么该函数的实现有时可能返回第一个引用,有时可能返回第二个引用。无法预测对于给定的调用将返回哪个引用。在这种情况下,不可能推断出返回引用的生命周期,因为每个参数引用可能指向具有不同生命周期的不同变量绑定。显式生命周期帮助避免或阐明这种情况。
同样,如果一个结构包含两个引用(作为两个成员字段),那么结构的成员函数有时可能返回第一个引用,有时可能返回第二个引用。再次,显式生命周期可以避免这种歧义。
在一些简单的情况下,编译器能够推断出生命周期,这称为生命周期省略

7

6

这本书中的案例故意设计得非常简单。生命周期的主题被认为是复杂的。

编译器不能很容易地推断具有多个参数的函数中的生命周期。

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

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

编译器绝对想不到那个。


据我所知,推断具有两个参数的函数返回类型的生命周期将等同于停机问题 - 换句话说,在有限时间内无法确定。 - dstromberg
1
编译器在具有多个参数的函数中无法轻松推断生命周期。除非第一个参数是&self&mut self,否则此引用的生命周期将分配给所有省略的输出生命周期。 - Ondrej Slinták

3
作为 Rust 的新手,我的理解是显式生命周期有两个作用。
1. 在函数上放置显式生命周期注释会限制可能出现在该函数内的代码类型。显式生命周期允许编译器确保您的程序正在执行您想要的操作。
2. 如果您(编译器)想要检查代码片段是否有效,则不必迭代查看每个被调用的函数内部。只需查看由该代码片段直接调用的函数的注释即可。这使得您(编译器)更容易推理您的程序,并使编译时间可管理。
关于第一点,请考虑以下用 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]])

这种行为总是让我感到惊讶。发生的情况是dfar共享内存,因此当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),编译器抱怨生命周期不匹配。在正确的地方。在正确的时间。调试是轻松愉快的。

1
你的示例无法运行的原因很简单,因为Rust只有局部生命周期和类型推断。你所建议的需要全局推断。每当你有一个生命周期无法省略的引用时,必须进行注释。

1
我认为生命周期注释是有关给定引用在接收范围内有效的协议,只要它在源范围内仍然有效。在同一生命周期中声明更多的引用会合并作用域,这意味着所有源引用都必须满足此协议。 这种注释允许编译器检查协议的履行情况。

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