Skip to content

Latest commit

 

History

History
executable file
·
436 lines (278 loc) · 39.4 KB

chapter_1.md

File metadata and controls

executable file
·
436 lines (278 loc) · 39.4 KB

Chapter 1: Fundation

当您深入研究Rust的更高级角落时,确保您对基本原理有牢固的理解非常重要。在任何编程语言中,随着您开始以更复杂的方式使用语言,各种关键字和概念的确切含义变得重要。在本章中,我们将讨论许多Rust的基本原语,并尝试更清楚地定义它们的含义,如何工作以及它们为什么会是现在这个样子。具体来说,我们将查看变量和值的区别,它们在内存中的表示以及程序拥有的不同内存区域。然后,我们将讨论一些所有权、借用和生命周期的微妙之处,这些都是您需要掌握的,然后才能继续阅读本书。

如果您愿意,您可以从头到尾阅读本章,或将其用作参考,以复习您不太确定的概念。我建议您只有在对本章内容感到完全舒适之后才继续前进,因为对于这些原语如何工作的误解将很快妨碍对更高级主题的理解或导致您错误地使用它们。

Talking about Memory

并非所有的内存都是一样的。在大多数编程环境中,您的程序可以访问堆栈、堆、寄存器、文本段、内存映射寄存器、内存映射文件和可能的非易失性RAM。在特定情况下选择使用哪一个内存区域,对于您可以存储什么,它将保持多长时间可访问,以及您使用什么机制访问它都有影响。这些内存区域的确切细节因平台而异,超出了本书的范围,但其中一些非常重要,对于您如何思考Rust代码也值得在这里涵盖。

Memory Terminology

在我们深入了解内存区域之前,你首先需要知道值、变量和指针之间的区别。在 Rust 中,值是类型和该类型值域中的元素的组合。可以使用值的类型表示将值转换为字节序列,但是你可以将值更多地看作程序员的意图。例如,类型为 u8 的数字 6 是数学整数 6 的一个实例,它在内存中的表示是字节 0x06。类似地,str "Hello world" 是所有字符串域中的一个值,其表示是其 UTF-8 编码。值的意义与存储这些字节的位置无关。

一个值被存储在一个地方,这在 Rust 术语中称为“可以容纳一个值的位置”。这个位置可以在栈、堆或其他许多位置上。存储值的最常见的位置是变量,在栈上具有命名值槽。

指针是一个值,它保存了内存中某个区域的地址,因此指针指向一个位置。可以解引用指针以访问存储在其指向的内存位置中的值。我们可以在多个变量中存储同一个指针,因此可以间接地引用内存中同一位置和同一基础值的多个变量。

请考虑清单 1-1 中的代码,该代码说明了这三个元素。

let x = 42;
let y = 43;
let var1 = &x;
let mut var2 = &x;
var2 = &y; // 1

清单 1-1:值、变量和指针

在这里有四个不同的值:42(一个i32),43(一个i32),x的地址(一个指针)和y的地址(一个指针)。还有四个变量:x,y,var1和var2。后两个变量都持有指针类型的值,因为引用是指针。虽然var1和var2最初存储相同的值,但它们存储独立的副本。当我们改变var2中存储的值时,var1中的值不会改变。特别地,=运算符将右侧表达式的值存储在左侧命名的位置中。

变量、值和指针之间的区别变得重要的一个有趣的例子是,在这样的语句中:

let string = "Hello, World";

尽管我们将一个字符串值赋给字符串变量,但实际变量值是指向字符串值“Hello world”中第一个字符的指针,而不是字符串值本身。此时你可能会问:“但等等,那么字符串值存储在哪里呢?指针指向哪里?” 如果是这样,你的眼睛很敏锐 - 我们马上会谈到这个。


Note: 从技术上讲,字符串的值还包括字符串的长度。当我们讨论宽指针类型时,我们将在第二章谈到它。


Variables in Depth

“我之前给出的变量定义非常宽泛,本身并不是非常有用。当你遇到更复杂的代码时,你需要更准确的心理模型来帮助你理解程序的真实运行方式。我们可以利用许多这样的模型。详细描述它们需要许多章节的篇幅,超出了本书的范围,但总体而言,它们可以分为两类:高级模型和低级模型。当您考虑代码的生命周期和借用时,高级模型非常有用,而低级模型适用于您推理不安全代码和原始指针的情况。下面两节中描述的变量模型足以应对本书中的大部分内容。”

High-Level Model

在高级模型中,我们不认为变量是保存字节数的地方。相反,我们将它们仅视为给定值的名称,在程序中实例化、移动和使用。当您为变量分配值时,该值从此以后就由该变量命名。当变量稍后被访问时,您可以想象从该变量的先前访问到新访问之间画一条线,建立两次访问之间的依赖关系。如果变量中的值被移动,则不能再从中画出线。

