Rust之泛型、特性和生命期(四):验证有生存期的引用

开发环境

  • Windows 10
  • Rust 1.71.0

 

  • VS Code 1.80.1

Rust之泛型、特性和生命期(四):验证有生存期的引用_第1张图片

项目工程

这里继续沿用上次工程rust-demo

验证具有生存期的引用

生存期是我们已经在使用的另一种泛型。生存期不是确保一个类型具有我们想要的行为,而是确保引用在我们需要时有效。

我们在第4章“引用和借用”一节中没有讨论的一个细节是,Rust中的每个引用都有一个生命周期,这是该引用有效的范围。大多数时候,生存期是隐式的,是推断出来的,就像大多数时候,类型是推断出来的一样。只有在可能存在多种类型时,我们才必须对类型进行注释。类似地,当引用的生存期可以以几种不同的方式关联时,我们必须注释生存期。Rust要求我们使用通用的生存期参数来注释这些关系,以确保运行时使用的实际引用绝对有效。

注释生存期甚至不是大多数其他编程语言都有的概念,所以这会让人感到陌生。虽然我们不会在这一章完整地讨论生存期,但是我们会讨论你可能遇到的生存期语法的常见方式,这样你就可以熟悉这个概念了。

用生存期防止悬空引用

生存期的主要目的是防止悬空引用,悬空引用会导致程序引用它想要引用的数据之外的数据。考虑示例16中的程序,它有一个外部作用域和一个内部作用域。

