【Rust学习之旅】令人闻风丧胆的”生命周期“ (上) (十一)

518 阅读10分钟

上一期我们说到了泛型与trait,这一期我们来看看,rust劝退排行榜前列的 生命周期。前面我们提到vue也有生命周期,这完全是八竿子打不着的概念😂,非说要有关联,就是字面意思一样。

什么是生命周期?

生命周期,简而言之就是引用的有效作用域。在大多数时候,我们无需手动的声明生命周期,因为编译器可以自动进行推导,用类型来类比下:

  • 就像编译器大部分时候可以自动推导类型 <-> 一样,编译器大多数时候也可以自动推导生命周期
  • 在多种类型存在时,编译器往往要求我们手动标明类型 <-> 当多个生命周期存在,且编译器无法推导出某个引用的生命周期时,就需要我们手动标明生命周期

生命周期与悬垂指针

生命周期的主要作用是避免悬垂引用,它会导致程序引用了本不该引用的数据:


#![allow(unused)]
fn main() {
{
    let r;

    {
        let x = 5;
        r = &x;
    }

    println!("r: {}", r);
}
}

这段代码有几点值得注意:

  • let r; 的声明方式貌似存在使用 null 的风险,实际上,当我们不初始化它就使用时,编译器会给予报错
  • r 引用了内部花括号中的 x 变量,但是 x 会在内部花括号 } 处被释放,因此回到外部花括号后,r 会引用一个无效的 x

此处 r 就是一个悬垂指针,它引用了提前被释放的变量 x

概括起来就是:引用的变量,被提前释放,导致无效引用,这就是悬垂指针

借用检查

为了保证 Rust 的所有权和借用的正确性,Rust 使用了一个借用检查器(Borrow checker),来检查我们程序的借用正确性。

要了解生命周期,需要好好理解一下作用域,生命周期其实就是描述一个变量的创建到释放的过程.

fn main() {
    let r;                // ---------+-- 'a
                          //          |
    {                     //          |
        let x = 5;        // -+-- 'b  |
        r = &x;           //  |       |
    }                     // -+       |
                          //          |
    println!("r: {}", r); //          |
}                         // ---------+

这段代码,对变量x的借用就是不合法的,因为r借用了x,x在自己的作用域结束的时候释放了。这里就产生了无效引用。

Rust 的借用检查器(Borrow checker),其实就是对作用域的比较。

函数中的生命周期

让我们来编写一个返回两个字符串 slice 中较长者的函数。这个函数获取两个字符串 slice 并返回一个字符串 slice。

这个函数获取作为引用的字符串 slice,而不是字符串,因为我们不希望 longest 函数获取参数的所有权。


fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}

这段代码,会发生错误,会抱怨缺少生命周期标注。因为 Rust 并不知道将要返回的引用是指向 x 或 y。事实上我们也不知道,因为函数体中 if 块返回一个 x 的引用而 else 块返回一个 y 的引用!

因此,这时就回到了文章开头说的内容:在存在多个引用时,编译器有时会无法自动推导生命周期,此时就需要我们手动去标注,通过为参数标注合适的生命周期来帮助编译器进行借用检查的分析。

生命周期标注语法

生命周期标注并不会改变任何引用的实际作用域,就像typescript的any大法欺骗编译器,让它通不找我们麻烦,但是代码有问题,还是有问题。

生命周期的语法也颇为与众不同,以 ' 开头,名称往往是一个单独的小写字母,大多数人都用 'a 来作为生命周期的名称。 如果是引用类型的参数,那么生命周期会位于引用符号 & 之后,并用一个空格来将生命周期和引用参数分隔开:

#![allow(unused)]
fn main() {
    &i32        // 一个引用
    &'a i32     // 具有显式生命周期的引用
    &'a mut i32 // 具有显式生命周期的可变引用
}

函数签名中的生命周期注解

下面我们来改造一下上面的代码


#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
}

需要注意的点如下:

  • 和泛型一样,使用生命周期参数,需要先声明 <'a>
  • xy 和返回值至少活得和 'a 一样久(因为返回值要么是 x,要么是 y)

现在函数签名表明对于某些生命周期 'a,函数会获取两个参数,他们都是与生命周期 'a 存在的一样长的字符串 slice。函数会返回一个同样也与生命周期 'a 存在的一样长的字符串 slice。它的实际含义是 longest 函数返回的引用的生命周期与函数参数所引用的值的生命周期的较小者一致。

在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过

深入理解生命周期

函数的返回值如果是一个引用类型,那么它的生命周期只会来源于

  • 函数参数的生命周期
  • 函数体中某个新建引用的生命周期

生命周期语法用来将函数的多个引用参数和返回值的作用域关联到一起,一旦关联到一起后,Rust 就拥有充分的信息来确保我们的操作是内存安全的。

结构体中的生命周期