在这个模型中,只有在变量保存了一个合法值的时候才存在;如果变量的值未初始化或已被移动,就不能从变量中画出线,实际上这个变量就不存在了。使用这个模型,您的整个程序由许多这些依赖关系线组成,通常称为流,每个流都追踪一个特定值实例的生命周期。当存在分支时,流可以分叉和合并,每个分支都追踪该值的不同生命周期。编译器可以检查在程序的任何给定点上,可以并行存在的所有流是否兼容。例如,不能有两个具有可变访问权限的并行流。此外,如果没有拥有该值的流,则不能借用该值的流。清单 1-2 显示了这两种情况的示例。

let mut x;
// this access would be illegal, nowhere to  draw the flow from:
// assert_eq!(x, 42); // x 还没有初始化,所以这里会报错
x = 42; // 1
        // this is okay, can draw a flow from the value assigned above;
let y = &x; //2
            // this establishes a second, mutable flow from x
x = 43; // 3
        // assert_eq!(*y, 42); // 4

清单1-2:借用检查器将捕捉到的非法流程

首先,我们不能在x被初始化之前使用它,因为我们没有任何地方可以从中绘制流。只有当我们给x赋值时才能从中绘制流。此代码有两种流:一个从1到3的独占流(&mut),一个从1通过2到4的共享流(&)。借用检查器检查每个流程的每个顶点并检查任何其他不兼容的流是否同时存在。在这种情况下,当借用检查器在3处检查独占流程时,它会看到终止在4处的共享流程。由于你不能同时拥有一个值的独占和共享使用,所以借用检查器(正确地)拒绝了该代码。请注意,如果没有4,这段代码将编译正常!共享流程将在2处终止,当在3处检查独占流程时,不存在冲突流程。

如果使用与以前相同的名称声明新变量,则它们仍被视为不同的变量。这被称为“影子化”-后面的变量通过相同的名称“遮盖”了前面的变量。这两个变量共存,尽管随后的代码不再有办法对先前的名称进行命名。该模型大致匹配编译器,特别是借用检查器对程序进行推理的方式,并且实际上在编译器内部用于生成高效的代码。

Low-Level Model

变量名称是可能包含合法值的内存位置。你可以把变量看作是“值槽”,当你赋值时,这个槽就被填满了,旧的值(如果有的话)就被替换掉了。当你访问它时,编译器会检查这个槽是否为空,因为如果为空,就意味着变量未初始化或者它的值已被移动。指向变量的指针就是指向变量的支持内存,可以被解除引用以获取它的值。例如,在语句let x:usize中,变量x是指向堆栈上一个可以存储大小为usize的值的内存区域的名称,尽管它没有明确定义的值(它的槽是空的)。如果你给变量赋值,比如x=6,那么,那个内存区域将会存储代表值6的位组合。当你给x赋值时,&x并不会改变。如果你声明了多个同名的变量,它们最终仍会有不同的内存块支持它们。这种模型符合C和C++及其他许多低级语言所使用的内存模型,当你需要显式地考虑内存时,它是非常有用的。


Note: 在此示例中,我们忽略CPU寄存器并将其视为优化。实际上,如果该变量不需要内存地址,编译器可能会使用寄存器来支持一个变量,而不是使用内存区域。


你可能会发现其中一个更符合你以前的模型,但我建议你尝试理解两种模型。它们都是同样有效的,也都是简化的,像任何有用的心理模型一样。如果你能从这两个角度考虑代码片段,你会发现更容易处理复杂的代码段,理解它们为什么不能像你预期的那样编译和工作。

Memory Regions

现在你已经掌握了我们如何引用内存的概念,接下来我们需要讨论一下内存的实际含义。内存有许多不同的区域,也许令人惊讶的是,并不是所有的内存都存储在计算机的DRAM中。你使用哪一个内存区域对你编写代码的方式有重要的影响。对于编写Rust代码而言,最重要的三个区域是栈、堆和静态内存。

Stack

栈是程序用作函数调用的临时内存段。每次调用函数时,都在栈顶分配一个连续的内存块,称为帧。栈的底部附近是主函数的帧,在函数调用其他函数时,会推入其他帧。函数的帧包含函数内的所有变量以及函数所取的任何参数。当函数返回时,它的堆栈帧被回收。

函数局部变量的值所占的字节并不会立即被清除,但访问它们不安全,因为可能已经被后续调用的函数覆盖了,这些函数的帧与被回收的帧重叠。即使它们没有被覆盖,它们可能包含非法使用的值,例如在函数返回时移动的值。