fn main() {
    let r;

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

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

示例16:试图使用其值超出范围的引用

注意:示例16、示例17和示例23中的例子声明了变量,但没有给它们一个初始值,所以变量名存在于外部作用域中。乍一看,这似乎与Rust没有空值相冲突。然而,如果我们试图在给变量赋值之前使用它,我们会得到一个编译时错误,这表明Rust确实不允许空值。 

外部作用域声明了一个名为r的没有初始值的变量,内部作用域声明了一个名为x的初始值为5的变量。在内部范围内,我们试图将r的值设置为对x的引用。然后内部范围结束,我们试图打印r中的值。这段代码不会编译,因为r引用的值在我们试图使用它之前已经超出了范围。以下是错误消息: 

Rust之泛型、特性和生命期(四):验证有生存期的引用_第2张图片

变量x没有“活得足够久”原因是当内部范围在第1099行结束时,x将超出范围。但是r对外作用域仍然有效;因为它的范围更大,我们说它“寿命更长”如果Rust允许这段代码工作,r将引用x超出范围时释放的内存,我们试图用r做的任何事情都不会正确工作。那么Rust是如何确定这段代码无效的呢?它使用借用检查器。 

借用检查器

Rust编译器有一个借用检查器,它比较范围以确定是否所有借用都是有效的。示例17显示了与示例16相同的代码,但是带有显示变量生命周期的注释。

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

示例17:分别命名为‘a’‘b’rx的生命期的注释

这里,我们用“a”标注了r的生存期,用“b”标注了x的生存期。正如您所看到的,内部的“b”块比外部的“a”生存期块小得多。在编译时,Rust比较了两个生存期的大小,发现r的生存期为“a ”,但它引用的内存的生存期为“b”。程序被拒绝,因为“b比”a短:引用的主题没有引用的生存期长。 

示例18修正了代码,所以它没有悬空引用,编译时没有任何错误。 

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

示例18:有效的引用,因为数据比引用有更长的生命周期

这里,x的生存期为“b ”,在这种情况下,它大于“a ”,这意味着r可以引用x,因为Rust知道,当x有效时,r中的引用将始终有效。 

既然您已经知道了引用的生命期在哪里,以及Rust如何分析生命期以确保引用总是有效的,那么让我们在函数的上下文中探索参数和返回值的一般生命期。 

函数中的泛型生存期

 我们将编写一个函数,返回两个字符串片段中较长的一个。该函数将接受两个字符串切片,并返回一个字符串切片。在我们实现了longest的函数之后,示例19中的代码应该打印出The longest string is abcd

文件名:src/main.rs

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

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

 示例19:main函数,它调用最长的函数来查找两个字符串片段中较长的一个

注意,我们希望函数接受字符串片,字符串片是引用,而不是字符串,因为我们不希望longest函数接受其参数的所有权。关于为什么我们在示例19中使用的参数是我们想要的参数的更多讨论,请参考第4章中的“字符串片段作为参数”一节。 

如果我们试图实现示例20所示的最长的函数,它不会编译。

文件名:src/main.rs 

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

 示例20:最长函数的一个实现,返回两个字符串片段中较长的一个,但还没有编译

Rust之泛型、特性和生命期(四):验证有生存期的引用_第3张图片

 帮助文本显示,返回类型需要一个通用的生存期参数,因为Rust无法判断返回的引用是指x还是y。实际上,我们也不知道,因为该函数体中的if块返回对x的引用,else块返回对y的引用!

当我们定义这个函数时,我们不知道将传递给这个函数的具体值,所以我们不知道是if情况还是else情况会执行。我们也不知道将要传入的引用的具体生存期,所以我们不能像在示例17和示例18中那样查看作用域来确定我们返回的引用是否总是有效。借用检查器也不能确定这一点,因为它不知道xy的生命周期如何与返回值的生命周期相关。要修复此错误,我们将添加定义引用之间关系的通用生存期参数,以便借项检查器可以执行其分析。 

生存期注释语法

 生存期注释不会改变任何引用的生存期。相反,它们描述了多个引用的生存期之间的关系,而不会影响生存期。正如当签名指定泛型类型参数时,函数可以接受任何类型一样,通过指定泛型生存期参数,函数可以接受任何生存期的引用。

生存期注释有一个稍微不同寻常的语法:生存期参数的名称必须以撇号(')开头,并且通常都是小写的,非常短,就像泛型类型一样。大多数人在第一个生命周期注释中使用名称'a。我们将生存期参数注释放在引用的&之后,使用空格将注释与引用的类型分开。

下面是一些例子:对没有寿命参数的i32的引用,对具有名为'a的寿命参数的i32的引用,以及对也具有寿命'ai32的可变引用。

&i32        // a reference
&'a i32     // a reference with an explicit lifetime
&'a mut i32 // a mutable reference with an explicit lifetime

 一个生存期注释本身没有太多意义,因为注释是为了告诉Rust多个引用的通用生存期参数是如何相互关联的。让我们看看在longest函数的上下文中,生存期注释是如何相互关联的。

函数签名中的生存期注释

 为了在函数签名中使用生存期注释,我们需要在函数名和参数列表之间的尖括号内声明泛型生存期参数,就像我们对泛型类型参数所做的那样。

我们希望签名表达以下约束:只要两个参数都有效,返回的引用就有效。这是参数的生存期和返回值之间的关系。我们将把生存期命名为'a,然后把它添加到每个引用中,如示例21所示。

文件名:src/main.rs 

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

示例21:longest的函数定义,指定签名中的所有引用必须有相同的生命周期'a

当我们使用示例19中的main函数时,这段代码应该可以编译并产生我们想要的结果。 

函数签名现在告诉Rust,对于某个生存期'a,函数采用两个参数,这两个参数都是至少与生存期'a一样长的字符串片段。函数签名还告诉Rust,从函数返回的字符串片段将至少与生存期'a一样长。实际上,这意味着由longest函数返回的引用的生存期与由函数参数引用的值的较小生存期相同。我们希望Rust在分析这段代码时使用这些关系。 

请记住,当我们在这个函数签名中指定生存期参数时,我们不会改变任何传入或返回的值的生存期。相反,我们指定借用检查器应该拒绝任何不符合这些约束的值。请注意,longest函数不需要确切知道xy将存在多长时间,只需要用某个范围来代替满足该签名的'a

在函数中标注生存期时,标注放在函数签名中,而不是函数体中。生存期注释成为函数契约的一部分,很像签名中的类型。让函数签名包含生命周期契约意味着Rust编译器所做的分析可以更简单。如果函数的注释方式或调用方式有问题,编译器错误可以更准确地指向我们的代码部分和约束。相反,如果Rust编译器对我们想要的生存期关系做了更多的推断,编译器可能只能指出我们代码的一个用法,而不是问题的原因。

当我们将具体的引用传递给longest时,替换'a的具体生存期是x的范围与y的范围重叠的部分,换句话说,泛型生存期'a将获得等于xy的较小生存期的具体生存期,因为我们已经用相同的生存期参数'a对返回的引用进行了注释,所以返回的引用对于xy的较小生存期的长度也是有效的。 

让我们看看生存期注释如何通过传入具有不同具体生存期的引用来限制longest函数。示例22是一个简单的例子。 

文件名:src/main.rs

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);
    }
}