前面的学习中我们定义的结构体全都包含拥有所有权的类型。

结构体也可以定义包含引用的数据,不过这需要为结构体定义中的每一个引用添加生命周期注解。

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
}

ImportantExcerpt 结构体中有一个引用类型的字段 part,因此需要为它标注上生命周期。结构体的生命周期标注语法跟泛型参数语法很像,需要对生命周期参数进行声明 <'a>。该生命周期标注说明,结构体 ImportantExcerpt 所引用的字符串 str 必须比该结构体活得更久

生命周期消除

实际上,对于编译器来说,每一个引用类型都有一个生命周期,那么为什么我们在使用过程中,很多时候无需标注生命周期?例如:


#![allow(unused)]
fn main() {
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}
}

该函数的参数和返回值都是引用类型,尽管我们没有显式的为其标注生命周期,编译依然可以通过。

rust编译器可以自动推断生命周期,就像类型注解一样,当只有编译器无法推断的时候,才需要人为标注,前面我们提到的声明vector的时候也需要手动添加注解,这里的生命周期也是一样。

生命周期消除大法

两点注意

  • 消除规则不是万能的,若编译器不能确定某件事是正确时,会直接判为不正确,那么你还是需要手动标注生命周期
  • 函数或者方法中,参数的生命周期被称为 输入生命周期,返回值的生命周期被称为 输出生命周期

三条规则

  • 每一个引用参数都会获得独自的生命周期

例如一个引用参数的函数就有一个生命周期标注: fn foo<'a>(x: &'a i32),两个引用参数的有两个生命周期标注:fn foo<'a, 'b>(x: &'a i32, y: &'b i32), 依此类推。

  • 若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期

例如函数 fn foo(x: &i32) -> &i32x 参数的生命周期会被自动赋给返回值 &i32,因此该函数等同于 fn foo<'a>(x: &'a i32) -> &'a i32

  • 若存在多个输入生命周期,且其中一个是 &self 或 &mut self,则 &self 的生命周期被赋给所有的输出生命周期

拥有 &self 形式的参数,说明该函数是一个 方法,该规则让方法的使用便利度大幅提升。

当然这只是省略的情况下编译器的自动推断,你也可以手动标注。

例如第三条规则,若一个方法,它的返回值的生命周期就是跟参数 &self 的不一样怎么办?总不能强迫我返回的值总是和 &self 活得一样久吧?!

这个时候我们就可以手动标注生命周期,当你标注生命周期后,编译器自然会乖乖听你的话。

方法中的生命周期

为具有生命周期的结构体实现方法时,我们使用的语法跟泛型参数语法很相似:

#![allow(unused)]
fn main() {
struct ImportantExcerpt<'a> {
    part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}
}

其中有几点需要注意的:

  • impl 中必须使用结构体的完整名称,包括 <'a>,因为生命周期标注也是结构体类型的一部分
  • 方法签名中,往往不需要标注生命周期,得益于生命周期消除的第一和第三规则

静态生命周期

在 Rust 中有一个非常特殊的生命周期,那就是 'static,拥有该生命周期的引用可以和整个程序活得一样久。

在之前我们学过字符串字面量,提到过它是被硬编码进 Rust 的二进制文件中,因此这些字符串变量全部具有 'static 的生命周期:


#![allow(unused)]
fn main() {
let s: &'static str = "我没啥优点,就是活得久,嘿嘿";
}
  • 生命周期 'static 意味着能和程序活得一样久,例如字符串字面量和特征对象
  • 实在遇到解决不了的生命周期标注问题,可以尝试 T: 'static,有时候它会给你奇迹

事实上,关于 'static, 有两种用法: &'static 和 T: 'static,后续会开一个专门的文章讲这种容易混淆的概念

结合泛型类型参数、trait bounds 和生命周期

让我们简要的看一下在同一函数中指定泛型类型参数、trait bounds 和生命周期的语法!

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";

    let result = longest_with_an_announcement(
        string1.as_str(),
        string2,
        "Today is someone's birthday!",
    );
    println!("The longest string is {}", result);
}

use std::fmt::Display;

fn longest_with_an_announcement<'a, T>(
    x: &'a str,
    y: &'a str,
    ann: T,
) -> &'a str
where
    T: Display,
{
    println!("Announcement! {}", ann);
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

生命周期也是泛型,所以生命周期参数 'a 和泛型类型参数 T 都位于函数名后的同一尖括号列表中。

结语

生命周期,其实就是一个,我们在JavaScript中确定作用域的问题。

在讨论:看谁活得久又是谁得到了无效引用编译器这个大聪明何时能自动推断的问题,只要解决了这三个问题,生命周期也就不难了

当然这里仅简单的讲了一下,后续还有有一期讲解生命周期的,比如:unsafe中的无界生命周期闭包函数中的生命周期、&'static 和 T: 'static的区别。现在只需要消化上面的部分就行了。😄