堆栈框架以及它们最终会消失的关键事实,与 Rust 中的生命周期概念密切相关。在堆栈框架上存储的任何变量在该框架消失后都无法访问,因此对它的任何引用的生命周期必须最多与该框架的生命周期一样长。

Heap

堆是一块与程序当前调用堆栈没有关联的内存池,堆内存中的值将一直存在直到显式释放。当您需要某个值的生命周期超出当前函数框架时,这很有用。如果该值是函数的返回值,则调用函数可以在其堆栈上留出一些空间供被调用函数返回时写入该值。但是,如果您想将该值发送到与当前线程可能根本没有共享堆栈帧的不同线程,则可以将其存储在堆上。

堆允许您显式地分配连续的内存段,这样做可以获取指向该内存段开头的指针。该内存段将被保留,直到您稍后将其释放;这个过程通常被称为释放,在C标准库中具有相应的函数名称。由于从堆中分配的内存不会在函数返回时消失,因此您可以在一个位置为值分配内存,将指针传递给“由于从堆中分配的内存不会在函数返回时消失,因此您可以在一个位置为值分配内存,将指针传递给另一个线程,并使该线程安全地继续操作该值。或者换句话说,当您从堆中分配内存时,所得到的指针具有不受限制的生命周期——其生命周期与程序保持活动的时间一样长。

在Rust中与堆交互的主要机制是Box类型。当您编写Box::new(value)时,该值被放置在堆上,并且您得到的(Box)是指向堆上该值的指针。当Box最终被丢弃时,该内存将被释放。

如果你忘记释放堆内存,它将永远存在,并且你的应用程序最终会占用机器上所有的内存。这就被称为内存泄漏,通常您应该尽量避免。但是,有些情况下您明确希望泄漏内存。例如,假设您有一个整个程序都应该能够访问的只读配置。您可以在堆上分配它,并使用Box::leak明确地泄漏它,以获取对它的“静态引用”。

Static Memory

静态内存是一个统称,用于描述位于编译程序中的几个紧密相关区域。这些区域会在程序执行时自动加载到程序的内存中。静态内存中的值在整个程序执行期间都存在。程序的静态内存包含程序的二进制代码,通常被映射为只读。当程序执行时,它会按照指令在文本段中的顺序逐步遍历二进制代码,并在函数调用时跳转。静态内存还保存了使用 static 关键字声明的变量的内存,以及代码中某些固定的常量值,例如字符串。

特殊的“static”生命周期得名于静态内存区域,标记了一个引用有效的时间为“只要静态内存存在”,即直到程序关闭。由于静态变量的内存是在程序启动时分配的,所以对静态内存中变量的引用根据定义是“static”,因为它在程序关闭之前不会被释放。反之则不然——可能存在指向非静态内存的“static”引用——但这个名称仍然是适当的:一旦您创建了一个具有“static”生命周期的引用,无论它指向什么,就像对于程序的其余部分来说,它可能就在静态内存中一样,因为它可以使用任意长的时间。

在 Rust 中,您将更频繁地遇到“static”生命周期,而不是通过“static”关键字(例如)遇到真正的静态内存。这是因为“static”经常出现在类型参数的特性限制中。例如:T: 'static 表示类型参数 T 能够在我们保留它的生命周期内存活,包括程序的剩余执行时间。基本上,这个限制要求 T 是自主拥有的,并且要么不借用其他(非静态)值,要么借用的所有值都是“static”,并且会在程序结束之前一直存在。一个很好的“static”绑定的例子是 std::thread::spawn 函数,它创建一个新线程,需要您传递的闭包是“static”的。由于新线程“可能的生命周期超过了当前线程,因此新线程不能引用存储在旧线程堆栈上的任何内容。新线程只能引用其整个生命周期内都存在的值,这可能是程序剩余时间的整个时间。


Note: 你可能会想知道const和static的不同之处。const关键字声明了以下项为常量。常量项可以在编译时完全计算,任何引用它们的代码在编译期间都将被替换为常量的计算值。常量没有与它关联的内存或其他储存(它不是一个空间)。你可以将常量视为一个特定值的方便名称。


Ownership

Rust的内存模型基于这样一个理念:所有的值都有一个单一的所有者——也就是说,每个值都有且只有一个位置(通常是作用域),负责最终释放该值。这是通过借用检查器来实现的。如果将值移动,例如将其分配给新变量,将其推入向量或将其放置在堆上,则该值的所有权将从旧位置移动到新位置。此时,您不能再通过从原始所有者流动的变量访问该值,即使构成该值的位实际上仍然存在。相反,您必须通过引用其新位置的变量访问移动的值。