示例22:使用引用具有不同具体生命周期的String值的longest函数

Rust之泛型、特性和生命期(四):验证有生存期的引用_第4张图片

在这个例子中,string1在外部作用域结束之前有效,string2在内部作用域结束之前有效,而result引用在内部作用域结束之前有效的内容。运行这段代码,您将看到借入检查器批准了;它将编译并打印The longest string is long string is long。 

接下来,让我们尝试一个例子,它显示了result中引用的生存期必须是两个参数中较小的生存期。我们将把result变量的声明移到内部作用域之外,但是将值分配给string2作用域内的result变量。那我们就转移println!在内部范围结束后,在内部范围外使用result。示例23中的代码无法编译。

文件名:src/main.rs 

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);
}

 示例23:试图在string2超出范围后使用result

Rust之泛型、特性和生命期(四):验证有生存期的引用_第5张图片

该错误显示,要使resultprintln!有效!语句中,string2需要在外部范围结束之前一直有效。Rust知道这一点,因为我们使用相同的生存期参数'a来注释函数参数和返回值的生存期。 

 作为人类,我们可以查看这段代码,发现string1string2长,因此result将包含对string1的引用。因为string1还没有超出范围,所以对string1的引用对于println!仍然有效!声明。但是,编译器在这种情况下看不到引用有效。我们已经告诉Rust,由longest函数返回的引用的生存期与传入的引用的较小生存期相同。因此,借用检查器拒绝示例23中的代码,因为它可能有一个无效的引用。

 尝试设计更多的实验,改变传递给longest函数的引用的值和生存期,以及如何使用返回的引用。在编译之前,假设您的实验是否会通过借用检查器;然后检查你是否正确!

从生存期的角度思考

 您需要指定生存期参数的方式取决于您的函数正在做什么。例如,如果我们将longest函数的实现改为总是返回第一个参数,而不是最长的字符串片段,我们就不需要在y参数上指定生存期。下面的代码将会编译:

文件名:src/main.rs

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

我们已经为参数x和返回类型指定了生存期参数'a,但没有为参数y指定,因为y的生存期与x的生存期或返回值没有任何关系。

从函数返回引用时,返回类型的生存期参数需要与其中一个参数的生存期参数匹配。如果返回的引用不引用参数之一,则它必须引用在此函数中创建的值。但是,这将是一个悬空引用,因为在函数结束时,该值将超出范围。考虑一下这个不会编译的longest函数的尝试实现:

文件名:src/main.rs

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

 这里,即使我们已经为返回类型指定了生存期参数'a这个实现也将无法编译,因为返回值的生存期与参数的生存期完全无关。下面是我们得到的错误消息:

Rust之泛型、特性和生命期(四):验证有生存期的引用_第6张图片

 问题是result超出了范围,在longest函数结束时被清除。我们还试图返回一个对函数result的引用。我们无法指定会改变悬空引用的生存期参数,Rust也不允许我们创建悬空引用。在这种情况下,最好的解决方法是返回一个自己的数据类型而不是引用,这样调用函数就负责清理这个值。

最终,生存期语法是关于连接各种参数和函数返回值的生存期。一旦它们被连接起来,Rust就有足够的信息来允许内存安全的操作,并禁止会创建悬空指针或违反内存安全的操作。 

结构定义中的生存期注释

到目前为止,我们定义的结构都持有自己的类型。我们可以定义结构来保存引用,但在这种情况下,我们需要在结构定义中的每个引用上添加一个生存期注释。示例24有一个名为ImportantExcerpt的结构,它保存一个字符串切片。 

文件名:src/main.rs

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,
    };
}

