原文链接:Understanding lifetimes in Rust
rust

今天是个好日子,你又想试试Rust编程的感觉了。上一次尝试非常顺滑,除了遭遇了一点点借用检查的问题,但解决问题的过程中你理解了它的工作机制,一切都是值得的!

借用检查可不能阻止你的Rust宏伟计划,攻克它之后你感觉写Rust代码都写得飞起了。然后你又一次输入了编译指令,开始编译,然而:

error[E0597]: `x` does not live long enough

又来?你深呼吸一口气,放松心情,看着这条报错信息。“存活得不够久(does not live long enough)”,这到底是啥意思?

生命周期简介

Rust编译器用生命周期来记录引用是否还有。引用检查也是借用检查机制的一大职责,和生命周期机制一起确保你使用的引用是有效的。

生命周期标注能够让借用检查判断引用是否有效。大多数情况下,借用检查机制自己就可以推断引用的生命周期,但是在代码中显式地使用生命周期标注,能够让借用检查机制直接得到引用有效性的相关信息。

本文将介绍生命周期的基本概念和使用方式,以及一些常用场景,需要你对Rust的相关概念有一定了解(比如借用检查)。

生命周期标志

稍微要注意的是,Rust生命周期的标志和其它语言中的不太一样,是在变量名前加单引号来表示,通常是用从小写字母来进行泛型表示:'a'b 等等。

为什么需要生命周期

Rust为什么需要这样一个奇怪的特性呢?答案就在于Rust的所有权机制。借用检查管理着内存的分配与释放,同时确保不会存在指向被释放内存的错误引用。相似的,生命周期也是在编译阶段进行检查,如果存在无效的引用也就过不了编译。

在函数返回引用以及创建引用结构体这两种场景,生命周期尤为重要,很容易出错。

例子

本质上生命周期就是作用域。当变量离开作用域时,它就会被释放掉,此时任何指向它的引用都会变成无效引用。下面是一个从官方文档中拷过来的最简单的示例:

// 这段代码 **无法** 通过编译
{
    let x;
    {                           // create new scope
        let y = 42;
        x = &y;
    }                           // y is dropped

    println!("The value of 'x' is {}.", x);
}

这段代码含有里外两个不同的作用域,当里面的作用域结束后,y 被释放掉了,即使 x 是在外层作用域声明的,但它仍然是无效引用,它引用的值 ”存活得不够久”。

用生命周期的术语来讲,内外作用域分别对应一个 'inner 和一个 'outer 作用域,后者明显比前者更久,当'inner 作用域结束,其内跟随它生命周期的所有变量都会成为不可用。

生命周期省略

当编写接受引用类型变量作为参数的函数时,大多数场景下编译器可以自动推导出变量的生命周期,不用费力去手动标注。这种情况就被称作“生命周期省略”。

编译器使用三条规则来确认函数签名可以省略生命周期:

  • 函数的返回值不是引用类型
  • 函数的入参中最多只有一个引用
  • 函数是个方法(method),即第一个参数是&self 或者 &mut self

示例与常见问题

生命周期很容易就会把脑子绕晕,简单怼一大堆文字介绍也不见得能让你理解它是怎样工作的。理解它的最好方式当然还是在编程实践中、在解决具体问题的过程中。

函数返回引用

Rust中,如果函数没有接收引用作为参数,是无法返回引用类型的,强行返回无法通过编译。如果函数参数中只有一个引用类型,那就不用显示的标注生命周期,所有出参中的引用类型都将视为和入参中的引用具有同样的生命周期。

fn f(s: &str) -> &str {
    s
}

但如果你再加入一个引用类型,即便函数内部并不适用它,编译也将无法通过了。这体现的就是上述第二条规则。

// 这段代码过不了编译
fn f(s: &str, t: &str) -> &str {
    if s.len() > 5 { s } else { t }
}

当一个函数接受多个引用参数时,每个参数都有各自的生命周期,函数返回的引用对应哪一个,编译器无从自动推导。比如下面代码中的 '??? 会是哪一个标注的生命周期?

// 这段代码过不了编译
fn f<'a, 'b>(s: &'a str, t: &'b str) -> &'??? str {
    if s.len() > 5 { s } else { t }
}

试想一下,如果你要使用这个函数返回的引用,应该给它指定一个怎样的生命周期?只有给它生命周期最短的那一个入参的,才能保证它有效,编译器才知道他俩是具有同样的、更短的生命周期的引用。如果像上面那个函数一样,所有入参都有可能被返回,那你只能确保他们的生命周期全都一样,如下:

fn f<'a>(s: &'a str, t: &'a str) -> &'a str {
    if s.len() > 5 { s } else { t }
}

如果函数的入参具有不同的生命周期,但你确切地知道你会返回哪一个,你可以标注对应的生命周期给返回类型,这样入参生命周期的差异也不会产生问题:

fn f<'a, 'b>(s: &'a str, _t: &'b str) -> &'a str {
    s
}

结构体引用

结构体引用的生命周期问题会更棘手一点,最好用非引用类型替代,这样不用担心引用有效性、生命周期持续长度等问题。以我的经验看这通常就是你想要的。

但是有些场景确实需要用结构体引用,尤其是当你想要编写一个不需要数据所有权转移、数据拷贝的代码包,结构体引用可以让原始数据以引用的方式在其它地方被访问,不用处理棘手的数据克隆问题。

举个例子,如果你想编写代码,寻找一段文字的首尾两条句子,并将它们俩存在一个结构体 S 中。不使用数据拷贝,那么就需要使用引用类型,并且给它们标注生命周期:

struct S<'a> {
    first: &'a str,
    last: &'a str,
}

如果段落为空,则返回 None,如果段落只有一条句子,则首尾都返回这一条:

fn try_create(paragraph: &str) -> Option<S> {
    let mut sentences = paragraph.split('.').filter(|s| !s.is_empty());
    match (sentences.next(), sentences.next_()) {
        (Some(first), Some(last)) => Some(S { first, last }),
        (Some(first), None) => Some(S { first, last: first }),
        _ => None,
    }
}

由于该函数符合生命周期自动推导的原则(如返回值非引用类型、只接收最多一个引用入参),因此不用手动给它标注生命周期。要想不改变原始数据的所有权,确实只能考虑输入一个引用参数来解决问题。

总结

本文只是粗略地介绍下Rust中的生命周期。考虑到它的重要性,我推荐再阅读官方文档的"引用有效性与生命周期” 一节,补充更多的概念理解。

如果你还想进阶的话,推荐观看 Jon Gjengset 的:Crust of Rust: Lifetime Annotations,该视频包含了多生命周期的示例,当然也有一些生命周期的介绍,值得一看。


明酱
62 声望6 粉丝