“有些类型是叛逆的,并且不遵循这个规则。如果一个值的类型实现了特殊的Copy trait,那么即使它被重新分配到新的内存位置,该值也不会被认为已经移动。相反,该值会被复制,旧的和新的位置仍然可访问。本质上,在移动的目的地构造了另一个相同的实例。Rust中大多数原始类型,如整数和浮点类型,都是可复制的。要复制,必须通过复制其位来复制类型的值。这就排除了所有包含非Copy类型和任何在值被丢弃时必须释放资源的类型。”

考虑一下为什么,假设像 Box 这样的类型是可复制的。如果我们执行 box2 = box1,那么 box1和 box2 都会认为它们拥有为盒子分配的堆内存,并且当它们超出范围时都会尝试释放它。释放内存两次可能会产生灾难性后果。

当一个值的所有者不再使用它时,所有者有责任通过丢弃它来进行任何必要的清理工作。在 Rust 中,当保存该值的变量不再在范围内时,自动执行丢弃操作。类型通常递归地释放它们包含的值,因此丢弃复杂类型的变量可能导致许多值被丢弃。由于 Rust 具有离散的所有权要求,我们不能意外地多次丢弃同一个值。保存对另一个值的引用的变量不拥有该其他值,因此变量被删除时该值不会被删除。

代码清单1-3概述了关于所有权、移动和复制语义以及丢弃的规则。

let x1 = 42;
let y1 = Box::new(84);
{
    // starts a new scope
    let z = (x1, y1); //1
    // z goes out of scope and its members are destroyed
    // It in turn owns x1 and y1, and when it goes out of scope, it destroys them.
} // 2

// x1's valus is Copy, so it's still available
let x2 = x1; // 3
// y1's value is not Copy, so it's no longer available, so it was moved into z
// let y2 = y1; // 4

清单1-3:移动和复制语义

我们从两个值开始,数字42和一个包含数字84的Box值(堆分配的值)。前者是拷贝,而后者不是。当我们将x1和y1放入元组z1中时,x1被复制到z中,而y1被移动到z中。此时,x1仍然可访问,并且可以再次使用。另一方面,一旦y1的值已被移动,它就无法访问,任何尝试访问它的操作都将导致编译器错误。当z超出作用域2时,它包含的元组值被丢弃,这反过来又使从x1复制的值和从y1移动的值被丢弃。当来自y1的Box被丢弃时,它也会释放用于存储y1值的堆内存。


Drop Order:

当变量超出范围时,例如 List 1-3 中内部范围的 x1 和 y1,Rust 会自动丢弃值。丢弃顺序的规则相当简单:变量(包括函数参数)按相反的顺序丢弃,嵌套值按源代码顺序丢弃。

这可能一开始听起来很奇怪——为什么会出现这种差异呢?不过如果我们仔细看一下,就会发现这很有道理。假设你编写了一个函数,声明了一个字符串,“然后将该字符串的引用插入新的哈希表中。当函数返回时,必须先删除哈希表;如果先删除了字符串,那么哈希表就会保存无效的引用!一般来说,后面的变量可能包含对前面值的引用,而反过来是不可能的,因为 Rust 的生命周期规则。因此,Rust 倒序删除变量。

现在,我们可以将嵌套值的行为与元组、数组或结构体中的值相同,但这可能会令用户感到惊讶。如果你构建了一个包含两个值的数组,如果最后一个元素首先被删除,那么这似乎很奇怪。同样适用于元组和结构体,在这种情况下,最直观的行为是先删除第一个元组元素或字段,然后是第二个,依此类推。与变量不同,此处无需反转删除顺序,因为 Rust(当前)不允许单个值中存在自引用。因此,Rust 选择了直观的选项。


Borrowing and Lifetimes

Rust允许值的所有者通过引用将该值借出给他人,而不放弃所有权。 引用是带有额外使用合约的指针,例如引用是否提供对所引用值的独占访引用是带有额外使用合约的指针,例如引用是否提供对所引用值的独占访问权,或者所引用的值是否还可以有其他引用指向它。

Shared References

一个共享引用 &T,顾名思义,就是一个可能被共用的指针。可以存在任意数量的其他引用指向同一个值,并且每个共享引用都是可复制的,因此您可以轻松地创建更多的引用。共享引用指向的值是不可变的;您不能修改或重新分配共享引用所指向的值,也不能将共享引用强制转换为可变的。

Rust编译器可以假定共享引用指向的值在引用存在期间不会发生改变。例如,如果Rust编译器看到共享引用后面的值在函数中被多次读取,它有权只读取一次并重复使用该值。更具体地说,列表1-4中的断言永远不应该失败。