示例24:一个包含引用的结构,需要一个生存期注释

这个结构有一个保存字符串片段的字段part,这是一个引用。与泛型数据类型一样,我们在结构名称后面的尖括号中声明泛型生存期参数的名称,这样我们就可以在结构定义的主体中使用生存期参数。此注释意味着ImportantExcerpt的实例不能比它在part字段中保存的引用存活得更久。 

这里的main函数创建了ImportantExcerpt结构的一个实例,该实例保存了对变量novel所拥有的字符串的第一句的引用。在创建ImportantExcerpt实例之前,novel中的数据已经存在。此外,在ImportantExcerpt超出范围之前,novel不会超出范围,因此ImportantExcerpt实例中的引用是有效的。 

终生省略

您已经了解到每个引用都有一个生存期,并且您需要为使用引用的函数或结构指定生存期参数。然而,在第四章中,我们在示例4-9中有一个函数,在示例25中再次显示,它编译时没有生命期注释。

文件名:src/lib.rs 

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[..]
}

示例25:我们在示例4-9中定义的一个函数,编译时没有生命期注释,即使参数和返回类型是引用

这个函数编译时没有生存期注释的原因是历史原因:在Rust的早期版本(1.0之前)中,这段代码不会编译,因为每个引用都需要一个显式的生存期。那时,函数签名应该是这样写的: 

fn first_word<'a>(s: &'a str) -> &'a str {

 在编写了大量Rust代码后,Rust团队发现Rust程序员在特定的情况下一遍又一遍地输入相同的生存期注释。这些情况是可以预测的,并且遵循一些确定的模式。开发人员将这些模式编程到编译器的代码中,因此借用检查器可以推断这些情况下的生存期,而不需要显式的注释。

这段Rust历史是相关的,因为更确定的模式可能会出现并被添加到编译器中。将来,可能需要更少的生存期注释。 

编程到Rust的引用分析中的模式被称为生存期省略规则。这些不是程序员要遵守的规则;它们是编译器将考虑的一组特殊情况,如果您的代码符合这些情况,您就不需要显式地编写生存期。

省略规则没有提供完整的推论。如果Rust确定性地应用了这些规则,但是对于引用的生命周期仍然不明确,编译器不会猜测其余引用的生命周期应该是多少。编译器不会猜测,而是会给出一个错误,您可以通过添加生存期注释来解决这个错误。 

函数或方法参数的生存期称为输入生存期,返回值的生存期称为输出生存期。 

当没有显式注释时,编译器使用三个规则来计算引用的生存期。第一个规则适用于输入生存期,第二个和第三个规则适用于输出生存期。如果编译器到达了三个规则的末尾,但仍然有一些引用无法确定它们的生存期,编译器将会出错停止。这些规则适用于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,因为这是一个方法,那么self的生存期被分配给所有输出生存期参数。第三条规则使得方法更易于读写,因为需要的符号更少。 

假设我们是编译器。我们将应用这些规则来计算示例25中first_word函数签名中引用的生存期。签名开始时没有任何与引用相关联的生存期:

fn first_word(s: &str) -> &str {

 然后编译器应用第一条规则,规定每个参数都有自己的生存期。我们像往常一样称之为‘a,所以现在的签名是这样的:

fn first_word<'a>(s: &'a str) -> &str {

 第二个规则适用,因为只有一个输入生命周期。第二个规则指定将一个输入参数的生存期分配给输出生存期,因此签名现在是这样的:

fn first_word<'a>(s: &'a str) -> &'a str {

现在,这个函数签名中的所有引用都有生存期,编译器可以继续分析,而不需要程序员在这个函数签名中注释生存期。

让我们看另一个例子,这一次使用longest函数,当我们在示例20中开始使用它时,它没有生存期参数:

