《Rust for Rustaceans》 样章试译 | 第二章 Rust 基础

原创
07/10 21:42
阅读数 0

引子

本文是对   Jon Gjengset 写的新书 《Rust for Rustaceans》样章第二章的中文试译初稿。出于对 Jon 的尊敬,以及想了解 Jon 眼中的 Rust ,我打算翻译一下这本书。发出来让大家看看翻译效果,欢迎指正。

本书链接:https://nostarch.com/rust-rustaceans[1]

Jon Gjengset  简单介绍:

Jon 在 MIT 读 PhD 的时候,用 Rust 实现了高性能关系数据库 (https://github.com/mit-pdos/noria)。也经常为 Rust 工具链和生态系统做贡献,在 youtube 直播 Rust 视频,受到 Rust 社区热烈欢迎。

审校本书的是 Dtolany,他是 syn/serde 等知名库的作者。Dtolany 是一位生产力非常高效的 Rust 开发者,在 Rust 社区也是广受推崇。

这次翻译也打算采用一些新的术语翻译,比如 lifetime ,以往一直翻译为“生命周期”,但是经过社区的一些探讨,感觉“生存期”更合适一些。

关于 《Rust for Rustaceans》 标题的中文翻译

我已经拿到了本书的早期版本(MEAP版),就是网站上目录标红部分的内容。从内容看这本书其实并不是各种具体的编程技巧的罗列,其实还是在心智模型上指导 Rust 开发者该如何更好的使用 Rust。这本书适合对 Rust 有一定基础想要进阶的人群。

由此,关于中文标题,我不禁想到了一个字:「禅」。禅,其实就是一种理念/原则,这里指编程理念,这本书讲的就是 Rust 编程之禅。

我大概想到了三个名字,大家也可以一起帮忙想一下:

  • 《Rust 编程之禅 —— 编写地道的 Rust 代码 》
  • 《Rust 编程之禅 —— 来自 Rust 资深开发者的经验之谈 》
  • 《Rust 务实指南 —— 编写地道 的 Rust 程序》

大家有什么好建议可以留言。

正文

当你深入到 Rust 更高级的内容时,确保自己对基础知识有一个坚实的理解是非常重要的。和其他任何编程语言一样,当你开始以更复杂的方式使用 Rust 的时候,各种关键字和概念的确切含义就会变得非常重要。在本章,我们将浏览 Rust 的很多基本面(primitives),并试图更精确地定义其含义,搞清楚它们的工作机制,以及为什么以现在这种方式存在。具体来说,我们会学习变量和值有什么不同,它们在内存中将如何表示,以及一个程序有哪些不同的内存区域。然后,我们将讨论一些所有权、借用和生存期的精妙之处,在你继续阅读本书之前,你需要掌握这些知识。

如果你愿意,你可以从头到尾一口气阅读完本章,或者你可以把它作为一个参考,来复习你不太确定的一些概念。我建议你只有在对本章内容感到完全适应的时候再继续学习本书的内容。

谈谈内存

并非所有的内存都是平等的。在大多数编程环境下,你的程序可以访问栈(stack)、堆(heap)、寄存器(register)、文本段(text segment)、内存映射的寄存器(memory-mapped register)、内存映射文件(memory-mapped file),有时也会有非易失性可读写存储器(Nonvolatile RAM)。在特定的情况下,你选择使用哪一个会影响到你可以在那里存储什么、可以保持多长时间,以及使用什么机制来访问它。这些内存区域细节因平台而异,超出了本书的范围,但有些内存区域堆你如何推理 Rust 代码非常重要,因此值得在此介绍。

引用内存位置

在深入研究内存区域之前,你首先需要了解 值(value)、变量(variable)和指针(pointer)之间的区别。Rust 中的值是一个类型和该类型值域[2]中的一个元素的组合。一个值可以根据其类型表征(type's representation)变成一串字节,但就这个词的本意而言,你可以认为一个值更像是“你 --程序员”这样的组合。例如, u8 类型中的数字 6是数学整数6的一个实例,它在内存中的表示是字节 0x06。同样,字符串 "hello world"是所有字符串域中的一个值,使用 UTF-8 编码来表示。一个值的意义与这些字节的存储位置无关。

值被存储于某个位置。位置(place)是 Rust 中的术语,意思是“一个可以容纳值的地方”。这个位置可以是栈,也可以是堆,或者是其他的某些位置。最常见的存储值的位置是一个变量,它是位于栈中的一个命名值槽(value slot)。指针是一个持有内存区域地址的值,所以指针指向某个位置。

我们可以通过解引用(dereference)指针来访问存储在它所指向内存位置的值。也可以在多个变量中存储相同的指针,这些变量正确地指向内存中的同一个位置,从而指向相同的值。

考虑清单 2-1 中的代码,它阐明了三个要素。

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

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

代码中包含四个不同的值:42(i32 类型)43(i32 类型)x的地址(指针类型),以及 y 的地址(指针类型)。代码中也包含四个变量:xyvar1var2。后两个变量都持有指针类型的值,因为引用(reference)就是指针。虽然 var1var2 最初存储的都是同一个值,但它们分布存储该值的独立副本。当我们改变 var2 (1) 存储的值时, var1 中的值不会改变。尤其是当 = 运算符将右侧表达式(expression)的值存储在左侧命名的地方。

在一个语句(statement)中,变量、值和指针之间的区别很重要。下面是一个有趣的示例:

let string = "Hello world";

尽管我们给变量 string 赋予了一个字符串的值,但该变量的值实际上是一个指针,而不是字符串值本身。此时,你可能会说:“你等等,那么字符串的值是在哪里存储的呢?指针指向哪里?”。如果你真这么想,那说明你有一个敏锐的头脑。我们接下来就会解释这些问题。

变量是什么?

我前面给出的变量定义很宽泛,而且本身不太可能有什么用。当你遇到更复杂的代码时,你将需要一个更精准的心智模型(mental model)来帮助你推理程序的真正作用。我们可以利用许多这样的模型。详细描述这些模型会占用好几章的篇幅,也超出了本书的范围,但大致上它们可以分为两类:抽象模型(high-level model)和底层模型(low-level model)。抽象模型在思考生存期和借用层面的代码时很有用,而底层模型在推理 Unsafe 代码和 原始指针时很有用。下面两节中描述的变量模型对于本书中大部分内容来说已经足够了。

抽象模型

在抽象模型下,我们不认为变量是存放字节的地方。相反,我们认为它们只是在整个程序中被实例化、移动和使用时赋值的名称。当你给变量赋值的时候,这个值就以此变量为名。当变量在之后被访问时,你可以在脑海中从该变量之前的访问到最新的访问之间画一条线,来确定两个访问之间的依赖关系。如果变量的值被移动了,就不能再从它那里画线了。

在该模型中,只要一个变量持有合法的值,它就存在。如果一个变量的值未被初始化或被移动了,你就不能从它那里画线了,所以实际上它并不存在。使用此模型,你的整个程序就是由许多这样的依赖线组成,通常称之为流(flow),每条流都追踪一个值的特定实例的生存期。当有分支时,流可以分叉和合并,每个分叉都追踪该值的不同生存期。编译器可以检查你程序中的任何已知点,所有可以相互平行存在的流都是兼容的。例如,不能有两条并行的流对值进行可变访问。也不能有一条流借用一个值,但没有流拥有这个值。清单 2-2 展示了这两种情况。

let mut x;
// 该访问是非法的,因为没有流的起始点:
// assert_eq!(x, 42);
x = 42;   // (1)
// 这是合法的,可以从上面分配的值中画出流:
let y = &x;   // (2)
// 这就建立了第二条来自 x 的 可变的流:
x = 43;       // (3)
// 这将继续从y那里获得流,而y又从x那里获得流:
assert_eq!(*y, 42);   // (4)

// 清单 2-2:借用检查器将捕获的非法流

首先,在 x 被初始化之前,我们不能使用它,因为我们没有地方可以画流。只有当 x 被赋值时,才能从它那里开始画流。这段代码有两条流:一条从 (1)(3) 的独占(&mut)流,以及一条从(1)(2)再到(4)的共享(&)流。借用检查器检查每条流的每个顶点,并检查是否有其他不兼容的流同时存在。在这种情况下,当借用检查器检查(3)处独占流时,它会看到终止于(4)处的共享流。由于不能对一个值同时进行独占和共享,编译器(正确地)拒绝了该段代码。请注意,如果没有(4),这段代码会编译的很好。共享流在(2)处终止,而当独占流在(3)处被检查时,不存在冲突的流。

如果一个新的变量与前一个变量同名,它们仍然被认为是不同的变量。后一个同名的变量被叫做“影子(shadow)变量”。这两个变量将共存,不过随后的代码无法再为前一个变量命名。该模型与实际编译器内部机制,特别是借用检查器大致吻合,所以使用它可以编写高效的代码。

底层模型

变量为内存位置命名,这种内存位置可能持有合法值,也可能没有。你可以把变量看作是一个“值槽”。当你给它赋值的时候,槽被填满,它的旧值(如果有的话)被析构和替换。当你访问它时,编译器会检查该槽是否为空,因为这意味着该变量未被初始化或其值已经被移动。指向变量的指针,指的是该变量的备用内存,并可以被解引用以获得其值。例如在语句 let x:usize 中,变量 x 是内存区域的名称,该区域可以容纳一个大小为 usize 的值,尽管它没有一个明确的值(它的槽是空的)。如果你给该变量赋值,比如x=6,那么该区域的内存就会容纳代表6这个值的比特位(bits)。这个模型与 C 和 C++ 以及其他许多底层语言所使用的内存模型相匹配,当你需要明确推理内存时,它是有用的。

注意:在此例子中,我们忽略了 CPU 寄存器,并将其视为一种优化。在现实中,如果一个变量不需要内存地址,编译器可能会使用一个寄存器为变量所用,而不是内存区域。

你可能会发现,其中一个和你之前的模型比较吻合,但我鼓励你尝试把两个模型都弄明白。它们都同样有效,并且都非常简洁,任何有用的心智模型都必须是简洁的。如果你能从两个维度来考虑一段代码,你会发现在处理复杂的代码段时要容易得多,并能理解为什么它们能或不能按你的期望进行编译和执行。

内存区域

既然你已经掌握了如何引用内存,那么现在我们需要讨论内存究竟是什么。内存有许多不同的区域,也许令人惊讶的是,并非所有区域都存储在计算机的 DRAM[3] 中。使用哪一部分内存对编写代码的方式有重大影响。用于编写 Rust 代码的三个最重要的区域是栈(stack)、堆(heap)和静态内存(static memory)。

栈是一个内存段,用于程序中函数调用的暂存空间。每次调用函数时,都会在栈顶分配一个称为帧(frame)的连续内存块。靠近栈底部的是主函数的帧,当函数调用其他函数时,额外的帧被压入栈。函数的帧包含该函数中包含的所有变量,以及该函数接受的任何参数。当函数返回时,它的栈帧被回收。

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

栈帧,以及它们最终会消失的重要事实,与 Rust 中的生存期概念紧密相连。任何存储在栈帧中的变量在该帧消失后都不能被访问,所以对它的任何引用都必须有一个和不长于这个栈帧自身生存期的生存期。

堆是一个内存池,与当前程序调用栈无关。在堆内存中的值会一直存在,直到它们被明确地释放。当你想让一个值超过当前函数栈帧的生存期时,这很有用。如果该值是函数的返回值,调用函数可以在其栈中留下一些空间,让被调用的函数在返回前将该值写入其中。但是,如果你想将该值发送给不同的线程,而当前线程可能根本不与之共享栈帧,那么你可以将其存储在堆上。

堆这个内存池足够大,你可以直接在其中分配连续的内存段。然后,你会得到一个指向该内存段起点的指针。此内存段将为你保留,直到你以后释放它。这个过程通常称之为 free,以 C 标准库中相应函数的名字命名。由于堆中分配的内存在函数返回时不会消失,所以你可以为某个值分配堆内存,并把它的指针传给另外一个线程,并让这个线程安全地进行值操作。或者,换个说法,当你用堆内存分配时,产生的指针拥有不受约束的生存期,意味着,它的生存期和你的程序运行时一样长。

Rust 中与堆交互的主要机制是 Box 类型。当你写 Box::new(value)时,该值被放到堆上,而你得到的结果(Box<T>)是堆上该值的一个指针。当 Box 最终被析构(Drop)时,该内存被释放。

如果你忘记释放堆内存,它会永远存在,而你的应用程序最终会吃掉机器上的所有内存。这就是所谓的内存泄露(memory leaking),通常这是你应该避免的事情。然而,在某些情况下,你会明确地想要泄漏内存。例如,假设你有一个全局只读配置,整个程序都可以访问。你可以在堆上分配它,然后用 Box::leak显式地泄漏它,以获得它的“静态引用”。

静态内存

静态内存其实是对位于已编译文件的几个密切相关区域的总称。当程序被执行时,这些区域会自动加载到内存中。静态内存中的值在程序整个执行过程中一直存在。程序的静态内存包含程序的二进制代码,通常被映射为只读。当程序执行时,它会走查文本段(text-segment)中二进制代码的每条指令,并在调用函数时跳转。静态内存还保存着你用静态关键字声明变量的内存,以及代码中的某些常量值,比如字符串。

专门的生存期'static,其命名就来自于静态内存区域,标志着一个引用只要静态内存还在,就一直有效。这就是指整个程序执行期。由于静态变量内存是在程序开始时就分配的,所以根据定义,对静态内存中的变量的引用是“静态的('static)”,因为它在整个程序结束前都不会被释放。反之则不然。并不是 'static的引用就必须指向静态内存。但是这个名称是合适的:一旦你创建了具有静态生存期的引用,就程序的其他部分而言,它指向的任何内容都可能在静态内存中,因为无论程序希望使用它多久都可以。

在使用 Rust 时,你遇到 'static'生存期相比于遇到真正的静态内存(比如通过 static 关键字创建)要频繁得多。这是因为'static 经常出现在类型参数的 trait限定中。像 T: 'static 这样的限定表明,类型参数 T 能存活多久,就保留它多久,直到程序执行结束。从本质上讲,这种限定要求 T 是自有的(owned)和 自足的(self-sufficient),要么它不借用其他(非静态)的值,要么它所借用的任何东西也是静态的( 'static)。作为限定的'static有一个很好的示例是std::thread::spoon函数,它用于创建一个新的线程,并要求传递给它的闭包是'static的。新的线程可能会比当前线程存活期更长(outlive),所以新线程不能引用存储在旧线程栈上的任何东西。新线程只能引用在整个生存期内存在的值,这可能是程序的剩余时间。

注意:你可能想知道 conststatic 有什么区别。const关键字用于声明常量。常量项(Const Item)可以在编译时被完全计算出来,任何引用它们的代码在编译时都会被常量的计算值所取代。一个常量没有与之相关的内存或其他存储空间(它不是一个位置)。你可以把常量视为方便获取某个特定值的名称。

所有权(Ownership)

Rust 的内存模型的核心思想是,所有的值都有一个所有者(owner),也就是说,正好有一个位置(通常是一个作用域)负责最终释放每个值。这是通过借用检查器来执行的。如果值被移动了,比如把它赋值给一个新的变量、插入到新的动态数组(Vec)中,或把它放到堆上,值的所有权就会从旧的位置移动到新的位置。这时,你就不能再通过原来那个所有者变量来访问该值,即便构成该值的比特位事实上仍然存在。你必须通过引用其新位置的变量来访问被移动的值。

有些类型并不遵循这一规则。如果一个值的类型实现了特殊的 Copy 特质,哪怕它被重新赋值到新的内存位置,该值也不会被认为是已经移动。相反,该值已经被复制,新旧两个位置都可被访问。从本质来看,这种行为是在要移动的目的位置上构建了另一个值相同的实例。Rust 中大多数原生类型,比如整数和浮点类型,都是 复制(Copy)类型。要成为复制类型,必须能够做到简单地通过复制它们的比特位来复制该类型的值。这就排除了所有包含非复制类型的类型,以及任何拥有资源的类型。因为当值被析构时,它必须被释放。

要了解原因,请考虑如果像 Box 这样的类型如果被 复制会发生什么。如果我们执行 box2 = box1,那么 box1box2 都会相信它们拥有分配给 box 的堆内存。当它们超出作用域之外,都会尝试释放堆内存。两次释放堆内存可能会导致灾难性后果。

当一个值的所有者不再使用它时,所有者有责任通过析构(Drop)它来对该值进行任何必要的清理。在 Rust 中,当保存值的变量不在作用域内时,会自动析构。类型通常会递归地析构它们包含的值,因此析构复杂类型的变量可能会导致析构很多值。由于 Rust 的所有权分立,所以不会发生意外多次析构相同值的情况。一个变量持有对另一个值的引用,并不表示拥有那个值,因此当这个变量被析构的时候,它引用的另一个值并不会同时析构。

清单 2-3 中的代码快速总结了有关所有权、移动和复制语义以及析构的规则。

let x1 = 42;
let y1 = Box::new(84);
{   // 开始一个新的作用域
    let z = (x1, y1); // (1)
    // z 出了此作用域就要被析构;
    // 它依次析构 x1 和 y1 中的值
}   // (2)
// x1 的值是 Copy 语义,所以它不会移动给 z
let x2 = x1; // (3)
// y1 的值不是 Copy 语义, 所以它会移动给 z
// let y2 = y1;  // (4)

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

这段代码一开始有两个值,数字 42 和一个包含数字 84 的 Box 类型(在堆上存储)。前者是 复制语义,而后者不是。当我们把 x1y1 放到元组中时,x1 是被复制给 z,而 y1则被移动给 z。此时 x1 依然可以被访问,可以在 (3)处被再次使用。另一方面,一旦在(4)处的 y1 的值被移动,它就变得不可被访问了,任何访问它的尝试都会引起编译器错误。当 z 超出(2)处的作用域时,它所包含的元组值会被析构,这意味着会析构从x1复制的值和从y1移动的值。当 y1 的 Box 被析构时,它会释放用于存储 y1 值的堆内存。

析构顺序

当值超出作用域时, Rust 会自动析构它们,比如清单 2-3 中内部作用域的 x1x2 。析构顺序的规则相当简单:变量(包括函数参数)按相反的顺序析构,嵌套值按源代码的顺序析构。

这看上去很奇怪,为什么会有这样的差异?但如果我们仔细思考,就会发现这很有道理。假设你写了一个函数,声明了一个字符串,然后将该字符串的引用插入到一个新的哈希表中。当函数返回时,哈希表必须先被析构。如果字符串先被析构,那么哈希表就会持有一个无效引用。一般来说,后来的变量可能包含对早期值的引用,而由于 Rust 的生存期规则,反过来就不会发生这个问题。出于这种原因,Rust 以相反的顺序析构变量。

现在来反向析构嵌套的值,比如元组、数组或结构体中的值。但这可能会让用户感到惊讶。如果你构建了一个包含两个值的数组,如果数组的最后一个元素先被析构,那会显得非常奇怪。这同样适用于元组和结构体,最直观的行为是第一个元组元素或字段先被析构,然后是第二个,以此类推。与变量不同,在这种情况下没有必要颠倒析构顺序,因为 Rust 目前不允许在单个值中进行自引用(self-reference)。所以, Rust 使用了符合直觉的选择。

借用(Borrowing)和生存期 (Lifetimes)

Rust 允许一个值的所有者将该值的引用借出去而不会放弃自己的所有权。引用是一个指针,它携带了自身该如何被使用的附加契约,例如,引用是否提供对被引用值的独占访问,或者被引用值是否也可以有其他引用指向它。

共享引用

一个共享引用,&T,顾名思义是一个可以共享的指针。包含了指向同一个值的任意数量的其他引用,每个共享引用都是复制类型,所以你可以轻易地制造更多的引用。共享引用后面的值是不可变的。你不能修改或重新分配共享引用指向的值,也不能把共享引用强制转换为可变的值。

Rust 编译器会假设共享引用所指向的值在该引用存在期间不会改变。例如,如果 Rust 编译器看到一个共享引用背后的值在一个函数中被多次读取,那么它有权利只读取一次并重复使用该值。具体而言,清单2-4中的断言不应该失败。

fn cache(input: &i32, sum: &mut i32) {
   *sum = *input + *input;
   assert_eq!(*sum, 2 * *input);
}
// 清单2-4 :Rust 假设共享引用是不可变的

这基本和编译器是否选择应用某个特定的优化无关。由编译器启发式(compiler heuristics)会随着时间的推移而改变,所以应该针对编译器允许做的事情来编码,而不是在某个特定的时间点上,在特定的情况下实际做什么。

译注:编译器启发式(compiler heuristics)是指当今编译器使用硬编码的启发式方法来决定何时,是否以及仅应用有限的一组优化中的哪一项。

可变引用

共享引用的替代方案是可变引用:&mut T。对于可变引用,Rust 编译器又被允许充分利用引用所携带的契约:编译器假设没有其他线程访问的目标值,无论是通过共享引用还是可变引用。换句话说,编译器假设可变引用是独占的。这使得一些有趣的优化成为了可能,这些优化在其他语言中不容易实现。以清单 2-5 中代码为例。

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

// 清单2-5:  Rust 假设可变借用是独占的

在 Rust 中,编译器可以假设输入和输出不指向同一内存。因此, 在(1)处输出的重新分配不能影响(2)处的检查,整个函数可以编译成一个 if-else块(block)。如果编译器不能依赖可变性契约,这种优化就会失效,因为在 noalias(&x, &mut x) 这种情况下, (1)处的输入可能会导致输出 3

一个可以改变的引用只允许你改变该引用所指向的内存位置。你是否可以改变直接引用之外的值,取决于位于两者之间的类型所提供的方法。通过清单2-6示例更容易理解。

let x = 42;
let mut y = &x; // y &i32 类型
let z = &mut y; // z 是 &mut &i32 类型
// 清单 2-6: 可变性只适用于直接引用的内存

在此例中,你可以通过使指针 y 引用不同的变量来改变它的值(也就是不同的指针)。但你不能改变所指向的值(即 x 的值)。同样,你可以通过z来改变y的指针值,但你不能改变 z 自身,使其指向一个不同的值。

拥有一个值和拥有一个可变的引用之间的区别主要是,所有者负责在不需要时析构该值。除此之外,你可以通过一个可变引用做任何事情。如果你拥有这个值的话,有一点需要注意:如果你把这个值移到可变引用之后,那么你必须在它的位置上留下另一个值。原因很简单,如果你不这样做,所有者仍然会认为它需要析构这个值,但是那里已经没有供它析构的值了。

清单 2-7 给出一个例子,说明你可以通过哪些方式来移动一个可变引用后面的值。

fn replace_with_84(s: &mut Box<i32>) {
    // 这是不可能的,因为 *s 会变成空值 :
    // let was = *s; // (1)
    // 但是这可以:
    let was = std::mem::take(s); // (2)
    // 这也可以:
    *s = was; // (3)
    // 可以在 &mut 后面交换值:
    let mut r = Box::new(84);
    std::mem::swap(s, &mut r); // (4)
    assert_ne!(*r, 84);
}

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

// 清单 2-7:可变性仅适用于直接引用的内存。

上面代码中被注释的行表示非法操作。在(1)处你不能简单地将值移出,因为调用者仍然会认为它拥有这个值,并且会在(5)处再次释放它,导致双重释放(double free)。如果你只是想留下一些有效的值,在 (2)处的std::mem::take 是一个不错的选择。它等同于 std::mem::replace(&mut value, Default::default()) ,它将值从可变引用后移出,但是为该类型留下一个新的默认值。默认值是一个单独的、自有的值,所以当作用域在(5)处结束时,调用者可以安全地析构它。

另外,如果你不需要引用后面的旧值,可以用一个已经拥有的值来覆盖它(如(3)处),将它留给调用者来析构此值。当这么做的时候,可变引用后面的旧值会被立即析构。

最后,如果存在两个可变引用,那么可以在不拥有其中任何一个的情况下交换它们的值(如(4)处)。因为两个引用最后都会有一个合法持有的值,供它们的所有者最终释放。

内部可变性

一些类型提供内部可变性,这意味着它们允许你通过共享引用来改变一个值。这些类型通常依靠额外的机制(比如原子 CPU 指令)或不变性 来提供安全的可变性,而不依赖独占性引用的语义。这些类型通常分为两类:一类是通过共享引用获得一个可变的引用,另一类是通过共享引用进行可变。

第一类包括像 MutexRefCell这样的类型,它们包含安全机制,以确保对于它们给出的任何值的可变引用,每次只能有一个可变引用(而没有共享引用)存在。其底层机制是,这些类型(以及其他类似的类型)都依赖于一个叫做UnsafeCell的类型,这个名字可能会让你对使用它感到犹豫。我们将在第十章中更详细地介绍 UnsafeCell,但现在你应该知道,这是唯一合法地从 &T 得到&mut T的方式。

另一类提供内部可变性的类型是那些不提供内部值的可变引用的类型,它们只对外公开操作该值的方法。std::sync::atomic中的原子整数类型和std::cell::Cell类型就属于这种类型。你不能直接获得对这种类型背后的 usizei32的引用,但你可以在一个给定的时刻读取和替换它的值。

注意:标准库中的 Cell 类型是一个有趣的例子,它通过不变(invariants)实现了安全的内部可变性。它不能跨线程共享,也不会给出对 Cell 中包含值的引用。相反,所有的方法要么完全替换该值,要么返回所含值的一个副本。因为不存在对内部值的引用,所以总是可以移动它。而且,由于 Cell 不能跨线程共享,即便可变是通过共享引用发生的,内部值也不会被并发改变。

生存期

可能正在看此书的你,已经通过编译器对违反生存期规则的反复抱怨而熟悉了生存期(lifetime)的概念。这种程度的理解对于你将要写的大多数 Rust 程序来说是有帮助的,但是当我们深入到 Rust 更复杂的部分时,你将需要一个更严格的心智模型来工作。

新的 Rust 开发者经常被教导要把生存期看作是与作用域相对应的:当获取某个变量的引用时,一个生存期就开始了,当这个变量被移动或超出作用域范围,生存期就结束了。这通常是正确的,而且通常是有用的,但实际情况要复杂一些。生存期实际上是一段代码区域的名称,一些引用必须在该区域内有效。虽然生存期经常与作用域重合,但并不是必须如此,正如我们将在本节后面看到那样。

生存期和借用检查

Rust 生存期的核心是借用检查器。每当一个具有某个生存期的引用'a 被使用时,借用检查器就会检查 'a是否仍然存在。它通过追踪路径回到 'a开始的位置,即引用被使用的地方,来实现这一点,并检查该路径上是否有任何冲突的使用。这可以确保引用仍然指向一个可以安全访问的值。这类似于我们的本章前面讨论的抽象“流”模型。编译器检查我们正在访问的引用的流不会与任何其他并行流相冲突。

清单 2-8 展示了一个简单的示例,其中有对 x 引用的生存期注释。

let mut x = Box::new(42);
let r = &x; // 'a  // (1)
if rand() > 0.5 {  // 译注:这个浮点数类型会出现问题,因为编译器已经推断为 i32 类型。 要正常执行需要将 if 条件修改为 `{use rand; rand::random::<f32>() > 0.5 }`
  
    *x = 84;         // (2)
else {
    println!("{}", r); // 'a     // (3)

// (4)
// 清单 2-8: 生存期不需要连续

生存期开始于(1)处对 x的引用。在(2)处的第一个分支中,将其值改为 84,这里需要一个 &mut x。编译器发现在获取引用和使用引用之间并无冲突,所以接受了这段代码。如果你习惯于将生存期视为作用域的话,这可能会让你感到惊讶,因为 r(2)处依然在作用域中(在(4)处出了作用域)。但是借用检查器足够聪明,它意识到如果这个分支被选中,以后就不会再使用 r,因此 x在这里被可变访问是没有问题的。或者,换句话说,在(1)处创建的生存期并没有延伸到这个分支。在(2)处之后没有来自于r的流,因此没有冲突的流。然后借用检查器在(3)处打印语句中发现了r被使用。它回到了(1)处的路径,发现没有冲突在使用((2)并没有在该路径上),所以它也接受了这个使用。

如果我们在清单 2-8 中的 (4)处添加 r 的另外一个用法,代码就不会再被编译了。生存期 'a将从(1)处持续到(4)处(r的最后一次使用),当借用检查器检查我们对r新的使用时,它会发现在(2)处有一个冲突的使用。

生存期可以变得非常复杂。在清单 2-9 中,你能看到一个有缺陷的生存期示例,它在开始和最终结束的地方周期性失效。

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

// 清单 2-9: 生存期存在缺陷

生存期从(1)处获取x的引用开始。然后在 (3)处移出了 x,使得'a 结束了生存期,因为它已经失效了。借用检查器通过考虑 'a(2)处结束而接受了这种移动,使得 (3)处没有来自 x的冲突流。无论代码现在是循环到(2)还是继续到最后的打印语句,都有一个有效的值可供流动,而且没有冲突的流,所以借用检查接受了这段代码。

同样,这与我们之前讨论的内存数据流模型完全吻合。当x被移动时,z与此同时也将失效。当稍后重新指派 z时,会创建一个仅在此处存在的全新的变量。考虑到这个模型,这个例子并不奇怪。

泛型生存期

偶尔你需要在自己的类型中存储引用。这些引用需要有一个生存期,当它们被用于该类型的各种方法时,借用检查器可以凭此检查它们的有效性。如果你想让类型的某个方法返回比自己的引用存活期更长的引用,尤其需要如此。

Rust 允许你定义包含一个或多个生存期的泛型类型。就像定义泛型类型一样。在 Steve Klabnik和Carol Nichols的《Rust 权威指南》中对这一主题做了一些详细介绍,所以我不会在这里重申基础知识。但是,当你编写这种性质更复杂的类型时,围绕这种类型和生存期之间的互动,有两个微妙的问题需要被注意:

  • 如果类型也实现了 Drop,那么析构这个类型将被视为任意生存期的使用或被标注为泛型类型。基本上,当你的类型实例被析构时,借用检查器会检查在析构它之前使用你的类型的任何泛型生存期是否仍然合法。这是必要的,以防止析构代码确实使用了这些引用。如果类型没有实现 Drop,析构这个类型就不被视为使用,用户只要不再使用该类型,就可以自由地使存储在类型中的任意引用失效,就像在清单 2-7 中所看到的那样。我们将在第十章中讨论更多关于析构的规则。
  • 虽然一个类型可以在包括多个生存期泛型,但经常这么做只会使得类型签名变得复杂。通常情况下,一个类型使用一个生存期泛型即可,编译器会将掺入到类型中的任何引用的生存期较短的那个作为类型的生存期。只有当你有一个包含多个引用的类型,并且它的方法返回的引用应该只与其中一个引用的生存期挂钩时,你才应该真正使用多个泛型生存期参数。

考虑一下清单 2-10 中的类型,它实现了一个迭代去,用于遍历由一个特定字符串分隔的部分。

struct StrSplit<'s'p> {
    delimiter: &'p str,
    document: &'s str,
}
impl<'s'pIterator for StrSplit<'s'p> {
    type Output = &'s str;
    fn next(&self) -> Option<Self::Output> {
        todo!()
    }
}
fn str_before(s: &str, c: char) -> Option<&str> {
    StrSplit { document: s, delimiter: &c.to_string() }.next()
}

// 清单 2-10: 需要多个泛型生存期的类型

当你构造此类型时,你必须给出要搜索的分隔符和文件,这两者都是对字符串值的引用。当你要求搜索下一个字符串时,会得到对document的引用。考虑一下,如果你在这种类型中使用单一的生存期会发生什么?迭代器产生的值,将与document的生存期和delimiter 相关联。这将使得str_before无法编写:返回类型将有一个与函数本地变量相关的生存期,即 to_string产生的String,并且借用检查器将拒绝该代码。

生存期型变

型变(Variance)是程序员经常接触到的一个概念,但很少有人知道它的名称,因为它大多是不可见的。简而言之,型变描述了哪些类型是其他类型的子类型(subtype),以及什么时候应该用子类型代替父类型(supertype),以及反过来。广义上来讲,如果一个类型A 至少和 B 一样有用,那么它就是另一个类型 B 的子类型。以 Java 来说,型变就是,如果 TurtleAnimal的子类型,那么可以把Turtle传递给接受 Animal的函数。在 Rust 中,这就是可以把&'static str传给接受&'a str函数的原因。

虽然型变隐藏在视线之外,但它经常出现,所以我们需要对它的工作机制有所了解。TurtleAnimal的子类型,因为 Turtle比一些未指定的 Animal更“有用”,即 Turtle可以做任何Animal可以做的事情,而且可能更多。同样,'static'a的子类型,因为'static的生存期至少与任何'a一样长,所以更有用。或者,更一般地说,如果 'b: 'a'b'a存活更长时间),那么'b'a的子类型。这显然不是正式的定义,但是它已经足够接近实际的用法了。

所有的类型都有型变,它定义了哪些奇特类似的类型可以用于该类型的位置。共有三种型变:协变(covariant)、不变(invariant)和逆变(contravariant)。如果你只可以使用子类型来代替一个类型,那么该类型就是协变的。例如,如果一个变量是 &'a T类型,那么你可以给它提供一个&'static T类型的值,因为&'a T'a上是协变的。&'a TT上也是协变的,所以你可以把&Vec<&'static str>传给一个接受&Vec<&'a str>的函数。

有些类型是不变的,这意味着你必须准确提供给定的类型。&mut T就是一个例子。如果一个函数接收&mut Vec<&'a str>,你就不能把&mut Vec<&'static str>传给它。原因很简单:如果这被允许,函数可以在Vec中放入一个短存活期的字符串,然后调用者会继续使用,认为它是一个 Vec<&'static str>,从而认为包含的字符串是'static的。任何提供可变性的类型一般都是不变的,原因都是如此。例如,Cell<T>T上是不变的。

最后一类,即逆变,出现在函数参数上。如果函数类型可以接受其参数不那么有用,那么它们就会更 有用。如果你将参数类型自身的型变与它们作为函数参数时的型变进行对比,就更清楚了。

let x: &'static str// 更有用,活的更长
let x: &'a str// 不太有用,活得更短
fn take_func1(&'static str// 更严格,所以不那么有用
fn take_func2(&'a str// 不太严格,所以更有用

这种翻转关系表明,Fn(T)T上是逆变。

那么,当涉及到生存期时候,为什么需要学习型变呢?当你考虑泛型生存期如何与借用检查器交互时,型变就变得相关了。考虑清单2-11中所示类型,它在一个字段中使用了多个生存期。

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

// 清单 2-11: 需要多个泛型生存期的类型

乍一看,在这里使用两个生存期似乎没必要,我们没有任何方法需要区分结构中不同部分的借用,就像清单2-10中的StrSplit那样。但是如果把这里的两个生存期换成同一个'a,代码就不能再编译了!这都是因为型变。

(1)处,编译器必须确定生存期参数应该被设置为哪种生存期。如果是两个生存期,'a被设置为有待确认的s的借用生存期,'b被设置为'static,因为那里提供的是"hello"的生存期。然而,如果只有一个生存期'a,编译器就会推断这个生命周期必须是'static。当我们试图通过共享引用访问字符串引用s 来打印它时,编译器试图缩短MutStr使用 s的可变借用,这样我们就可以再次借用s

在双生存期的情况下,'a只是在打印前结束,'b保持不变。另一方面,在单生存期的情况下,我们遇到了一些问题。编译器想缩短s的借用,但要做到这一点,也必须缩短str的借用。虽然 &'static str一般来说可以缩短为任何&'a str&'a T'a是协变的),但这里它在&mut T后面,而&mut TT上是不变的。不变要求相关类型永远不会被子类型或父类型取代,所以编译器缩短借用的尝试失败了,它报告该清单仍然有可变借用!

总之,你要确保类型在尽可能多的泛型参数上保持协变(或者在适当的时候保持逆变)。如果这需要引入额外的生存期参数,你需要仔细权衡增加一个参数的认知成本和型变的用户体验。

总结

本章的目的是建立一个坚实的、有共识的基础,可以让我们继续学习后面的章节。到目前为止,我希望你能牢牢地掌握 Rust 的内存和所有权模型,而且那些你可能从借用检查器中看到的错误也似乎不那么神秘了。你可能已经知道了本章所涉及的零星内容,但希望这一章能给你一个全面的印象,让你知道这一切是如何结合起来的。在下一章中,我们将为类型做一些类似的事情。我们将讨论类型如何在内存中表示,看看泛型和特质(trait)如何产生执行代码,并看看 Rust 为更高级的用例提供的一些特殊类型和特质结构。

参考资料

[1]

https://nostarch.com/rust-rustaceans: https://nostarch.com/rust-rustaceans

[2]

值域: https://zh.wikipedia.org/wiki/%E5%80%BC%E5%9F%9F

[3]

DRAM: https://zhuanlan.zhihu.com/p/21294481


本文分享自微信公众号 - 觉学社(WakerGroup)。
如有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部