pub fn cache(input: &i32, sum: &mut i32) {
    *sum = *input + *input;
    assert_eq!(*sum, 2 * *input);
}

清单1-4:Rust假定共享引用是不可变的。

无论编译器是否选择应用给定的优化,都不太相关。编译器的启发式算法随时间而变化,因此通常要根据编译器允许的做法编写代码,而不是根据特定情况下编译器在特定时刻实际执行的做法。

Mutable References

与共享引用的另一种选择是可变引用:&mut T。使用可变引用,Rust 编译器再次充分利用引用所带来的约定:编译器假设没有其他线程通过共享引用或可变引用访问目标值。换句话说,它假设可变引用是独占的。这使得一些在其他语言中不容易实现的有趣的优化变得可能。例如,看看清单 1-5 中的代码。

pub fn noalias(input: &i32, output: &mut i32) {
    if *input == 1 {
        *output = 2;
    }
    if *input != 1 {
        *output = 3;
    }
}

清单1-5:Rust假定可变引用是独占的。

在 Rust 中,编译器假设输入和输出不指向相同的内存。因此,输出的重新分配在 1 处不会影响 2 处的检查,并且整个函数可以编译为单个 if-else 块。如果编译器不能依赖于排他性可变性合同, 则该优化将无效,因为像 noalias(&x,&mut x) 这样的情况中,输入为 1 就可能导致输出为 3。

一个可变的引用允许你仅仅改变引用指向的内存位置。是否可以改变超出直接引用的值取决于介于引用之间的类型提供的方法。为更好地解释,请看示例1-6。

let x = 42;
let d = 55;
let mut y = &x; // y is a shared reference to x, y is of type &i32
let z = &mut y; // z is a mutable reference to y, z is of type &mut &i32
println!("Change before: z = {}", z);
*z = &d; // z is now a mutable reference to d
println!("Change after: z = {}", z);

示例1-6:可变引用允许您改变引用指向的内存位置。

在这个示例中,你可以通过将指针 y 引用到另一个变量来更改指针 y 的值(换句话说,它指向了一个不同的指针),但你无法更改所指向的值(也就是 x 的值)。同样地,你可以通过 z 更改指向 y 的指针值,但你无法更改 z 本身来持有一个不同的引用。

拥有一个值和拥有可变引用之间的主要区别在于,当这个值不再需要时,所有者将负责丢弃它。除此之外,通过可变引用,你可以像拥有这个值一样进行任何操作,但有一个例外:如果你移动了可变引用后面的值,那么你必须留下另一个值来占据它的位置。如果你没有这样做,所有者仍然会认为它需要丢弃这个值,但已经没有值需要丢弃了!

pub fn replace_with_84(s: &mut Box<i32>) {
    // this is not okay, as *s would be empty after the swap
    // let was = *s; // 1
    // but this is okay, as we put the original value back
    let was = std::mem::take(s); // 2 // was is 42 , s is 0
    println!("was is: {:?}, s is : {:?}", was, s);
    // so is this:
    *s = was; // 3 // s is 42
    println!("s is: {:?}", s);
    // we can exchange value behind &mut:
    let mut r = Box::new(84);
    std::mem::swap(s, &mut r); // 4 // s is 84, r is 42
    println!("s: {:?}, r : {:?}", s, r);
    assert_eq!(*r, 42);
}

let mut s = Box::new(42);
replace_with_84(&mut s);
// 5

示例1-7:可变引用允许您更改引用指向的值。

我添加了注释行来表示非法操作。你不能简单地将值移出1,因为调用者仍然认为它拥有该值,并在5处再次释放它,导致重复释放。如果你只想留下一些有效值,std::mem::take 2是一个很好的选择。它等同于std::mem::replace(&mut value, Default::default());它从可变引用后面移出值,但在其位置上留下一个新的默认值。默认值是一个独立的拥有值,所以调用者安全地在作用域在5处结束时将其删除。

或者,如果你不需要引用后面的旧值,你可以用一个你已经拥有的值重写它,然后让调用者稍后丢弃该值。当你这样做时,原来在可变引用后面的值会立即被丢弃。

最后,如果你有两个可变引用,你可以在不拥有它们之一的情况下交换它们的值 4,因为两个引用最终会具有合法的拥有者来释放它们。

interior mutability

有些类型提供内部可变性,这意味着它们允许您通过共享引用来改变值。这些类型通常依赖于额外的机制(例如原子CPU指令)或不变量,以提供安全的可变性,而不依赖于独占引用的语义。这些通常分为两类:一类是允许您通过共享引用获得可变引用的类型,另一类是仅通过共享引用即可替换值的类型。