fn longest(x: &str, y: &str) -> &str {

 让我们应用第一条规则:每个参数都有自己的生存期。这次我们有两个参数,而不是一个,所以我们有两个生存期:

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

 您可以看到第二条规则不适用,因为有多个输入生存期。第三条规则也不适用,因为longest是函数而不是方法,所以没有一个参数是self。在研究了所有三个规则之后,我们仍然没有弄清楚返回类型的生存期是多少。这就是为什么我们在试图编译示例20中的代码时出现错误:编译器通过了生存期省略规则,但仍然无法计算出签名中引用的所有生存期。

因为第三条规则实际上只适用于方法签名,我们将在接下来的上下文中查看生存期,以了解为什么第三条规则意味着我们不必经常在方法签名中注释生存期。

方法定义中的生存期注释

当我们在具有生存期的结构上实现方法时,我们使用与示例11所示的泛型类型参数相同的语法。我们在哪里声明和使用生存期参数取决于它们是与结构字段相关还是与方法参数和返回值相关。

结构字段的生存期名称总是需要在impl关键字之后声明,然后在结构名称之后使用,因为这些生存期是结构类型的一部分。 

impl块内部的方法签名中,引用可能与结构的字段中的引用的生存期相关联,或者它们可能是独立的。此外,生存期省略规则通常使得方法签名中不需要生存期注释。让我们看一些例子,使用我们在示例24中定义的名为ImportantExcerpt的结构。

首先,我们将使用一个名为level的方法,其唯一的参数是对self的引用,其返回值是i32,而不是对任何东西的引用:

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

 impl后的生存期参数声明及其在类型名后的使用是必需的,但由于第一个省略规则,我们不需要注释self引用的生存期。

下面是一个应用第三生命周期省略规则的示例:

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

有两个输入生命周期,所以Rust应用第一个生命周期省略规则,给&selfannouncement它们自己的生命周期。然后,因为参数之一是&self,返回类型获得&self的生存期,并且所有生存期都已被考虑。

静态生存期

我们需要讨论的一个特殊的生命周期是'static,这意味着受影响的引用可以存在于整个程序期间。所有字符串文字都有'static生存期,我们可以对其进行如下注释:

let s: &'static str = "I have a static lifetime.";

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

您可能会在错误消息中看到使用“'static生存期的建议。但是在指定'static作为引用的生存期之前,请考虑您拥有的引用是否实际上存在于您的程序的整个生存期中,以及您是否希望它存在。大多数情况下,提示'static生存期的错误信息是由于试图创建悬空引用或可用生存期不匹配而导致的。在这种情况下,解决方案是修复这些问题,而不是指定'static生存期。

泛型类型参数、特征界限和生存期

让我们简单地看一下在一个函数中指定泛型类型参数、特征界限和生存期的语法! 

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
    }
}

这是示例21中longest的函数,返回两个字符串片段中较长的一个。但是现在它有了一个名为ann的泛型类型T的额外参数,该参数可以由实现where子句指定的Display特征的任何类型填充。这个额外的参数将使用{}打印,这就是为什么Display特征界限是必要的。因为生存期是泛型的一种类型,所以生存期参数'a和泛型类型参数T的声明放在函数名后面的尖括号内的同一列表中。

总结

这一章我们讲了很多!现在,您已经了解了泛型类型参数、特征和特征界限,以及泛型生存期参数,您已经准备好编写在许多不同情况下都可以工作的代码了。泛型类型参数允许您将代码应用于不同的类型。特征和特征界限确保了即使类型是泛型的,它们也会有代码需要的行为。您了解了如何使用生存期注释来确保这个灵活的代码不会有任何悬空引用。所有这些分析都发生在编译时,不会影响运行时性能! 

信不信由你,关于我们在本章讨论的主题,还有很多东西要学:第17章讨论了trait对象,这是使用trait的另一种方式。还有一些更复杂的场景涉及生存期注释,您只需要在非常高级的场景中使用它们;对于这些,你应该阅读生锈的参考。但是接下来,您将学习如何在Rust中编写测试,这样您就可以确保您的代码按照它应该的方式运行。

本章重点

  • 验证生存期引用
  • 防止悬空引用
  • 借用检查器的概念
  • 函数中泛型生存期
  • 掌握生存期的注释语法
  • 函数签名生存期注释
  • 结构定义生存期注释
  • 方法定义中的生存期注释
  • 静态生存期
  • 泛型类型参数,trait界限及生存期

你可能感兴趣的:(Rust,rust,开发语言,后端)