写点什么

Rust 从 0 到 1- 泛型 - 生命周期

用户头像
关注
发布于: 22 小时前
Rust从0到1-泛型-生命周期

在讨论 “所有权-引用和借用” 章节时,我们有一个重要的概念未做介绍:引用的生命周期(lifetime),也就是引用保持有效的作用域。大多数场景下生命周期是可以被推断出的,隐式的,类似大多数场景下,数据类型可以被推断出一样;同样类似于在有多种可能类型的时候必须注明类型,也会出现引用的生命周期无法推断出来的情况(在不同的条件下,引用所关联的对象不同,特别是在运行时,事先指明生命周期,可以让编译器帮助我们发现有问题的引用,避免指针悬空问题),所以 Rust 要求我们使用生命周期参数来明确注明引用的生命周期,以确保运行时引用是绝对有效的。

Rust 生命周期的概念在其它语言中很少见,是 Rust 最与众不同的功能,最终也是为了编写“安全”的应用。本章并未涵盖到它全部的内容,我们会讨论一些常见的生命周期语法,以便于熟悉这个概念。

避免悬空引用

生命周期的主要目的是避免悬空引用(关于悬空引用的问题前面我们已经讨论过),参考下面的例子:

{    let r;
{ let x = 5; r = &x; }
println!("r: {}", r);}
复制代码

上面的例子中,在外部作用域中我们定义了一个没有初值的变量 r,而在内部作用域中我们定义了一个初值为 5 的变量 x,并尝试将 x 的引用赋值给 r 。接着我们在内部作用域结束后,尝试打印 r 的值。我们如果尝试编译,会产生类似以下错误信息:

$ cargo run   Compiling chapter10 v0.1.0 (file:///projects/generic)error[E0597]: `x` does not live long enough  --> src/main.rs:7:17   |7  |             r = &x;   |                 ^^ borrowed value does not live long enough8  |         }   |         - `x` dropped here while still borrowed9  | 10 |         println!("r: {}", r);   |                           - borrow later used here
error: aborting due to previous error
For more information about this error, try `rustc --explain E0597`.error: could not compile `generic`
To learn more, run the command again with --verbose.
复制代码

变量 x 并没有 live long enough,因为 x 在内部作用域结束时就被丢弃了。但此时 r 仍是有效的,如果这段代码可以编译通过,那么 r 将会引用 x 离开作用域时被释放的内存,这时尝试对 r 做任何操作都极有可能无法得到正确的结果。得益于借用检查器,Rust 将不允许这段代码编译通过,从而避免悬空引用。

借用检查器

Rust 编译器有一个借用检查器(borrow checker),它会确保所有的借用都是有效的,参考下面例子的注释:

{    let r;                // ---------+-- 'a                          //          |    {                     //          |        let x = 5;        // -+-- 'b  |        r = &x;           //  |       |    }                     // -+       |                          //          |    println!("r: {}", r); //          |}                         // ---------+
复制代码

我们使用 'a 代表 r 的生命周期, 'b 代表 x 的生命周期。显而易见, 'b 要比 'a 的生命周期要短。在编译时,借用检查器会比较这两个生命周期的大小,并发现借用者 r 拥有生命周期 'a,但是它引用的出借者 x 拥有生命周期 'b :借用者比出借者存在的时间更短(作用域截至时间),并且在出借者生命周期结束后,借用者仍然被用到。

让我们看看没有产生悬空引用可以正确编译的例子:

{    let x = 5;            // ----------+-- 'b                          //           |    let r = &x;           // --+-- 'a  |                          //   |       |    println!("r: {}", r); //   |       |                          //   |       |}                         // --+-------+
复制代码

上面的例子中 x 的生命周期 'b,比 r 的生命周期 'a 要大。因此 r 对 x 的引用就不会产生问题:r 在有效的时候 x 也总是有效。

函数中的生命周期

下面我们尝试编写一个返回两个字符串中较长一个的函数 longest ,它的效果如下,最终应该打印出 The longest string is abcd :

fn main() {    let string1 = String::from("abcd");    let string2 = "xyz";
let result = longest(string1.as_str(), string2); println!("The longest string is {}", result);}
复制代码

注意 longest  函数参数为字符串切片,因为我们不希望 longest 函数获取字符串的所有权。我们先尝试按照之前学习的知识编写 longest  函数:

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

上面的例子是无法编译通过的,它会产生类似以下错误:

$ cargo run   Compiling chapter10 v0.1.0 (file:///projects/generic)error[E0106]: missing lifetime specifier --> src/main.rs:9:33  |9 | fn longest(x: &str, y: &str) -> &str {  |               ----     ----     ^ expected named lifetime parameter  |  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`help: consider introducing a named lifetime parameter  |9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {  |           ^^^^    ^^^^^^^     ^^^^^^^     ^^^
error: aborting due to previous error
For more information about this error, try `rustc --explain E0106`.error: could not compile `generic`
To learn more, run the command again with --verbose.
复制代码

编译器揭示了返回值需要一个生命周期参数,因为函数 longest 在运行时可能返回 x 的引用,也可能返回 y 的引用,Rust 无法在编译时知道将要返回哪一个,事实上我们也无法知道!如果我们无法知道传递给函数的具体值,就不知道到底是 if 还是 else 会被执行;并且,我们也不知道传入的引用的具体生命周期,所以无法像前面的例子中那样通过观察作用域来确定返回的引用是否总是有效。借用检查器同样也无法做出判断,因为它不知道 x 和 y 的生命周期与返回值的生命周期的关系。为了修复这个错误,我们将增加生命周期参数来定义引用 x 和 y 与返回值之间的关系,从而让借用检查器可以做出判断。

Lifetime Annotation 语法

Lifetime Annotation 并不会改变引用的生命周期长短。与函数定义中指定了泛型参数后就可以接受任何类型类似,当指定了生命周期参数后函数也能接受任何引用的生命周期。Lifetime Annotation 描述了多个引用的生命周期相互的关系,同时并不会对其生命周期造成影响。

Lifetime Annotation 的语法不太常见:其名称通常全是小写,并且必须以 ' (撇号)开头。类似于泛型其名称通常非常短,'a 是大多数情况下默认使用的名称。生命周期参数紧跟在引用符 & 之后,其后有一个空格将其与引用的类型分隔开。参考下面的例子:

&i32        // 引用&'a i32     // 带有显式生命周期的引用&'a mut i32 // 带有显式生命周期的可变引用
复制代码

单个引用的 Lifetime Annotation 本身意义不大,因为 Lifetime Annotation 主要是用于告诉 Rust 多个引用的生命周期之间的关系。譬如,如果函数有两个 i32 类型的引用参数 first 和 second,它们的生命周期都为 'a ,这意味生命周期 'a  小于等于引用 first 和 second 的生命周期,也就是 first 和 second 中最短的那个。

函数定义中的 Lifetime Annotation

类似泛型参数,生命周期参数也是声明在函数名和参数列表间的尖括号中。现在我们回过头再来看 longest 函数上下文中的生命周期,我们想要告诉 Rust 参数中的引用和返回值必须拥有相同的生命周期:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {    if x.len() > y.len() {        x    } else {        y    }}
复制代码

例子中的函数定义表明对于生命周期 'a,函数的两个参数,他们都是至少与生命周期 'a 一样长的字符串切片,并且函数会返回一个至少与生命周期 'a 一样长的字符串切片。这就是我们通过生命周期参数告诉 Rust 的约束,我们并没有改变任何参数或返回值的生命周期,只是任何不满足这个约束的参数都将被借用检查器拒绝。注意 longest 函数并不需要知道 x 和 y 具体会存在多久,而只需要知道有某个作用域 'a 将会满足这个约束。

当在函数中使用 Lifetime Annotation 时,其并不存在于函数体的任何代码中。这是因为 Rust 会对函数代码进行分析,但是当函数中的引用来自外部代码时,让 Rust 自动分析出参数或返回值的生命周期几乎是不可能的,因为在每次函数被调用时这些引用的生命周期都可能不同。这也就是为什么我们需要显示的声明生命周期。

当函数 longest 被调用时, 'a 所代表的生命周期是 x 的作用域与 y 的作用域相重叠的那一部分。也就是说 'a 的生命周期等同于 x 和 y 的生命周期中较小的那一个。同样,因为我们用相同的 'a 标注了返回的引用值,所以返回的引用值也需要保证在 x 和 y 中较短的那个生命周期结束之前保证有效。参考下面的例子:

fn main() {    let string1 = String::from("long string is long");    {        let string2 = String::from("xyz");        let result = longest(string1.as_str(), string2.as_str());        println!("The longest string is {}", result);    }}
复制代码

上面的例子中 string1 直到外部作用域结束都是有效的,string2 则在内部作用域中是有效的,而 result 则引用了一些直到内部作用域结束都是有效的值。下面我们再来看另外一个例子:

fn main() {    let string1 = String::from("long string is long");    let result;    {        let string2 = String::from("xyz");        result = longest(string1.as_str(), string2.as_str());    }    println!("The longest string is {}", result);}
复制代码

上面的例子编译会出现类似下面的错误:

$ cargo run   Compiling chapter10 v0.1.0 (file:///projects/generic)error[E0597]: `string2` does not live long enough --> src/main.rs:6:44  |6 |         result = longest(string1.as_str(), string2.as_str());  |                                            ^^^^^^^ borrowed value does not live long enough7 |     }  |     - `string2` dropped here while still borrowed8 |     println!("The longest string is {}", result);  |                                          ------ borrow later used here
error: aborting due to previous error
For more information about this error, try `rustc --explain E0597`.error: could not compile `generic`
To learn more, run the command again with --verbose.
复制代码

上面错误的意思是为了保证 println! 中的 result 是有效的,string2 需要直到外部作用域结束都是有效的,这是应为我们使用 Lifetime Annotation 声明函数的参数和返回值都使用了相同的生命周期 'a。

如果人为的分析上述代码,我们可能会认为是正确的:string1 的长度比 string2 长,因此 result 会返回指向 string1 的引用。因为 string1 尚未离开作用域,对于 println! 来说 string1 的引用仍然是有效的。但是,我们通过 Lifetime Annotation 告诉 Rust 的是:longest 函数的返回值的生命周期应该与其参数中生命周期较短那个保持一致。因此,借用检查器拒绝编译通过,因为在其它场景下可能会存在无效的引用。

大家可以尝试采用不同的值和不同生命周期的引用作为 longest 函数的参数和返回值来检验理解是否正确!

从生命周期的角度去思考

生命周期参数如何声明的依赖函数的具体实现。譬如,如果将 longest 函数的实现改为返回第一个参数而不是最长的一个,就不需要为参数 y 指定生命周期:

fn longest<'a>(x: &'a str, y: &str) -> &'a str {    x}
复制代码

上面的例子中,我们只为参数 x 和返回值指定了生命周期参数 'a,因为 y 的生命周期与 x 和返回值的生命周期没有任何关系。

当从函数返回一个引用,其生命周期需要与其中一个参数的生命周期相匹配。如果返回值没有引用任何一个参数,那么唯一的可能就是它引用了一个函数内部创建的值,那么它将会是一个悬空引用,因为函数内部创建的值将会在函数结束时离开作用域。参考下面的例子:

fn longest<'a>(x: &str, y: &str) -> &'a str {    let result = String::from("really long string");    result.as_str()}
复制代码

即便我们为返回值指定了生命周期参数 'a,但却仍然编译失败,因为返回值的生命周期与参数完全没有关系。错误信息类似下面这样:

$ cargo run   Compiling chapter10 v0.1.0 (file:///projects/generic)error[E0515]: cannot return value referencing local variable `result`  --> src/main.rs:11:5   |11 |     result.as_str()   |     ------^^^^^^^^^   |     |   |     returns a value referencing data owned by the current function   |     `result` is borrowed here
error: aborting due to previous error
For more information about this error, try `rustc --explain E0515`.error: could not compile `generic`
To learn more, run the command again with --verbose.
复制代码

上面的例子中 result 在 longest 函数的结尾将离开作用域并被清理,而我们却尝试返回 result 的引用,这将造成悬空引用,这是 Rust 所不允许的。这种情况下,最好的解决方案是返回一个有所有权的数据类型而不是一个引用,交由函数调用者负责清理这个值。

综上,生命周期就是用来将函数的多个参数与其返回值的生命周期进行关联,一旦声明了它们的关联关系,Rust 就有了足够的信息来阻止会产生悬空指针或是违反内存安全的行为。

结构体定义中的 Lifetime Annotations

目前为止,我们只定义过有所有权类型的结构体。下面,我们将定义包含引用的结构体,参考下面的例子:

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 的实例不能比其 part 字段的引用生命周期更长。例子中 ImportantExcerpt 的实例存放了变量 novel 第一个句子的引用。变量 novel 在 ImportantExcerpt 实例创建之前就存在,且直到 main 函数结束后,所以 ImportantExcerpt 实例中 part 字段的引用是有效的。

隐式生命周期

现在我们已经知道每一个引用都有生命周期,而且我们需要在使用了引用的函数或结构体中声明生命周期。然而,前面章节的一些例子中我们并没有使用 Lifetime Annotations 却能编译成功:

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[..]}
复制代码

在早期版本(pre-1.0)的 Rust 中,上面的例子是不能编译的。所有的引用都必须有明确的生命周期。那时的函数定义是这样的:

fn first_word<'a>(s: &'a str) -> &'a str {
复制代码

后来 Rust 团队发现在某些场景下程序员们总是重复地编写同样的生命周期定义,并且这些场景遵在 Rust 编译器中进行了实现,让借用检查器在这些场景下能推断出生命周期而无需再显示的声明。未来可能会有更多的模式在编译器中实现,将会更少的需要显示声明生命周期。

这些规则被称为生命周期省略规则(lifetime elision rules),如果代码符合这些规则,就无需显示声明生命周期。省略规则并不提供完整的推断:也就是说代码在符合这些规则的条件下,仍然有一些引用的生命周期无法做出判断,那么编译器会直接给出一个错误。这个问题可以通过补充引用的生命周期定义来解决。

函数或方法的参数的生命周期被称为输入生命周期(input lifetimes),其返回值的生命周期被称为输出生命周期(output lifetimes)。编译器目前采用三条规则来判断是否需要显示的声明生命周期。第一条规则适用于输入生命周期,后两条规则适用于输出生命周期。如果之后仍然存在没有计算出生命周期的引用,编译器将会报错。这些规则同时适用于 fn 及 impl :

  • 第一条规则:每一个引用参数都有它自己的生命周期。即有一个引用参数的函数有一个生命周期,如 fn foo<'a>(x: &'a i32),有两个引用参数的函数有两个不同的生命周期,如 fn foo<'a, 'b>(x: &'a i32, y: &'b i32),依此类推。

  • 第二条规则:如果只有一个输入生命周期,那么它被赋予所有输出生命周期,如 fn foo<'a>(x: &'a i32) -> &'a i32。

  • 第三条规则:如果有多个输入生命周期并且其中一个参数是 &self 或 &mut self,说明其是方法(method),所有输出生命周期被赋予 self 的生命周期。

为了更好的理解这些规则,我们将其应用于例子中的函数 first_word 。开始我们并未声明任何生命周期:

fn first_word(s: &str) -> &str {
复制代码

应用第一条规则后函数定义看起来类似下面这样:

fn first_word<'a>(s: &'a str) -> &str {
复制代码

应用第二条规则后函数定义看起来类似下面这样:

fn first_word<'a>(s: &'a str) -> &'a str {
复制代码

第三条规则不适用,不过现在这个函数定义中的所有引用都有了生命周期,已经和早期版本的写法类似,编译器已经可以做出分析,而不需要在显示的声明。

让我们再看看另一个例子,最初的 longest 函数:

fn longest(x: &str, y: &str) -> &str {
复制代码

应用第一条规则后函数定义看起来类似下面这样:

fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
复制代码

第二和第三条规则都不适用,此时返回值仍然没有声明其生命周期,编译器无法对代码进行分析,因此,就会编译失败。

方法定义中的 Lifetime Annotations

当为带有生命周期的结构体实现方法时,其语法依然与泛型参数类似,并且是否需要生命周期参数依然依赖于具体实现,即是否和结构体的字段或方法参数和返回值相关。结构体字段的生命周期必须在 impl 关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。

impl 块里的方法定义中所包含的引用可能与结构体的引用字段相关联,也可能是独立的。另外,得益于生命周期省略规则,我们很多时候无需在方法定义中显示声明生命周期,下面我们以前面例子中的结构体 ImportantExcerpt 为例:

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

impl 和类型名称之后的生命周期参数 'a 是必要的,不过得益于第一条生命周期省略规则我们不需要显示的声明 self 引用的生命周期。下面我们再来看一个适用于第三条生命周期省略规则的例子:

impl<'a> ImportantExcerpt<'a> {    fn announce_and_return_part(&self, announcement: &str) -> &str {        println!("Attention please: {}", announcement);        self.part    }}
复制代码

方法 announce_and_return_part 有两个输入生命周期,在应用第一条和第三条生命周期省略规则后,self、announcement 和 返回值都有了各自的生命周期,这样编译器就可以进行分析判断了。

静态生命周期

还有一种特殊的生命周期需要讨论:静态生命周期 'static,其生命周期存活于整个程序期间。所有的字符串字文本都拥有静态生命周期,我们也可以手动进行声明:

let s: &'static str = "I have a static lifetime.";
复制代码

由于字符串的文本被直接储存在程序的二进制中,总是可用的。因此所有的字符串文本的生命周期都是 'static 。

我们可能会在错误信息的提示中见过使用 'static 的建议,不过将引用声明为 'static 之前,我们需要仔细思考一下这个引用是否真的需要在整个程序的生命周期里都有效。大部分情况,是因为我们尝试创建了一个悬空引用或者生命周期不匹配。

同时使用泛型、trait bounds 和生命周期

让我们看一下在函数中同时使用泛型、trait bounds 和生命周期的语法:

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 }}
复制代码

上面的例子仍然是返回两个字符串切片中较长的函数,不过额外多了一个参数 ann。ann 的类型是泛型 T,它可以是任何实现了 Display trait 的类型,因为它在函数中被打印出来。因为生命周期也是一种泛型,所以生命周期参数 'a 和泛型参数 T 都位于函数名后的同一尖括号列表中。

总结

泛型参数意味着代码可以适用于不同的类型。trait 和 trait bounds 对泛型所需具备的行为做出了约束。生命周期参数指定了引用的生命周期之间的关系,保证不会出现悬空引用。并且这一切都是在编译时进行,不会影响代码的运行时效率!

后面的章节还会对相关的容进行更深入的讨论,包括 trait 对象,生命周期更复杂的场景等。

发布于: 22 小时前阅读数: 18
用户头像

关注

公众号"山 顽石"欢迎大家关注;) 2021.03.23 加入

IT老兵,关注Rust、Java、前端、架构、大数据、AI、算法等;平时喜欢美食、旅游、宠物、健身、篮球、乒乓球等。希望能和大家交流分享。

评论

发布
暂无评论
Rust从0到1-泛型-生命周期