第一类包括Mutex和RefCell等类型,它们都包含安全机制,以确保对于它们所提供可变引用的任何值,只能存在一个可变引用(而没有共享引用)。在底层,这些类型(以及类似它们的类型)都依赖于一种叫做UnsafeCell的类型,它的名称应该会让你立即对使用它产生疑虑。在第9章中,我们将更详细地介绍UnsafeCell,但是现在你应该知道,这是通过共享引用进行修改的唯一正确方式。

还有其他类型的类别可以提供内部可变性,它们并不提供对内部值的可变引用,而是仅提供在原地操作该值的方法。标准库中的 std::sync::atomic 中的原子整型类型和 std::cell::Cell 类型属于此类别。你不能直接获取这种类型背后的 usize 或 i32 引用,但你可以在给定时间点读取和替换其值。

Note: 标准库中的Cell类型是安全内部可变性的有趣例子。它不能在线程之间共享,并且从不提供对Cell内部值的引用。相反,其方法要么完全替换值,要么返回所包含值的副本。由于无法存在对内部值的引用,因此可以随时移动它。并且由于Cell无法在线程之间共享,即使通过共享引用进行了变异,内部值也永远不会被同时变异。

Lifetimes

新一代 Rust 开发者通常会被教导将生命周期视为与作用域对应:当你引用某个变量时,生命周期开始,当该变量被移动或作用域结束时,生命周期就结束了。这通常是正确且有用的,但实际情况会更加复杂。生命周期实际上是某个引用必须有效的代码区域的名称。虽然生命周期通常会与作用域重合,但不必总是这样,我们将在本节后面看到。

Lifetime and the Borrow Checker

在 Rust 生命周期的核心是借用检查器。每当使用某个生命周期 'a 的引用时,借用检查器都会检查 'a 是否仍然存在。它通过从使用点追踪路径回溯到a开始的地方——引用被取出的地方,并检查路径上是否有冲突使用来实现此目的。这确保了引用仍然指向可以安全访问的值。这类似于本章前面讨论的高级“数据流”心理模型;编译器检查我们正在访问的引用的流程是否与任何其他并行流程发生冲突。

清单1-8展示了一个简单的示例代码,用于参考x的生命周期注释。

fn rand() -> f32 {
    1.0
}

let mut x = Box::new(42);
let r = &x; // 1 'a
if rand() > 0.5 {
    *x = 84; // 2
} else {
    println!("{}", r); // 3 'a
}

println!("r = {}", r); // 4

代码清单1-8:生命周期不需要连续。

生命周期从我们引用x的时候开始,值为1。在第一个分支2中,我们立即尝试通过将其值更改为84来修改x,这需要一个&mut x。借用检查者取出x的可变引用并立即检查其使用。它发现在引用被使用之前和之后之间不存在冲突的使用,因此接受该代码。如果您习惯将生命周期视为作用域,则可能会感到惊讶,因为r仍在第2处作用域内(它在第4处超出作用域)。但是,借用检查器足够聪明,能够意识到如果采用此分支,则r不会在以后使用,因此在这里可以对x进行可变访问。或者,换句话说,从1创建的生命周期不延伸到此分支:r没有超出第2处,因此没有冲突的流程。借用检查器随后在第3处找到了r的使用的打印语句。它沿着路径追溯到1,并找不到冲突的使用(2不在该路径上),因此也接受了此使用。

如果我们在清单1-8中添加另一个使用r的4(println!("r = {}", r);),代码将不再编译。此时,生命周期'a将持续从1到4(r的最后使用),当借用检查者检查我们对r的新使用时,会发现在2处存在冲突使用。

生命周期可能会变得相当复杂。在清单1-9中,您可以看到一个具有空洞的生命周期示例,在其开始和最终结束之间会间歇性地无效。

let mut x = Box::new(42);
let mut z = &x; //1
for i in 0..100 {
    println!("{}", z); //2 'a
    x = Box::new(i); // 3
    z = &x; //4 'a
}
println!("{}", z); //'a

代码清单1-9:生命周期不需要连续(todo(davirain 需要在理解))

生命周期从我们引用x时开始计时,此时为1。然后我们在3处退出x,因为它不再有效,所以结束了生命周期'a。借用检查器通过将生命周期'a视为在2处结束而接受了这个移动,这样在3处就不会有与x冲突的流。然后,在4处通过更新z中的引用来重新启动生命周期。无论代码是否回到2或继续到最终的打印语句,这两个使用现在都有一个有效值可以流向,且没有冲突的流,所以借用检查器接受了该代码!

同样,这与我们之前讨论的内存数据流模型完美地吻合。当x被移动时,z停止存在。当我们稍后重新分配z时,我们正在创建一个仅从该点开始存在的全新变量。恰好这个新变量也叫做z。考虑到这个模型,这个例子并不奇怪。

Note: 借用检查器必须保守。如果它不确定一个借用是否有效,就会拒绝它,因为允许无效的借用的后果可能是灾难性的。借用检查器不断变得更加智能,但有时需要帮助它了解为什么一个借用是合法的。这是为什么我们有不安全的 Rust 的原因之一。

Generic Lifetimes

“有时候你需要在你自己的类型中存储引用。这些引用需要有一个生命周期,这样借用检查器才能在它们在类型的各种方法中被使用时检查它们的有效性。特别是如果你想让类型的某个方法返回一个比self引用更长的引用,这就尤为重要。”

“Rust允许您使类型定义泛型化为一个或多个生命周期,就像它允许您将其泛型化为类型一样。《Rust编程语言》(Steve Klabnik和Carol Nichols,No Starch Press,2018)对这个话题进行了详细的介绍,因此我不会在这里重复基础知识。但是,随着您编写更复杂的此类类型,有两个关于此类类型和生命周期交互的微妙之处您应该意识到。”

“首先,如果您的类型还实现了Drop,那么Drop您的类型将计算作用于任何泛型生命周期或类型的使用。实际上,当您的类型的实例被Drop时,借用检查器将检查在Drop之前是否仍然可以使用该类型的任何泛型生命周期。这是必要的,因为如果您的Drop代码使用了这些引用,那么所有的泛型生命周期都需要依然有效。如果您的类型没有实现Drop,则Drop该类型不会计算为使用,用户可以忽略存储在该类型中的任何引用,只要他们不再使用它即可,就像我们在列表1-7中看到的那样。我们将在第9章更详细地讨论关于Drop的规则。”

“同时,虽然一个类型可以泛型地涵盖多个生命周期,但这样做通常只会使你的类型签名不必要地复杂化。通常,一个类型只泛型于一个生命周期即可,编译器将使用其中较短的生命周期来插入到你的类型中任何引用。只有当你有一个包含多个引用的类型,并且它的方法返回应绑定于这些引用中一个引用的生命周期的引用时,才应该真正使用多个泛型生命周期参数。”

“考虑清单1-10中的类型,它可以为您提供一个按特定其他字符串分隔的字符串部分的迭代器。”

struct StrSplit<'s, 'p> {
    delimiter: &'p str,
    document: &'s str,
}

impl<'s, 'p> Iterator for StrSplit<'s, 'p> {
    type Item = &'p str;
    fn next(&mut self) -> Option<Self::Item> {
        todo!()
    }
}

// have erorr
// fn str_before<'a> (s: &'a str, c: char ) -> Option<&'a str> {
//     StrSplit { document: s, delimiter: &c.to_string() }.next()
// }

// Compiling playground v0.0.1 (/playground)
// error[E0515]: cannot return value referencing temporary value
// --> src/main.rs:17:5
//  |
// 17 |     StrSplit { document: s, delimiter: &c.to_string() }.next()
//  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^-------------^^^^^^^^^
//  |     |                                   |
//  |     |                                   temporary value created here
//  |     returns a value referencing data owned by the current function

// For more information about this error, try `rustc --explain E0515`.
// error: could not compile `playground` (bin "playground") due to previous error

fn str_before<'a> (s: &'a str, c: &'a str ) -> Option<&'a str> {
    StrSplit { document: s, delimiter: c }.next()
}

1-10清单:一个需要针对多个生命周期进行泛型的类型

构建这种类型时,您必须提供分隔符和待搜索的文档,两者都是string值的引用。当您请求下一个字符串时,您将获得文档的引用。考虑如果您在此类型中使用单一生命周期会发生什么。迭代器产生的值将绑定到文档和分隔符的生命周期上。这将使得str_before难以编写:返回类型将具有与函数局部变量相关联的生命周期——由to_string产生的String对象——借用检查程序将拒绝该代码。

Lifetime Variance

Variance是一种程序员经常接触但很少知道名称的概念,因为它大多是看不见的。一眼望去,Variance描述了哪些类型是其他类型的子类型,以及何时可以使用子类型代替超类型(反之亦然)。广义上讲,如果类型A至少与类型B同样有用,则类型A是类型B的子类型。在Java中,Variance是什么。如果Turtle是Animal的子类型,则可以将Turtle传递给接受Animal的函数,或者在Rust中,可以将“&'static str”传递给接受“&'a str”的函数的原因。

虽然Variance通常在视线之外,但它经常出现,我们需要了解它的工作原理。乌龟是Animal的一个子类型,因为乌龟比某些未指定的动物更“有用” - 乌龟可以做任何动物能做的事情,可能更多。同样, 'static是'a类型的子类型,因为'static至少与任何'a一样长寿,因此更有用。或者更一般地说,如果'b:'a('b比'a更长寿),那么'b是'a的子类型。这显然不是正式定义,但它足够接近以实用。

所有类型都有差异,这定义了可以在该类型的位置使用哪些类似类型。有三种差异:协变、不变和逆变。如果可以仅使用子类型代替类型,则类型是协变的。例如,如果变量的类型是 &'a T,则可以为其提供类型为 &'static T 的值,因为 &'a T 在 'a 上是协变的。&'a T 在 T 上也是协变的,因此可以将 &Vec<&'static str> 传递给接受 &Vec<&'a str> 的函数。

有些类型是不变的,这意味着你必须提供精确给定的类型。&mut T就是一个例子——如果一个函 数需要一个&mut Vec<&'a str>,你不能传递一个&mut Vec<&'static str>。也就是说,&mut T是T不变的。如果你能够传递,则函数可以将一个短暂的字符串放入Vec中,而调用者会继续使用它,认为它是Vec<&'static str>,因此包含的字符串是'static!任何提供可变性的类型通常都是不变的,原因相同,例如Cell在T方面是不变的。

最后一个类别是逆变,仅适用于函数参数。如果函数类型不介意参数被降低效用,那么它们会更加有用。如果你将参数类型本身的Variance与其作为函数参数时的Variance进行对比,这一点会更加清晰:

// let x: &'static str  = "hello, world!"; // more usefult , live longer
// let x: &'a str = "hello, world!"; // less useful, live shorter

fn take_func1(s: &'static str) {} // stricter, so less useful
fn take_func2<'a>(s: &'a str) {} // less stricter, so more useful

这种颠倒的关系表明,Fn(T)在T中是逆变的。

当涉及到生命周期时,为什么需要学习Variance?当您考虑通用生命周期参数与借用检查器交互时,Variance变得相关。请考虑像列表1-11中显示的使用单个字段中的多个生命周期的类型。

struct MutStr<'a, 'b>  {
    s: &'a mut &'b str,
}
let mut s = "hello";
*MutStr { s: &mut s }.s = "world";  // 1
println!("{}", s); // 2

清单1-11:需要在多个生命周期上实现通用性的类型

乍一看,在这里使用两个生命周期似乎没必要——我们没有任何需要区分结构不同部分的借用方法,就像在1-10清单中的StrSplit一样。但是,如果你用单个'a'替换这里的两个生命周期,代码将无法编译!这全是因为Variance。

Note: 在位置1的语法可能看起来很陌生。这相当于定义一个变量x持有MutStr,然后写入*x.s = "world",但是没有变量,因此MutStr会立即被丢弃。

在1处,编译器必须确定生命周期参数应设置为什么生命周期。如果有两个生命周期,则将'a设置为s借用的待确定生命周期,而'b设置为'static',因为提供的字符串"hello"的生命周期为'static'。如果只有一个生命周期'a',则编译器推断该生命周期必须为'static'。

当我们稍后尝试通过共享引用访问字符串引用s并将其打印时,编译器会尝试缩短MutStr使用的s的可变借用时间,以允许对s的共享借用。

在双生命周期的情况下,“a”在println之前结束,“b”保持不变。然而,在单生命周期的情况下,我们遇到了问题。编译器希望缩短对“s”的借用,但为了做到这一点,它也必须缩短对“str”的借用。虽然“&'static str”通常可以缩短为任何“&'a str”(“&'a T”在“'a”上是协变的),但在这里它是在“&mut T”后面,而“&mut T”在T上是不变的。不变性要求相关类型永远不会被替换为子类型或者超类型,因此编译器试图缩短借用的尝试失败,并报告列表仍然被可变地借用。哎呀!

由于不变性所强加的减少的灵活性,你需要确保你的类型在尽可能多的泛型参数上保持协变(或在适当的情况下反变)。如果这需要引入额外的生命周期参数,你需要仔细权衡增加另一个参数的认知成本和不变性的人体工程学成本。

Summary

本章的目的是建立一个坚实的、共享的基础,为接下来的章节奠定基础。我希望你已经掌握了Rust 的内存和所有权模型,并且你从借用检查器(borrow checker)得到的错误看起来不再神秘。你可能已经了解了我们在这里讨论的一些片段,但是希望这一章给你提供一个更全面的整体认识。在下一章中,我们将对类型进行类似的介绍。我们将讨论如何在内存中表示类型,看看泛型和特性如何产生运行代码,并探讨 Rust 提供的一些特殊类型和特性结构,用于更高级的用例。