介绍

ch01-00-introduction.md
commit 62f78bb3f7c222b574ff547d0161c2533691f9b4

欢迎阅读“Rust 程序设计语言”,一本介绍 Rust 的书。Rust 是一门着眼于安全、速度和并发的编程语言。它的设计兼顾性能与底层控制,以及高级语言强大的抽象能力。适合那些有类 C 语言经验,正在寻找更安全的替代品的开发者;以及有着类 Python 语言背景,寻求在不牺牲表现力的前提下,编写性能更好的代码的开发者。

Rust 主要在编译时执行安全检查和内存管理决策,对运行时性能的影响微不足道。这使其在许多语言不擅长的应用场景中得以大显身手:空间和时间需求可预测的程序,嵌入到其他语言中,以及编写底层代码,如设备驱动和操作系统。Rust 也很擅长 web 程序:它驱动着 Rust 包注册网站(package registry site),crates.io!我们期待使用 Rust 进行创作。

本书的目标读者至少应了解一门其它编程语言。读完本书之后,你应该能自如的编写 Rust 程序。我们将通过短小精干、前后呼应的例子来学习 Rust,并展示其多样功能的使用方法,同时了解幕后如何运行。

为本书做出贡献

本书是开源的。如果你发现任何错误,不要犹豫,在 GitHub 上发起 issue 或提交 pull request。请查看 CONTRIBUTING.md 获取更多信息。

译者注:译本的 GitHub 仓库,同样欢迎 Issue 和 PR :)

安装

ch01-01-installation.md
commit c1b95a18dbcbb06aadf07c03759f27d88ccf62cf

第一步是安装 Rust。你需要网络连接来执行本章的命令,因为我们要从网上下载 Rust。

我们将会展示很多在终端中输入的命令,这些命令均以 $ 开头。你不需要真的输入$,在这里它代表每行命令的起始。网上有很多教程和例子遵循这种惯例:$ 代表以常规用户身份运行命令,# 代表需要用管理员身份运行命令。没有以 $(或 #)起始的行通常是之前命令的输出。

在 Linux 或 Mac 上安装

如果你使用 Linux 或 Mac,你需要做的全部,就是打开一个终端并输入:

$ curl https://sh.rustup.rs -sSf | sh

这会下载一个脚本并开始安装。可能会提示你输入密码,如果一切顺利,将会出现如下内容:

Rust is installed now. Great!

当然,如果你对于 curl | sh 心有疑虑,你可以随意下载、检查和运行这个脚本。

在 Windows 上安装

如果你使用 Windows,前往 https://rustup.rs,按说明下载 rustup-init.exe,运行并照其指示操作。

本书中其余 Windows 相关的命令,假设你使用 cmd 作为 shell。如果你使用其它 shell,也许可以执行与 Linux 和 Mac 用户相同的命令。如果不行,请查看该 shell 的文档。

自定义安装

无论出于何种理由,如果不愿意使用 rustup.rs,请查看 Rust 安装页面 获取其他选择。

更新

一旦 Rust 安装完,更新到最新版本很简单。在 shell 中执行:

$ rustup update

卸载

卸载 Rust 同样简单。在 shell 中执行:

$ rustup self uninstall

故障排除

安装完 Rust 后,在 shell 中执行:

$ rustc --version

应该能看到类似这样的版本号、提交哈希和提交日期,对应安装时的最新稳定版:

rustc x.y.z (abcabcabc yyyy-mm-dd)

出现这些内容,Rust 就安装成功了!

恭喜入坑!(此处应该有掌声!)

如果在 Windows 中使用出现问题,检查 Rust(rustc,cargo 等)是否在 %PATH% 环境变量所包含的路径中。

如果还是不能解决,有许多地方可以求助。最简单的是 irc.mozilla.org 上的 #rust IRC 频道 ,可以使用 Mibbit 来访问它。然后就能和其他 Rustacean(Rust 用户的称号,有自嘲意味)聊天并寻求帮助。其它给力的资源包括用户论坛Stack Overflow

本地文档

安装程序自带本地文档,可以离线阅读。输入 rustup doc 可以在浏览器中查看。

任何时候,如果你拿不准标准库中类型或函数,请查看 API 文档!

Hello, World!

ch01-02-hello-world.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

Rust 已安好,让我们来编写第一个程序。当学习一门新语言的时候,使用该语言在屏幕上打印 “Hello, world!” 是一项传统,我们将遵循这个传统。

注意:本书假设你熟悉基本的命令行操作。对于你的编辑器、工具,以及你的代码存在何处,Rust 并没有特殊要求,如果你更喜欢 IDE,请随意。

创建项目文件夹

首先,创建一个存放代码的文件夹。Rust 并不关心它的位置,不过在本书中,我们建议你在 home 目录中创建一个 projects 目录,并把你的所有项目放在这。打开一个终端,输入如下命令来创建一个文件夹:

Linux 和 Mac:

$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world

Windows:

> mkdir %USERPROFILE%\projects
> cd %USERPROFILE%\projects
> mkdir hello_world
> cd hello_world

编写并运行 Rust 程序

接下来,新建一个叫做 main.rs 的文件。Rust 源代码总是以 .rs 后缀结尾。如果文件名包含多个单词,使用下划线分隔它们。例如 my_program.rs,而不是 myprogram.rs

现在打开刚创建的 main.rs 文件,输入如下代码:

Filename: main.rs

fn main() {
    println!("Hello, world!");
}

保存文件,并回到终端窗口。在 Linux 或 OSX 上,输入如下命令:

$ rustc main.rs
$ ./main
Hello, world!

在 Windows 上,运行 .\main.exe,而不是./main。不管使用何种系统,你应该在终端看到 Hello, world! 字样。如果你做到了,恭喜你!你已经正式编写了一个 Rust 程序,成为一名 Rust 程序员!

分析 Rust 程序

现在,让我们回过头来,仔细看看“Hello, world!”程序到底发生了什么。这是拼图的第一片:

fn main() {

}

这几行定义了一个 Rust 函数。一个叫 main 的函数,没有参数也没有返回值。如果有参数的话,它们应该出现在括弧中,()之间。main 函数是特殊的:它是每一个可执行的 Rust 程序的入口点。

还须注意函数体被包裹在花括号中,{} 之间。所有函数体都要用花括号包裹起来(译者注:有些语言,当函数体只有一行时可以省略花括号,但 Rust 中是不行的)。一般来说,将左花括号与函数声明置于一行,并以空格分隔,是良好的代码风格。

main() 函数中:

    println!("Hello, world!");

一行代码完成这个小程序的所有工作:在屏幕上打印文本。这里有很多细节需要注意。首先 Rust 使用 4 个空格的缩进风格,而不是 1 个制表符(tab)。

第二个重要的部分是println!()。这是 ,Rust 元编程(metaprogramming)的关键所在。而调用一个函数,则要像这样:println(没有!)。我们将在 21 章 E 小节中更加详细的讨论宏,现在你只需记住,当看到符号 ! 的时候,调用的是宏而不是普通函数。

接下来,"Hello, world!" 是一个 字符串。我们把这个字符串作为一个参数传递给println!,它负责在屏幕上打印这个字符串。轻松加愉快!(⊙o⊙)

该行以分号结尾(;)。; 代表一个表达式的结束和下一个表达式的开始。大部分 Rust 代码行以 ; 结尾。

编译和运行是两个步骤

“编写并运行 Rust 程序”部分,展示了如何创建运行程序。现在我们将拆分并检查每一步操作。

运行一个 Rust 程序之前,必须先编译它。可以通过 rustc 命令来使用 Rust 编译器,并传递源文件的名字给它,如下:

$ rustc main.rs

如果你有 C 或 C++ 背景,就会发现这与 gccclang 类似。编译成功后,Rust 应该会输出一个二进制可执行文件,在 Linux 或 OSX 上在 shell 中你可以通过ls命令看到如下:

$ ls
main  main.rs

在 Windows 上,输入:

> dir /B %= the /B option says to only show the file names =%
main.exe
main.rs

这表示我们有两个文件:.rs 后缀的源文件,和可执行文件(在 Windows下是 main.exe,其它平台是 main)。然后运行 mainmain.exe 文件,像这样:

$ ./main  # or .\main.exe on Windows

如果 main.rs 是我们的“Hello, world!”程序,它将会在终端上打印Hello, world!

来自 Ruby、Python 或 JavaScript 这样的动态类型语言背景的同学,可能不太习惯将编译和执行分为两个步骤。Rust 是一种 预编译静态类型语言ahead-of-time compiled language),这意味着编译好程序后,把它给任何人,他们不需要安装 Rust 就可运行。如果你给他们一个 .rb.py.js 文件,他们需要先分别安装 Ruby,Python,JavaScript 实现(运行时环境,VM),不过你只需要一句命令就可以编译和执行程序。这一切都是语言设计上的权衡取舍。

使用 rustc 编译简单程序是没问题的,不过随着项目的增长,你可能需要控制你项目的方方面面,并且更容易地将代码分享给其它人或项目。所以接下来,我们要介绍一个叫做 Cargo 的工具,它会帮助你编写真实世界中的 Rust 程序。

Hello, Cargo!

Cargo 是 Rust 的构建系统和包管理工具,同时 Rustacean 们使用 Cargo 来管理他们的 Rust 项目,它使得很多任务变得更轻松。例如,Cargo 负责构建代码、下载依赖库并编译。我们把代码需要的库叫做 依赖dependencies)。

最简单的 Rust 程序,比如我们刚刚编写的,并没有任何依赖,所以我们只使用了 Cargo 构建代码的功能。随着更复杂程序的编写,你会想要添加依赖,如果你使用 Cargo 开始的话,这将会变得简单许多。

由于绝大部分 Rust 项目使用 Cargo,本书接下来的部分将假设你使用它。如果使用之前介绍的官方安装包的话,它自带 Cargo。如果通过其他方式安装的话,可以在终端输入如下命令,检查是否安装了 Cargo:

$ cargo --version

如果出现了版本号,一切 OK!如果出现类似“command not found”的错误,你应该查看安装文档以确定如何单独安装 Cargo。

使用 Cargo 创建项目

让我们使用 Cargo 来创建一个新项目,然后看看与上面的hello_world项目有什么不同。回到 projects 目录(或者任何你放置代码的目录):

Linux 和 Mac:

$ cd ~/projects

Windows:

> cd %USERPROFILE%\projects

并在任何操作系统运行:

$ cargo new hello_cargo --bin
$ cd hello_cargo

我们向 cargo new 传递了 --bin,因为我们的目标是生成一个可执行程序,而不是一个库。可执行程序是二进制可执行文件,通常就叫做 二进制文件binaries)。项目的名称被定为hello_cargo,同时 Cargo 在一个同名目录中创建它的文件,接着我们可以进入查看。

如果列出 hello_cargo 目录中的文件,将会看到 Cargo 生成了一个文件和一个目录:一个 Cargo.toml 文件和一个 src 目录,main.rs 文件位于目录中。它也在 hello_cargo 目录初始化了一个 git 仓库,以及一个 .gitignore 文件;你可以通过--vcs参数,切换到其它版本控制系统(VCS),或者不使用 VCS。

使用文本编辑器(IDE)打开 Cargo.toml 文件。它应该看起来像这样:

Filename: Cargo.toml

[package]
name = "hello_cargo"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]

[dependencies]

这个文件使用 TOML (Tom's Obvious, Minimal Language) 格式。TOML 类似于 INI,不过有一些额外的改进之处,并且被用作 Cargo 的配置文件的格式。

第一行,[package],是一个段落标题,表明下面的语句用来配置一个包。随着我们在这个文件增加更多的信息,还将增加其他段落。

最后一行,[dependencies],是项目依赖的 crates(Rust 代码包)的段落的开始,这样 Cargo 就知道下载和编译它们了。这个项目并不需要任何其他的 crate,不过在下一章猜猜看教程会需要。

现在看看 src/main.rs

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

Cargo 为你生成了一个“Hello World!”,正如我们之前编写的那个!目前为止,之前项目与 Cargo 生成项目区别有:

  • 代码位于 src 目录
  • 项目根目录包含一个 Cargo.toml 配置文件

Cargo 期望源文件位于 src 目录,将项目根目录留给 README、license 信息、配置文件和其他跟代码无关的文件。这样,Cargo 帮助你保持项目干净整洁,一切井井有条。

如果没有用 Cargo 创建项目,比如 hello_world 目录中的项目,可以通过将代码放入 src 目录,并创建一个合适的 Cargo.toml,将其转化为一个 Cargo 项目。

构建并运行 Cargo 项目

现在让我们看看通过 Cargo 构建和运行 Hello World 程序有什么不同。为此,输入如下命令:

$ cargo build
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)

这应该创建 target/debug/hello_cargo(或者在 Windows 上是 target\debug\hello_cargo.exe)可执行文件,可以通过这个命令运行:

$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!

好的!如果一切顺利,Hello, world!应该再次打印在终端上。

首次运行 cargo build 的时候,Cargo 会在项目根目录创建一个新文件,Cargo.lock,它看起来像这样:

Filename: Cargo.lock

[root]
name = "hello_cargo"
version = "0.1.0"

Cargo 使用 Cargo.lock 来记录程序的依赖。这个项目并没有依赖,所以内容比较少。事实上,你自己永远也不需要碰这个文件,让 Cargo 处理它就行了。

我们刚刚使用 cargo build 构建了项目并使用 ./target/debug/hello_cargo 运行了它,也可以使用 cargo run 编译并运行:

$ cargo run
     Running `target/debug/hello_cargo`
Hello, world!

注意这一次并没有出现“正在编译 hello_cargo”的输出。Cargo 发现文件并没有被改变,直接运行了二进制文件。如果修改了源文件的话,将会出现像这样的输出:

$ cargo run
   Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
     Running `target/debug/hello_cargo`
Hello, world!

所以现在又出现更多的不同:

  • 使用 cargo build 构建项目(或使用 cargo run 一步构建并运行),而不是使用rustc
  • 有别于将构建结果放在源码目录,Cargo 将它放到 target/debug 目录。

Cargo 的另一个优点是,不管你使用什么操作系统,它的命令都是一样的,所以之后我们将不再为 Linux 和 Mac 以及 Windows 提供相应的命令。

发布构建

当项目最终准备好发布了,可以使用 cargo build --release 来优化编译项目。这会在 target/release 下生成可执行文件,而不是 target/debug。优化可以让 Rust 代码运行的更快,然而也需要更长的编译时间。因此产生了两种不同的配置:一种为了开发,你需要快速重新构建;另一种构建给用户的最终程序,不会重新构建,并且程序运行得越快越好。如果你在测试代码的运行时间,请确保运行 cargo build --release 并使用 target/release 下的可执行文件。

把 Cargo 当作习惯

对于简单项目, Cargo 并不比 rustc 更有价值,不过随着开发的进行终将体现它的价值。对于拥有多个 crate 的复杂项目,让 Cargo 来协调构建将更简单。有了 Cargo,只需运行cargo build,然后一切将有序运行。即便这个项目很简单,也它使用了很多你之后的 Rust 生涯将会用得上的实用工具。其实你可以开始任何你想要从事的项目,使用下面的命令:

$ git clone someurl.com/someproject
$ cd someproject
$ cargo build

注意:如果想要了解 Cargo 更多的细节,请阅读官方的 Cargo guide,它覆盖了所有的功能。

猜猜看

ch02-00-guessing-game-tutorial.md
commit e6d6caab41471f7115a621029bd428a812c5260e

让我们亲自动手,快速熟悉 Rust!本章将介绍 Rust 中常用的一些概念,并通过真实的程序来展示如何运用。你将会学到更多诸如 letmatch、方法、关联函数、外部 crate 等知识!后继章节会深入探索这些概念的细节。在这一章,我们将练习基础。

我们会实现一个经典的新手编程问题:猜猜看游戏。它是这么工作的:程序将会随机生成一个 1 到 100 之间的随机整数。接着它会请玩家猜一个数并输入,然后提示猜测是大了还是小了。如果猜对了,它会在退出前祝贺你。

准备一个新项目

要创建一个新项目,进入第一章创建的项目目录,使用 Cargo 创建它:

$ cargo new guessing_game --bin
$ cd guessing_game

第一个命令,cargo new,获取项目的名称(guessing_game)作为第一个参数。--bin参数告诉 Cargo 创建一个二进制项目,与第一章类似。第二个命令进入到新创建的项目目录。

看看生成的 Cargo.toml 文件:

Filename: Cargo.toml

[package]
name = "guessing_game"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]

[dependencies]

如果 Cargo 从环境中获取的开发者信息不正确,修改这个文件并再次保存。

正如第一章那样,cargo new 生成了一个“Hello, world!”程序。查看 src/main.rs 文件:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");
}

现在让我们使用 cargo run,编译运行一步到位:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/debug/guessing_game`
Hello, world!

run 命令适合用在需要快速迭代的项目,而这个游戏就是:我们需要在下一步迭代之前快速测试。

重新打开 src/main.rs 文件。我们将会在这个文件中编写全部代码。

处理一次猜测

程序的第一部分请求和处理用户输入,并检查输入是否符合预期。首先,需要有一个让玩家输入猜测的地方。在 src/main.rs 中输入列表 2-1 中的代码。

Filename: src/main.rs

use std::io;

fn main() {
    println!("Guess the number!");

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

Listing 2-1: Code to get a guess from the user and print it out

这些代码包含很多信息,我们一点一点地过一遍。为了获取用户输入并打印结果作为输出,我们需要将 io(输入/输出)库引入当前作用域。io库来自于标准库(也被称为std):

use std::io;

Rust 默认只在每个程序的 prelude 中引入少量类型。如果需要的类型不在 prelude 中,你必须使用一个 use 语句显式的将其引入作用域。std::io 库提供很多 io 相关的功能,比如接受用户输入。

如第一章所提及,main 函数是程序的入口点:

fn main() {

fn 语法声明了一个新函数,() 表明没有参数,{ 作为函数体的开始。

第一章也提及,println! 是一个在屏幕上打印字符串的宏:

println!("Guess the number!");

println!("Please input your guess.");

这些代码仅仅打印提示,介绍游戏的内容然后请用户输入。

用变量储存值

接下来,创建一个地方储存用户输入,像这样:

let mut guess = String::new();

现在程序开始变得有意思了!这一小行代码发生了很多事。注意这是一个 let 语句,用来创建变量。这里是另外一个例子:

let foo = bar;

这行代码会创建一个叫做 foo 的新变量并把它绑定到值 bar 上。在 Rust 中,变量默认是不可变的。下面的例子展示了如何在变量名前使用 mut 来使一个变量可变:

let foo = 5; // immutable
let mut bar = 5; // mutable

注意:// 开始一个注释,它持续到本行的结尾。Rust 忽略注释中的所有内容。

现在我们知道了 let mut guess 会引入一个叫做 guess 的可变变量。等号(=)的右边是 guess 所绑定的值,它是 String::new 的结果,这个函数会返回一个 String 的新实例。String 是一个标准库提供的字符串类型,它是 UTF-8 编码的可增长文本块。

::new 那一行的 :: 语法表明 newString 类型的一个 关联函数associated function)。关联函数是针对类型实现的,在这个例子中是 String,而不是 String 的某个特定实例。一些语言中把它称为静态方法static method)。

new 函数创建了一个新的空 String,你会在很多类型上发现new 函数,这是创建类型实例的惯用函数名。

总结一下,let mut guess = String::new(); 这一行创建了一个可变变量,绑定到一个新的 String 空实例上。

回忆一下,我们在程序的第一行使用 use std::io; 从标准库中引入“输入输出”。现在调用 io 的关联函数 stdin

io::stdin().read_line(&mut guess)
    .expect("Failed to read line");

如果程序的开头没有 use std::io 这一行,我们可以把函数调用写成 std::io::stdinstdin 函数返回一个 std::io::Stdin 的实例,这代表终端标准输入句柄的类型。

代码的下一部分,.read_line(&mut guess),调用 read_line 方法从标准输入句柄获取用户输入。我们还向 read_line() 传递了一个参数:&mut guess

read_line 的工作是,无论用户在标准输入中键入什么内容,都将其存入一个字符串中,因此它需要字符串作为参数。这个字符串参数应该是可变的,以便 read_line 将用户输入附加上去。

& 表示这个参数是一个引用reference),它允许多处代码访问同一处数据,而无需在内存中多次拷贝。引用是一个复杂的特性,Rust 的一个主要优势就是安全而简单的操纵引用。完成当前程序并不需要了解如此多细节:第四章会更全面的解释引用。现在,我们只需知道它像变量一样,默认是不可变的,需要写成 &mut guess 而不是 &guess 来使其可变。

我们还没有分析完这行代码。虽然这是单独一行代码,但它是一个逻辑行(虽然换行了但仍是一个语句)的第一部分。第二部分是这个方法:

.expect("Failed to read line");

当使用 .expect() 语法调用方法时,通过‘换行并缩进’来把长行拆开,是明智的。我们完全可以这样写:

io::stdin().read_line(&mut guess).expect("Failed to read line");

不过,过长的行难以阅读,所以最好拆开来写,两行代码两个方法调用。现在来看看这行代码干了什么。

使用 Result 类型来处理潜在的错误

之前提到,read_line 将用户输入附加到传递给它字符串中,不过它也返回一个值——在这个例子中是 io::Result。Rust 标准库中有很多叫做 Result 的类型。一个 Result 泛型以及对应子模块的特定版本,比如 io::Result

Result 类型是 枚举enumerations,通常也写作 enums。枚举类型持有固定集合的值,这些值被称为枚举的成员variants)。第六章将介绍枚举的更多细节。

对于 Result,它的成员是 OkErrOk 表示操作成功,内部包含产生的值。Err 意味着操作失败,包含失败的前因后果。

Result 类型的作用是编码错误处理信息。Result 类型的值,像其他类型一样,拥有定义于其上的方法。io::Result 的实例拥有expect方法,如果实例的值是 Errexpect 会导致程序崩溃,并显示当做参数传递给 expect 的信息;如果实例的值是 Okexpect 会获取 Ok 中的值并原样返回。在本例中,这个值是用户输入到标准输入中的字节的数量。

如果不使用 expect,程序也能编译,不过会出现一个警告:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
src/main.rs:10:5: 10:39 warning: unused result which must be used,
#[warn(unused_must_use)] on by default
src/main.rs:10     io::stdin().read_line(&mut guess);
                   ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Rust 警告我们没有使用 read_line 的返回值 Result,说明有一个可能的错误没处理。想消除警告,就老实的写错误处理,不过我们就是希望程序在出现问题时立即崩溃,所以直接使用 expect。第九章会学习如何从错误中恢复。

使用 println! 占位符打印值

除了位于结尾的大括号,目前为止就只有一行代码值得讨论一下了,就是这一行:

println!("You guessed: {}", guess);

这行代码打印存储用户输入的字符串。第一个参数是格式化字符串,里面的 {} 是预留在特定位置的占位符。使用占位符也可以打印多个值:格式化字符串中第一个占位符对应第二个参数值,第二个占位符对应第三个参数值,以此类推(第一个参数是格式化字符串本身)。调用一次 println! 打印多个值看起来像这样:

let x = 5;
let y = 10;

println!("x = {} and y = {}", x, y);

这行代码会打印出 x = 5 and y = 10

测试第一部分代码

让我们来测试下猜猜看游戏的第一部分。使用cargo run运行它:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6

至此为止,游戏的第一部分已经完成:我们从键盘获取输入并打印了出来。

生成一个秘密数字

接下来,需要生成一个秘密数字,好让用户来猜。秘密数字应该每次都不同,这样重复玩才不会乏味;范围应该在 1 到 100 之间,这样才不会太困难。Rust 标准库中尚未包含随机数功能。然而,Rust 团队还是提供了一个 rand crate

使用 crate 来增加更多功能

记住 crate 是一个 Rust 代码的包。我们正在构建的项目是一个二进制 crate,它生成一个可执行文件。 rand crate 是一个 库 crate,库 crate 可以包含任意能被其他程序使用的代码。

Cargo 对外部 crate 的运用是亮点。在我们使用 rand 编写代码之前,需要编辑 Cargo.toml ,声明 rand 作为一个依赖。现在打开这个文件并在 [dependencies] 标题(Cargo 为你创建了它)之下添加:

Filename: Cargo.toml

[dependencies]

rand = "0.3.14"

Cargo.toml 文件中,标题以及之后的内容属同一个段落,遇到下一个标题则开始新的段落。[dependencies] 部分告诉 Cargo 本项目依赖了哪些外部 crate 及其版本。本例中,我们使用语义化版本 0.3.14 来指定 rand crate。Cargo 理解语义化版本(Semantic Versioning)(有时也称为 SemVer),是一种定义版本号的标准。0.3.14 事实上是 ^0.3.14 的简写,它表示“任何与 0.3.14 版本公有 API 相兼容的版本”。

现在,不修改任何代码,构建项目,如列表 2-2 所示:

$ cargo build
    Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading rand v0.3.14
 Downloading libc v0.2.14
   Compiling libc v0.2.14
   Compiling rand v0.3.14
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)

Listing 2-2: The output from running cargo build after adding the rand crate as a dependency

可能会出现不同的版本号(多亏了语义化版本,它们与代码是兼容的!),同时显示顺序也可能会有所不同。

现在我们有了一个外部依赖,Cargo 从 registryCrates.io)上获取了一份(兼容的)最新版本的代码。Crates.io 是 Rust 生态环境中的开发者们向他人贡献 Rust 开源项目的地方。

在更新完 registry (索引)后,Cargo 检查 [dependencies] 段落并下载缺失的部分。本例中,只声明了 rand 一个依赖,然而 Cargo 还是额外获取了 libc,因为 rand 依赖 libc 来正常工作。下载完成后,Rust 编译依赖,然后使用这些依赖编译项目。

如果不做任何修改,立刻再次运行 cargo build,则不会有任何输出。Cargo 知道它已经下载并编译了依赖,同时 Cargo.toml 文件也没有变动,并且代码也没有任何修改,所以它不会重新编译代码。因为无事可做,它简单的退出了。如果打开 src/main.rs 文件,做一些普通的修改,保存并再次构建,只会出现一行输出:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)

这一行表示 Cargo 只针对 src/main.rs 文件的微小修改而构建。依赖没有变化,所以 Cargo 会复用已经为此下载并编译的代码。它只是重新构建了部分(项目)代码。

Cargo.lock 文件确保构建是可重现的

Cargo 有一个机制来确保任何人在任何时候重新构建代码,都会产生相同的结果:Cargo 只会使用你指定的依赖的版本,除非你又手动指定了别的。例如,如果下周 rand crate 的 v0.3.15 版本出来了,它修复了一个重要的 bug,同时也含有一个缺陷,会破坏代码的运行,这时会发生什么呢?

答案是 Cargo.lock 文件。它在第一次运行 cargo build 时创建,并放在 guessing_game 目录,Cargo 计算出所有符合要求的依赖版本并写入 Cargo.lock 文件。当将来构建项目时,如果 Cargo.lock 存在,Cargo 就使用里面指定的版本,不会重新计算。自动使你拥有了一个可重现的构建。换句话说,项目会继续使用 0.3.14 直到你显式升级,感谢 Cargo.lock

更新 crate 到一个新版本

当你确实需要升级 crate 时,Cargo 提供了另一个命令,update,他会:

  1. 忽略 Cargo.lock 文件,并计算出所有符合 Cargo.toml 声明的最新版本。
  2. 如果成功了,Cargo 会把这些版本写入 Cargo.lock 文件。

不过,Cargo 默认只会寻找大于 0.3.0 而小于 0.4.0 的版本。如果 rand crate 发布了两个新版本,0.3.150.4.0,在运行 cargo update 时会出现如下内容:

$ cargo update
    Updating registry `https://github.com/rust-lang/crates.io-index`
    Updating rand v0.3.14 -> v0.3.15

这时,值得注意的是 Cargo.lock 文件中的一个改变,rand crate 现在使用的版本是0.3.15

如果想要使用 0.4.0 版本的 rand 或是任何 0.4.x 系列的版本,必须像这样更新 Cargo.toml 文件:

[dependencies]

rand = "0.4.0"

下一次运行 cargo build 时,Cargo 会从 registry 更新,并根据你指定的新版本重新计算。

第十四章会讲到 Cargo 及其生态系统的更多内容,不过目前你只需要了解这么多。通过 Cargo 复用库文件非常容易,因此 Rustacean 能够编写出由很多包组装而成的更轻巧的项目。

生成一个随机数

让我们开始使用 rand。下一步是更新 src/main.rs,如列表 2-3 所示:

Filename: src/main.rs

extern crate rand;

use std::io;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);
}

Listing 2-3: Code changes needed in order to generate a random number

我们在顶部增加一行 extern crate rand; 通知 Rust 我们要使用外部依赖。这也会调用相应的 use rand,所以现在可以使用 rand:: 前缀来调用 rand 中的内容。

接下来,我们增加了一行 useuse rand::RngRng 是一个 trait,它定义了随机数生成器应实现的方法 ,想使用这些方法的话此 trait 必须在作用域中。第十章会详细介绍 trait。

另外,中间还新增加了两行。rand::thread_rng 函数提供实际使用的随机数生成器:它位于当前执行线程,并从操作系统获取 seed。接下来,调用随机数生成器的 gen_range 方法。这个方法由刚才引入到作用域的 Rng trait 定义。gen_range 方法获取两个数字作为参数,并生成一个范围在两者之间的随机数。它包含下限但不包含上限,所以需要指定1101来请求一个1100之间的数。

知道 use 哪个 trait 和该从 crate 中调用哪个方法并不是全部,crate 的说明位于其文档中,Cargo 有一个很棒的功能是:运行 cargo doc --open 命令来构建所有本地依赖提供的文档,并在浏览器中打开。例如,假设你对 rand crate 中的其他功能感兴趣,cargo doc --open 并点击左侧导航栏中的 rand

新增加的第二行代码打印出了秘密数字。这在开发程序时很有用,因为我们可以去测试它,不过在最终版本我们会删掉它。游戏一开始就打印出结果就没什么可玩的了!

尝试运行程序几次:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4
$ cargo run
     Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5

你应该能得到不同的随机数,同时他们应该都是在 1 和 100 之间的。干得漂亮!

比较猜测与秘密数字

现在有了用户输入和一个随机数,我们可以比较他们。这个步骤如列表 2-4 所示:

Filename: src/main.rs

extern crate rand;

use std::io;
use std::cmp::Ordering;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    println!("You guessed: {}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less    => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal   => println!("You win!"),
    }
}

Listing 2-4: Handling the possible return values of comparing two numbers

新代码的第一行是另一个 use,从标准库引入了一个叫做 std::cmp::Ordering 的类型。Ordering 是一个像 Result 一样的枚举,不过它的成员是 LessGreaterEqual。这是你做比较时可能出现的三种结果。

接着,底部的五行新代码使用了 Ordering 类型:

match guess.cmp(&secret_number) {
    Ordering::Less    => println!("Too small!"),
    Ordering::Greater => println!("Too big!"),
    Ordering::Equal   => println!("You win!"),
}

cmp 方法用来比较两个值。在任何可比较的值上调用,然后获取另一个被比较值的引用:这里是把 guesssecret_number 做比较,返回一个 Ordering 枚举的成员。再使用一个 match 表达式,根据枚举成员来决定接下来干什么。

一个 match 表达式由 分支(arms) 构成。一个分支包含一个 模式pattern)和动作,表达式头的求值结果符合分支的模式时将执行对应的动作。Rust 获取提供给 match 的值并挨个检查每个分支的模式。match 结构和模式是 Rust 的强大功能,它体现了代码可能遇到的多种情形,并帮助你没有遗漏的处理。这些功能将分别在第六章和第十八章详细介绍。

让我们看看使用 match 表达式的例子。假设用户猜了 50,这时随机生成的秘密数字是 38。比较 50 与 38 时,因为 50 比 38 要大,cmp 方法会返回 Ordering::Greatermatch 表达式得到该值,然后检查第一个分支的模式,Ordering::LessOrdering::Greater并不匹配,所以它忽略了这个分支的动作并来到下一个分支。下一个分支的模式是 Ordering::Greater正确匹配!这个分支关联的动作被执行,在屏幕打印出 Too big!match 表达式就此终止,因为该场景下没有检查最后一个分支的必要。

然而,列表 2-4 的代码并不能编译,可以尝试一下:

$ cargo build
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
  --> src/main.rs:23:21
   |
23 |     match guess.cmp(&secret_number) {
   |                     ^^^^^^^^^^^^^^ expected struct `std::string::String`, found integral variable
   |
   = note: expected type `&std::string::String`
   = note:    found type `&{integer}`

error: aborting due to previous error
Could not compile `guessing_game`.

错误的核心表明这里有不匹配的类型mismatched types)。Rust 有一个静态强类型系统,同时也有类型推断。当我们写出 let guess = String::new() 时,Rust 推断出 guess 应该是一个String,不需要我们写出类型。另一方面,secret_number,是一个数字类型。多种数字类型拥有 1 到 100 之间的值:32 位数字 i32;32 位无符号数字 u32;64 位数字 i64 等等。Rust 默认使用 i32,所以它是 secret_number 的类型,除非增加类型信息,或任何能让 Rust 推断出不同数值类型的信息。这里错误的原因在于 Rust 不会比较字符串类型和数字类型。

所以我们必须把从输入中读取到的 String 转换为一个真正的数字类型,才好与秘密数字进行比较。可以通过在 main 函数体中增加如下两行代码来实现:

Filename: src/main.rs

extern crate rand;

use std::io;
use std::cmp::Ordering;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    println!("Please input your guess.");

    let mut guess = String::new();

    io::stdin().read_line(&mut guess)
        .expect("Failed to read line");

    let guess: u32 = guess.trim().parse()
        .expect("Please type a number!");

    println!("You guessed: {}", guess);

    match guess.cmp(&secret_number) {
        Ordering::Less    => println!("Too small!"),
        Ordering::Greater => println!("Too big!"),
        Ordering::Equal   => println!("You win!"),
    }
}

这两行代码是:

let guess: u32 = guess.trim().parse()
    .expect("Please type a number!");

这里创建了一个叫做 guess 的变量。不过等等,不是已经有了一个叫做guess的变量了吗?确实如此,不过 Rust 允许遮盖shadow),用一个新值来遮盖 guess 之前的值。这个功能常用在需要转换值类型之类的场景,它允许我们复用 guess 变量的名字,而不是被迫创建两个不同变量,诸如 guess_strguess 之类。(第三章会介绍 shadowing 的更多细节。)

guess 被绑定到 guess.trim().parse() 表达式。表达式中的 guess 是包含输入的 String 类型。String 实例的 trim 方法会去除字符串开头和结尾的空白。u32 只能由数字字符转换,不过用户必须输入回车键才能让 read_line 返回,然而用户按下回车键时,会在字符串中增加一个换行(newline)符。例如,用户输入 5 并回车,guess 看起来像这样:5\n\n 代表“换行”,回车键。trim 方法消除 \n,只留下5

字符串的parse方法 将字符串解析成数字。这个方法可以解析多种数字类型,因此需要告诉 Rust 具体的数字类型,这里通过 let guess: u32 指定。guess 后面的冒号(:)告诉 Rust 我们指定了变量的类型。Rust 有一些内建的数字类型;u32 是一个无符号的 32 位整型。对于不大的正整数来说,它是不错的类型,第三章还会讲到其他数字类型。另外,程序中的 u32 注解以及与 secret_number 的比较,意味着 Rust 会推断出 secret_number 也是 u32 类型。现在可以使用相同类型比较两个值了!

parse 调用可能产生错误。例如,字符串中包含 A👍%,就无法将其转换为一个数字。因此,parse 方法返回一个 Result 类型。像之前讨论的 read_line 方法,按部就班的用 expect 方法处理即可。如果 parse 不能从字符串生成一个数字,返回一个 Result::Err 时,expect 会使游戏崩溃并打印附带的信息。如果 parse 成功地将字符串转换为一个数字,它会返回 Result::Ok,然后 expect 会返回 Ok 中的数字。

现在让我们运行程序!

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
  76
You guessed: 76
Too big!

漂亮!即便是在猜测之前添加了空格,程序依然能判断出用户猜测了 76。多运行程序几次来检验不同类型输入的相应行为:猜一个正确的数字,猜一个过大的数字和猜一个过小的数字。

现在游戏已经大体上能玩了,不过用户只能猜一次。增加一个循环来改变它吧!

使用循环来允许多次猜测

loop 关键字提供了一个无限循环。将其加入后,用户可以反复猜测:

Filename: src/main.rs

extern crate rand;

use std::io;
use std::cmp::Ordering;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin().read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse()
            .expect("Please type a number!");

        println!("You guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less    => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal   => println!("You win!"),
        }
    }
}

如上所示,我们将提示用户猜测之后的所有内容放入了循环。确保这些代码额外缩进了一层,再次运行程序。注意这里有一个新问题,因为程序忠实地执行了我们的要求:永远地请求另一个猜测,用户没法退出!

用户总能使用 Ctrl-C 终止程序。不过还有另一个方法跳出无限循环,就是“比较猜测”部分提到的 parse:如果用户输入一个非数字答案,程序会崩溃。用户可以利用这一点来退出,如下所示:

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit
thread 'main' panicked at 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:785
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/guess` (exit code: 101)

输入 quit 确实退出了程序,同时其他任何非数字输入也一样。然而,这并不理想,我们想要当猜测正确的数字时游戏能自动退出。

猜测正确后退出

让我们增加一个 break,在用户猜对时退出游戏:

Filename: src/main.rs

extern crate rand;

use std::io;
use std::cmp::Ordering;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    println!("The secret number is: {}", secret_number);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin().read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = guess.trim().parse()
            .expect("Please type a number!");

        println!("You guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less    => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal   => {
                println!("You win!");
                break;
            }
        }
    }
}

通过在 You win! 之后增加一行 break,用户猜对了神秘数字后会退出循环。退出循环也意味着退出程序,因为循环是 main 的最后一部分。

处理无效输入

为了进一步改善游戏性,不要在用户输入非数字时崩溃,需要忽略非数字,让用户可以继续猜测。可以通过修改 guessString 转化为 u32 那部分代码来实现:

let guess: u32 = match guess.trim().parse() {
    Ok(num) => num,
    Err(_) => continue,
};

expect 调用换成 match 语句,是从“立即崩溃”转到真正处理错误的惯用方法。须知 parse 返回一个 Result 类型,而 Result 是一个拥有 OkErr 成员的枚举。这里使用的 match 表达式,和之前处理 cmp 方法返回 Ordering 时用的一样。

如果 parse 能够成功的将字符串转换为一个数字,它会返回一个包含结果数字的 Ok。这个 Ok 值与match 第一个分支的模式相匹配,该分支对应的动作返回 Ok 值中的数字 num,最后如愿变成新创建的 guess 变量。

如果 parse 能将字符串转换为一个数字,它会返回一个包含更多错误信息的 ErrErr 值不能匹配第一个 match 分支的 Ok(num) 模式,但是会匹配第二个分支的 Err(_) 模式:_ 是一个兜底值,用来匹配所有 Err 值,不管其中有何种信息。所以程序会执行第二个分支的动作,continue 意味着进入 loop 的下一次循环,请求另一个猜测。这样程序就忽略了 parse 可能遇到的所有错误!

现在万事俱备,只需运行 cargo run

$ cargo run
   Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
     Running `target/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!

太棒了!再有最后一个小的修改,就能完成猜猜看游戏了:还记得程序依然会打印出秘密数字。在测试时还好,但正式发布时会毁了游戏。删掉打印秘密数字的 println!。列表 2-5 为最终代码:

Filename: src/main.rs

extern crate rand;

use std::io;
use std::cmp::Ordering;
use rand::Rng;

fn main() {
    println!("Guess the number!");

    let secret_number = rand::thread_rng().gen_range(1, 101);

    loop {
        println!("Please input your guess.");

        let mut guess = String::new();

        io::stdin().read_line(&mut guess)
            .expect("Failed to read line");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };

        println!("You guessed: {}", guess);

        match guess.cmp(&secret_number) {
            Ordering::Less    => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal   => {
                println!("You win!");
                break;
            }
        }
    }
}

Listing 2-5: Complete code of the guessing game

总结

此时此刻,你顺利完成了猜猜看游戏!恭喜!

这是一个通过动手实践学习 Rust 新概念的项目:letmatch、方法、关联函数、使用外部 crate 等等,接下来的几章,我们将会继续深入。第三章涉及到大部分编程语言都有的概念,比如变量、数据类型和函数,以及如何在 Rust 中使用他们。第四章探索所有权(ownership),这是一个 Rust 同其他语言都不相同的功能。第五章讨论结构体和方法的语法,而第六章侧重解释枚举。

通用编程概念

ch03-00-common-programming-concepts.md
commit 04aa3a45eb72855b34213703718f50a12a3eeec8

本章涉及一些几乎所有编程语言都有的概念,以及他们在 Rust 中是如何工作的。很多编程语言的核心概念都是共通的,本章中展示的概念都不是 Rust 特有的,不过我们会在 Rust 环境中讨论他们,解释他们的使用习惯。

具体的,我们将会学习变量,基本类型,函数,注释和控制流。这些基础知识将会出现在每一个 Rust 程序中,提早学习这些概念会使你拥有坚实的起步。

关键字

Rust 语言有一系列保留的关键字keywords),只能由语言本身使用,像大部分语言一样。你不能使用这些关键字作为变量或函数的名称,大部分关键字有特殊的意义,并被用来完成 Rust 程序中的各种任务;一些关键字目前没有分配,是为将来可能添加的功能保留的。可以在附录 A 中找到关键字的列表。

变量和可变性

ch03-01-variables-and-mutability.md
commit 04aa3a45eb72855b34213703718f50a12a3eeec8

第二章中提到过,变量默认是不可变immutable)的。这是利用 Rust 安全和简单并发的优势编写代码一大助力。不过,变量仍然有可变的选项。让我们探讨一下,拥抱不可变性的原因及方法,以及何时你不想拥抱。

当变量不可变时,意味着一旦值被绑定上一个名称,你就不能改变这个值。作为说明,通过cargo new --bin variablesprojects 目录生成一个叫做 variables 的新项目。

接着,在新建的 variables 目录,打开 src/main.rs 并替换其代码为如下:

Filename: src/main.rs

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

保存并使用cargo run运行程序。应该会看到一个错误信息,如下输出所示:

$ cargo run
   Compiling variables v0.0.1 (file:///projects/variables)
error[E0384]: re-assignment of immutable variable `x`
 --> src/main.rs:4:5
  |
2 |     let x = 5;
  |         - first assignment to `x`
3 |     println!("The value of x is: {}", x);
4 |     x = 6;
  |     ^^^^^ re-assignment of immutable variable

这个例子展示了编译器如何帮助你找出程序中的错误。即便编译错误令人沮丧,那也不过是说程序不能安全的完成你想让它完成的工作;而不能说明你是不是一个好程序员!有经验的 Rustacean 们一样会遇到编译错误。这些错误给出的原因是对不可变变量重新赋值re-assignment of immutable variable),因为我们尝试对不可变变量x赋第二个值。

尝试去改变预设为不可变的值,产生编译错误是很重要的,因为这种情况可能导致 bug:如果代码的一部分假设一个值永远也不会改变,而另一部分代码改变了它,第一部分代码就有可能以不可预料的方式运行。不得不承认这种 bug 难以跟踪,尤其是第二部分代码只是有时改变其值。

Rust 编译器保证,如果声明一个值不会变,它就真的不会变。这意味着当阅读和编写代码时,不需要厘清如何以及哪里可能会被改变,从而使得代码易于推导。

不过可变性也是非常有用的。变量只是默认不可变;可以通过在变量名之前加 mut 来使其可变。它向读者表明了其他代码将会改变这个变量的意图。

例如,改变 src/main.rs 并替换其代码为如下:

Filename: src/main.rs

fn main() {
    let mut x = 5;
    println!("The value of x is: {}", x);
    x = 6;
    println!("The value of x is: {}", x);
}

当运行这个程序,出现如下:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
     Running `target/debug/variables`
The value of x is: 5
The value of x is: 6

通过 mut,允许把绑定到 x 的值从 5 改成 6。在一些情况下,你会想要一个变量可变,因为相对不可变的风格更容易写。

除了避免 bug 外,还有多处需要权衡取舍。例如,使用大型数据结构时,适当地使变量可变,可能比复制和返回新分配的实例更快。对于较小的数据结构,总是创建新实例,采用更偏向函数式的风格编程,可能会使代码更易理解,为可读性而遭受性能惩罚或许值得。

变量和常量的区别

不允许改变值的变量,可能会使你想起另一个大部分编程语言都有的概念:常量constants)。类似于不可变变量,常量也是绑定到一个名称的不允许改变的值,不过常量与变量还是有一些区别。

首先,不允许对常量使用 mut:常量不光默认不能变,它总是不能变。

声明常量使用 const 关键字而不是 let,而且必须注明值的类型。在下一部分,“数据类型”,涉及到类型和类型注解,现在无需关心这些细节,记住总是标注类型即可。

常量可以在任何作用域声明,包括全局作用域,这在一个值需要被很多部分的代码用到时很有用。

最后一个区别是常量只能用于常量表达式,而不能作为函数调用的结果,或任何其他只在运行时计算的值。

这是一个常量声明的例子,它的名称是 MAX_POINTS,值是 100,000。(常量使用下划线分隔的大写字母命名):

const MAX_POINTS: u32 = 100_000;

常量在整个程序生命周期中都有效,位于它声明的作用域之中。这使得常量可以作为多处代码使用的全局范围的值,例如一个游戏中所有玩家可以获取的最高分或者光速。

将作用于整个程序的值,由硬编码改为常量(并编写文档),对后来的维护者了解值的意义很用帮助。它也能将硬编码的值汇总一处,为将来可能的修改提供方便。

遮盖(Shadowing)

如第二章“猜猜看游戏”所讲的,我们可以定义一个与之前变量重名的新变量,而新变量会遮盖之前的变量。Rustacean 称之为“第一个变量被第二个遮盖了”,这意味着使用这个变量时会看第二个值。可以用相同变量名称来遮盖它自己,以及重复使用 let 关键字来多次遮盖,如下所示:

Filename: src/main.rs

fn main() {
    let x = 5;

    let x = x + 1;

    let x = x * 2;

    println!("The value of x is: {}", x);
}

这个程序首先将 x 绑定到值 5 上。接着通过 let x = 遮盖 x,获取原始值并加 1 这样 x 的值就变成 6 了。第三个 let 语句也覆盖了 x,获取之前的值并乘以 2x 最终的值是 12。运行这个程序,它会有如下输出:

$ cargo run
   Compiling variables v0.1.0 (file:///projects/variables)
     Running `target/debug/variables`
The value of x is: 12

这与将变量声明为 mut 是有区别的。因为除非再次使用 let 关键字,不小心尝试对变量重新赋值会导致编译时错误。我们可以用这个值进行一些计算,不过计算完之后变量仍然是不变的。

mut 与遮盖的另一个区别是,当再次使用 let 时,实际上创建了一个新变量,我们可以改变值的类型。例如,假设程序请求用户输入空格来提供文本间隔,然而我们真正需要的是将输入存储成数字(多少个空格):

let spaces = "   ";
let spaces = spaces.len();

这里允许第一个 spaces 变量是字符串类型,而第二个 spaces 变量,它是一个恰巧与第一个变量同名的崭新变量,是数字类型。遮盖使我们不必使用不同的名字,如 spaces_strspaces_num;相反,我们可以复用 spaces 这个更简单的名字。然而,如果尝试使用mut,如下所示:

let mut spaces = "   ";
spaces = spaces.len();

会导致一个编译错误,因为改变一个变量的类型是不被允许的:

error[E0308]: mismatched types
 --> src/main.rs:3:14
  |
3 |     spaces = spaces.len();
  |              ^^^^^^^^^^^^ expected &str, found usize
  |
  = note: expected type `&str`
  = note:    found type `usize`

现在我们探索了变量如何工作,让我们看看更多的数据类型。

数据类型

ch03-02-data-types.md
commit fe4833a8ef2853c55424e7747a4ef8dd64c35b32

在 Rust 中,任何值都属于一种明确的类型type),声明它被指定了何种数据,以便明确其处理方式。我们将分两部分探讨一些内建类型:标量(scalar)和复合(compound)。

Rust 是静态类型statically typed)语言,也就是说在编译时就需要知道所有变量的类型,这一认知将贯穿整个章节,请在头脑中明确。通过值的形式及其使用方式,编译器通常可以推断出我们想要用的类型。多种类型均有可能时,比如第二章中使用 parseString 转换为数字,必须增加类型注解,像这样:

let guess: u32 = "42".parse().expect("Not a number!");

如果不添加类型注解,Rust 会显示如下错误。这说明编译器需要更多信息,来了解我们想要的类型:

error[E0282]: unable to infer enough type information about `_`
 --> src/main.rs:2:9
  |
2 |     let guess = "42".parse().expect("Not a number!");
  |         ^^^^^ cannot infer type for `_`
  |
  = note: type annotations or generic parameter binding required

在我们讨论各种数据类型时,你会看到多样的类型注解。

标量类型

标量类型代表一个单独的值。Rust 有四种基本的标量类型:整型、浮点型、布尔类型和字符类型。你可能在其他语言中见过他们,不过让我们深入了解他们在 Rust 中时如何工作的。

整型

整数是一个没有小数部分的数字。我们在这一章的前面使用过 i32 类型。该类型声明指示,i32 关联的值应该是一个占据32比特位的有符号整数(因为这个i,与u代表的无符号相对)。表格 3-1 展示了 Rust 内建的整数类型。每一种变体的有符号和无符号列(例如,i32)可以用来声明对应的整数值。

Table 3-1: Integer Types in Rust

Length Signed Unsigned
8-bit i8 u8
16-bit i16 u16
32-bit i32 u32
64-bit i64 u64
arch isize usize

每一种变体都可以是有符号或无符号的,并有一个明确的大小。有符号和无符号代表数字能否为负值;换句话说,数字是否需要有一个符号(有符号数),或者永远为正而不需要符号(无符号数)。这有点像在纸上书写数字:当需要考虑符号的时候,数字以加号或减号作为前缀;然而,可以安全地假设为正数时,加号前缀通常省略。有符号数以二进制补码形式(two’s complement representation)存储(如果你不清楚这是什么,可以在网上搜索;对其的解释超出了本书的范畴)。

每一个有符号的变体可以储存包含从 -(2n - 1) 到 2n - 1 - 1 在内的数字,这里 n 是变体使用的位数。所以 i8 可以储存从 -(27) 到 27 - 1 在内的数字,也就是从 -128 到 127。无符号的变体可以储存从 0 到 2n - 1 的数字,所以 u8 可以储存从 0 到 28 - 1 的数字,也就是从 0 到 255。

另外,isizeusize 类型依赖运行程序的计算机架构:64 位架构上他们是 64 位的, 32 位架构上他们是 32 位的。

可以使用表格 3-2 中的任何一种形式编写数字字面值。除字节以外的其它字面值允许使用类型后缀,例如 57u8,允许使用 _ 做为分隔符以方便读数,例如 1_000 (分隔符的数量与位置并不影响实际的数字)。

Table 3-2: Integer Literals in Rust

Number literals Example
Decimal 98_222
Hex 0xff
Octal 0o77
Binary 0b1111_0000
Byte (u8 only) b'A'

那么该使用哪种类型的数字呢?如果拿不定主意,Rust 的默认类型通常就很好,数字类型默认是 i32:它通常是最快的,甚至在 64 位系统上也是。isizeusize 的主要作为集合的索引。

浮点型

Rust 同样有两个主要的浮点数类型,f32f64,它们是带小数点的数字,分别占 32 位和 64 位比特。默认类型是 f64,因为它与 f32 速度差不多,然而精度更高。在 32 位系统上也能够使用 f64,不过比使用 f32 要慢。多数情况下,以潜在的性能损耗换取更高的精度是合理的;如果觉得浮点数的大小是个麻烦,你应该以性能测试作为决策依据。

这是一个展示浮点数的实例:

Filename: src/main.rs

fn main() {
    let x = 2.0; // f64

    let y: f32 = 3.0; // f32
}

浮点数采用 IEEE-754 标准表示。f32是单精度浮点数,f64是双精度浮点数。

数字运算符

Rust 支持所有数字类型常见的基本数学运算操作:加法、减法、乘法、除法以及余数。如下代码展示了如何使用一个let语句来使用他们:

Filename: src/main.rs

fn main() {
    // addition
    let sum = 5 + 10;

    // subtraction
    let difference = 95.5 - 4.3;

    // multiplication
    let product = 4 * 30;

    // division
    let quotient = 56.7 / 32.2;

    // remainder
    let remainder = 43 % 5;
}

这些语句中的每个表达式使用了一个数学运算符并计算出了一个值,他们绑定到了一个变量。附录 B 包含了一个 Rust 提供的所有运算符的列表。

布尔型

正如其他大部分编程语言一样,Rust 中的布尔类型有两个可能的值:truefalse。Rust 中的布尔类型使用bool表示。例如:

Filename: src/main.rs

fn main() {
    let t = true;

    let f: bool = false; // with explicit type annotation
}

使用布尔值的主要场景是条件表达式,例如if。在“控制流”(“Control Flow”)部分将讲到if表达式在 Rust 中如何工作。

字符类型

目前为止只使用到了数字,不过 Rust 也支持字符。Rust 的char类型是大部分语言中基本字母字符类型,如下代码展示了如何使用它:

Filename: src/main.rs

fn main() {
   let c = 'z';
   let z = 'ℤ';
   let heart_eyed_cat = '😻';
}

Rust 的char类型代表了一个 Unicode 变量值(Unicode Scalar Value),这意味着它可以比 ASCII 表示更多内容。拼音字母(Accented letters),中文/日文/汉语等象形文字,emoji(絵文字)以及零长度的空白字符对于 Rust char类型都是有效的。Unicode 标量值包含从 U+0000U+D7FFU+E000U+10FFFF 之间的值。不过,“字符”并不是一个 Unicode 中的概念,所以人直觉上的“字符”可能与 Rust 中的char并不符合。第八章的“字符串”部分将详细讨论这个主题。

复合类型

复合类型可以将多个其他类型的值组合成一个类型。Rust 有两个原生的复合类型:元组(tuple)和数组(array)。

将值组合进元组

元组是一个将多个其他类型的值组合进一个复合类型的主要方式。

我们使用一个括号中的逗号分隔的值列表来创建一个元组。元组中的每一个位置都有一个类型,而且这些不同值的类型也不必是相同的。这个例子中使用了额外的可选类型注解:

Filename: src/main.rs

fn main() {
    let tup: (i32, f64, u8) = (500, 6.4, 1);
}

tup变量绑定了整个元组,因为元组被认为是一个单独的复合元素。为了从元组中获取单个的值,可以使用模式匹配(pattern matching)来解构(destructure )元组,像这样:

Filename: src/main.rs

fn main() {
    let tup = (500, 6.4, 1);

    let (x, y, z) = tup;

    println!("The value of y is: {}", y);
}

程序首先创建了一个元组并绑定到tup变量上。接着使用了let和一个模式将tup分成了三个不同的变量,xyz。这叫做解构destructuring),因为它将一个元组拆成了三个部分。最后,程序打印出了y的值,也就是6.4

除了使用模式匹配解构之外,也可以使用点号(.)后跟值的索引来直接访问他们。例如:

Filename: src/main.rs

fn main() {
    let x: (i32, f64, u8) = (500, 6.4, 1);

    let five_hundred = x.0;

    let six_point_four = x.1;

    let one = x.2;
}

这个程序创建了一个元组,x,并接着使用索引为每个元素创建新变量。跟大多数编程语言一样,元组的第一个索引值是 0。

数组

另一个获取一个多个值集合的方式是数组array)。与元组不同,数组中的每个元素的类型必须相同。Rust 中的数组与一些其他语言中的数组不同,因为 Rust 中的数组是固定长度的:一旦声明,他们的长度不能增长或缩小。

Rust 中数组的值位于中括号中的逗号分隔的列表中:

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];
}

数组在想要在栈(stack)而不是在堆(heap)上为数据分配空间时十分有用(第四章将讨论栈与堆的更多内容),或者是想要确保总是有固定数量的元素时。虽然它并不如 vector 类型那么灵活。vector 类型是标准库提供的一个允许增长和缩小长度的类似数组的集合类型。当不确定是应该使用数组还是 vector 的时候,你可能应该使用 vector:第八章会详细讨论 vector。

一个你可能想要使用数组而不是 vector 的例子是当程序需要知道一年中月份的名字时。程序不大可能回去增加或减少月份,这时你可以使用数组因为我们知道它总是含有 12 个元素:

let months = ["January", "February", "March", "April", "May", "June", "July",
              "August", "September", "October", "November", "December"];
访问数组元素

数组是一整块分配在栈上的内存。可以使用索引来访问数组的元素,像这样:

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let first = a[0];
    let second = a[1];
}

在这个例子中,叫做first的变量的值是1,因为它是数组索引[0]的值。second将会是数组索引[1]的值2

无效的数组元素访问

如果我们访问数组结尾之后的元素会发生什么呢?比如我们将上面的例子改为如下:

Filename: src/main.rs

fn main() {
    let a = [1, 2, 3, 4, 5];

    let element = a[10];

    println!("The value of element is: {}", element);
}

使用cargo run运行代码后会产生如下结果:

$ cargo run
   Compiling arrays v0.1.0 (file:///projects/arrays)
     Running `target/debug/arrays`
thread '<main>' panicked at 'index out of bounds: the len is 5 but the index is
 10', src/main.rs:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/arrays` (exit code: 101)

编译并没有产生任何错误,不过程序会导致一个运行时runtime)错误并且不会成功退出。当尝试用索引访问一个元素时,Rust 会检查指定的索引是否小于数组的长度。如果索引超出了数组长度,Rust 会panic,这是 Rust 中的术语,它用于程序因为错误而退出的情况。

这是第一个在实战中遇到的 Rust 安全原则的例子。在很多底层语言中,并没有进行这类检查,这样当提供了一个不正确的索引时,就会访问无效的内存。Rust 通过立即退出而不是允许内存访问并继续执行来使你免受这类错误困扰。第九章会讨论更多 Rust 的错误处理。

函数如何工作

ch03-03-how-functions-work.md
commit 04aa3a45eb72855b34213703718f50a12a3eeec8

函数在 Rust 代码中应用广泛。你已经见过一个语言中最重要的函数:main函数,它时很多程序的入口点。你也见过了fn关键字,它用来声明新函数。

Rust 代码使用 snake case 作为函数和变量名称的规范风格。在 snake case 中,所有字母都是小写并使用下划线分隔单词。这里是一个函数定义程序的例子:

Filename: src/main.rs

fn main() {
    println!("Hello, world!");

    another_function();
}

fn another_function() {
    println!("Another function.");
}

Rust 中的函数定义以fn开始并在函数名后跟一对括号。大括号告诉编译器哪里是函数体的开始和结尾。

可以使用定义过的函数名后跟括号来调用任意函数。因为another_function已经在程序中定义过了,它可以在main函数中被调用。注意,源码中another_functionmain函数之后被定义;也可以在其之前定义。Rust 不关心函数定义于何处,只要他们被定义了。

让我们开始一个叫做 functions 的新二进制项目来进一步探索函数。将上面的another_function例子写入 src/main.rs 中并运行。你应该会看到如下输出:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
     Running `target/debug/functions`
Hello, world!
Another function.

代码在main函数中按照他们出现的顺序被执行。首先,打印“Hello, world!”信息,接着another_function被调用并打印它的信息。

函数参数

函数也可以被定义为拥有参数parameters),他们是作为函数签名一部分的特殊变量。当函数拥有参数,可以为这些参数提供具体的值。技术上讲,这些具体值被称为参数( arguments),不过通常的习惯是倾向于在函数定义中的变量和调用函数时传递的具体值都可以用“parameter”和“argument”而不加区别。

如下被重写的another_function版本展示了 Rust 中参数是什么样的:

Filename: src/main.rs

fn main() {
    another_function(5);
}

fn another_function(x: i32) {
    println!("The value of x is: {}", x);
}

尝试运行程序,将会得到如下输出:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
     Running `target/debug/functions`
The value of x is: 5

another_function的声明有一个叫做x的参数。x的类型被指定为i32。当5被传递给another_function时,println!宏将5放入格式化字符串中大括号的位置。

在函数签名中,必须声明每个参数的类型。这是 Rust 设计中一个经过慎重考虑的决定:要求在函数定义中提供类型注解意味着编译器再也不需要在别的地方要求你注明类型就能知道你的意图。

当一个函数有多个参数时,使用逗号隔开他们,像这样:

Filename: src/main.rs

fn main() {
    another_function(5, 6);
}

fn another_function(x: i32, y: i32) {
    println!("The value of x is: {}", x);
    println!("The value of y is: {}", y);
}

这个例子创建了一个有两个参数的函数,都是i32类型的。函数打印出了这两个参数的值。注意函数参数并不一定都是相同类型的,这个例子中他们只是碰巧相同罢了。

尝试运行代码。使用上面的例子替换当前 function 项目的 src/main.rs 文件,并cargo run运行它:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
     Running `target/debug/functions`
The value of x is: 5
The value of y is: 6

因为我们使用5作为x的值和6作为y的值来调用函数,这两个字符串和他们的值并被打印出来。

函数体

函数体由一系列的语句和一个可选的表达式构成。目前为止,我们只涉及到了没有结尾表达式的函数,不过我们见过表达式作为了语句的一部分。因为 Rust 是一个基于表达式(expression-based)的语言,这是一个需要理解的(不同于其他语言)重要区别。其他语言并没有这样的区别,所以让我们看看语句与表达式有什么区别以及他们是如何影响函数体的。

语句与表达式

我们已经用过语句与表达式了。语句Statements)是执行一些操作但不返回值的指令。表达式(Expressions)计算并产生一个值。让我们看看一些例子:

使用let关键字创建变量并绑定一个值是一个语句。在列表 3-3 中,let y = 6;是一个语句:

Filename: src/main.rs

fn main() {
    let y = 6;
}

Listing 3-3: A main function declaration containing one statement.

函数定义也是语句;上面整个例子本身就是一个语句。

语句并不返回值。因此,不能把let语句赋值给另一个变量,比如下面的例子尝试做的:

Filename: src/main.rs

fn main() {
    let x = (let y = 6);
}

当运行这个程序,会得到如下错误:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found statement (`let`)
 --> src/main.rs:2:14
  |
2 |     let x = (let y = 6);
  |              ^^^
  |
  = note: variable declaration using `let` is a statement

let y = 6语句并不返回值,所以并没有x可以绑定的值。这与其他语言不同,例如 C 和 Ruby,他们的赋值语句返回所赋的值。在这些语言中,可以这么写x = y = 6这样xy的值都是6;这在 Rust 中可不行。

表达式计算出一些值,而且他们组成了其余大部分你将会编写的 Rust 代码。考虑一个简单的数学运算,比如5 + 6,这是一个表达式并计算出值11。表达式可以是语句的一部分:在列表 3-3 中有这个语句let y = 6;6是一个表达式它计算出的值是6。函数调用是一个表达式。宏调用是一个表达式。我们用来创新建作用域的大括号(代码块),{},也是一个表达式,例如:

Filename: src/main.rs

fn main() {
    let x = 5;

    let y = {
        let x = 3;
        x + 1
    };

    println!("The value of y is: {}", y);
}

这个表达式:

{
    let x = 3;
    x + 1
}

这个代码块的值是4。这个值作为let语句的一部分被绑定到y上。注意结尾没有分号的那一行,与大部分我们见过的代码行不同。表达式并不包含结尾的分号。如果在表达式的结尾加上分号,他就变成了语句,这也就使其不返回一个值。在接下来的探索中记住函数和表达式都返回值就行了。

函数的返回值

可以向调用它的代码返回值。并不对返回值命名,不过会在一个箭头(->)后声明它的类型。在 Rust 中,函数的返回值等同于函数体最后一个表达式的值。这是一个有返回值的函数的例子:

Filename: src/main.rs

fn five() -> i32 {
    5
}

fn main() {
    let x = five();

    println!("The value of x is: {}", x);
}

在函数five中并没有函数调用、宏、甚至也没有let语句————只有数字5它自己。这在 Rust 中是一个完全有效的函数。注意函数的返回值类型也被指定了,就是-> i32。尝试运行代码;输出应该看起来像这样:

$ cargo run
   Compiling functions v0.1.0 (file:///projects/functions)
     Running `target/debug/functions`
The value of x is: 5

函数five的返回值是5,也就是为什么返回值类型是i32。让我们仔细检查一下这段代码。这有两个重要的部分:首先,let x = five();这一行表明我们使用函数的返回值来初始化了一个变量。因为函数five返回5,这一行与如下这行相同:

let x = 5;

其次,函数five没有参数并定义了返回值类型,不过函数体只有单单一个5也没有分号,因为这是我们想要返回值的表达式。让我们看看另一个例子:

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {}", x);
}

fn plus_one(x: i32) -> i32 {
    x + 1
}

运行代码会打印出The value of x is: 6。如果在包含x + 1的那一行的结尾加上一个分号,把它从表达式变成语句后会怎样呢?

Filename: src/main.rs

fn main() {
    let x = plus_one(5);

    println!("The value of x is: {}", x);
}

fn plus_one(x: i32) -> i32 {
    x + 1;
}

运行代码会产生一个错误,如下:

error[E0308]: mismatched types
 --> src/main.rs:7:28
  |
7 |   fn plus_one(x: i32) -> i32 {
  |  ____________________________^ starting here...
8 | |     x + 1;
9 | | }
  | |_^ ...ending here: expected i32, found ()
  |
  = note: expected type `i32`
             found type `()`
help: consider removing this semicolon:
 --> src/main.rs:8:10
  |
8 |     x + 1;
  |          ^

主要的错误信息,“mismatched types,”(类型不匹配),揭示了代码的核心问题。函数plus_one的定义说明它要返回一个i32,不过语句并不返回一个值,这由那个空元组()表明。因此,这个函数返回了空元组()(译者注:原文说此函数没有返回任何值,可能有误),这与函数定义相矛盾并导致一个错误。在输出中,Rust 提供了一个可能会对修正问题有帮助的信息:它建议去掉分号,这会修复这个错误。

注释

ch03-04-comments.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

所有编程语言都力求使他们的代码易于理解,不过有时需要提供额外的解释。在这种情况下,程序员在源码中留下记录,或者注释comments),编译器会忽略他们不过其他阅读代码的人可能会用得上。

这是一个注释的例子:

// Hello, world.

在 Rust 中,注释必须以两道斜杠开始并持续到本行的结尾。对于超过一行的注释,需要在每一行都加上//,像这样:

// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.

注释也可以在放在包含代码的行的末尾:

Filename: src/main.rs

fn main() {
    let lucky_number = 7; // I’m feeling lucky today.
}

不过你会经常看到他们被以这种格式使用,也就是位于它所解释的代码行的上面一行:

Filename: src/main.rs

fn main() {
    // I’m feeling lucky today.
    let lucky_number = 7;
}

这就是注释的全部。并没有什么特别复杂的。

控制流

ch03-05-control-flow.md
commit 04aa3a45eb72855b34213703718f50a12a3eeec8

通过条件是不是真来决定是否某些代码,或者根据条件是否为真来重复运行一段代码是大部分编程语言的基本组成部分。Rust 代码中最常见的用来控制执行流的结构是if表达式和循环。

if表达式

if表达式允许根据条件执行不同的代码分支。我们提供一个条件并表示“如果符合这个条件,运行这段代码。如果条件不满足,不运行这段代码。”

projects 目录创建一个叫做 branches 的新项目来学习if表达式。在 src/main.rs 文件中,输入如下内容:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number < 5 {
        println!("condition was true");
    } else {
        println!("condition was false");
    }
}

所有if表达式以if关键字开头,它后跟一个条件。在这个例子中,条件检查number是否有一个小于 5 的值。在条件为真时希望执行的代码块位于紧跟条件之后的大括号中。if表达式中与条件关联的代码块有时被叫做 arms,就像第二章“比较猜测与秘密数字”部分中讨论到的match表达式中分支一样。也可以包含一个可选的else表达式,这里我们就这么做了,来提供一个在条件为假时应当执行的代码块。如果不提供else表达式并且条件为假时,程序会直接忽略if代码块并继续执行下面的代码。

尝试运行代码,应该能看到如下输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
     Running `target/debug/branches`
condition was true

尝试改变number的值使条件为假时看看会发生什么:

let number = 7;

再次运行程序并查看输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
     Running `target/debug/branches`
condition was false

另外值得注意的是代码中的条件必须bool。如果像看看条件不是bool值时会发生什么,尝试运行如下代码:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number {
        println!("number was three");
    }
}

这里if条件的值是3,Rust 抛出了一个错误:

error[E0308]: mismatched types
 --> src/main.rs:4:8
  |
4 |     if number {
  |        ^^^^^^ expected bool, found integral variable
  |
  = note: expected type `bool`
             found type `{integer}`

这个错误表明 Rust 期望一个bool不过却得到了一个整型。Rust 并不会尝试自动地将非布尔值转换为布尔值,不像例如 Ruby 和 JavaScript 这样的语言。必须总是显式地使用boolean作为if的条件。例如如果想要if代码块只在一个数字不等于0时执行,可以把if表达式修改为如下:

Filename: src/main.rs

fn main() {
    let number = 3;

    if number != 0 {
        println!("number was something other than zero");
    }
}

运行代码会打印出number was something other than zero

使用else if实现多重条件

可以将else if表达式与ifelse组合来实现多重条件。例如:

Filename: src/main.rs

fn main() {
    let number = 6;

    if number % 4 == 0 {
        println!("number is divisible by 4");
    } else if number % 3 == 0 {
        println!("number is divisible by 3");
    } else if number % 2 == 0 {
        println!("number is divisible by 2");
    } else {
        println!("number is not divisible by 4, 3, or 2");
    }
}

这个程序有四个可能的执行路径。运行后应该能看到如下输出:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
     Running `target/debug/branches`
number is divisible by 3

当执行这个程序,它按顺序检查每个if表达式并执行第一个条件为真的代码块。注意即使 6 可以被 2 整除,也不会出现number is divisible by 2的输出,更不会出现else块中的number is not divisible by 4, 3, or 2。原因是 Rust 只会执行第一个条件为真的代码块,并且它一旦找到一个以后,就不会检查剩下的条件了。

使用过多的else if表达式会使代码显得杂乱无章,所以如果有多于一个else if,最好重构代码。为此第六章会介绍 Rust 中一个叫做match的强大的分支结构(branching construct)。

let语句中使用if

因为if是一个表达式,我们可以在let语句的右侧使用它,例如在列表 3-4 中:

Filename: src/main.rs

fn main() {
    let condition = true;
    let number = if condition {
        5
    } else {
        6
    };

    println!("The value of number is: {}", number);
}

Listing 3-4: Assigning the result of an if expression to a variable

number变量将会绑定到基于if表达式结果的值。运行这段代码看看会出现什么:

$ cargo run
   Compiling branches v0.1.0 (file:///projects/branches)
     Running `target/debug/branches`
The value of number is: 5

还记得代码块的值是其最后一个表达式的值,以及数字本身也是一个表达式吗。在这个例子中,整个if表达式的值依赖哪个代码块被执行。这意味着if的每个分支的可能的返回值都必须是相同类型;在列表 3-4 中,if分支和else分支的结果都是i32整型。不过如果像下面的例子那样这些类型并不匹配会怎么样呢?

Filename: src/main.rs

fn main() {
    let condition = true;

    let number = if condition {
        5
    } else {
        "six"
    };

    println!("The value of number is: {}", number);
}

当运行这段代码,会得到一个错误。ifelse分支的值类型是不相容的,同时 Rust 也准确地表明了在程序中的何处发现的这个问题:

error[E0308]: if and else have incompatible types
 --> src/main.rs:4:18
  |
4 |       let number = if condition {
  |  __________________^ starting here...
5 | |         5
6 | |     } else {
7 | |         "six"
8 | |     };
  | |_____^ ...ending here: expected integral variable, found reference
  |
  = note: expected type `{integer}`
             found type `&'static str`

if代码块的表达式返回一个整型,而else代码块返回一个字符串。这并不可行,因为变量必须只有一个类型。Rust 需要在编译时就确切的知道number变量的类型,这样它就可以在编译时证明其他使用number变量的地方它的类型是有效的。Rust 并不能够在number的类型只能在运行时确定的情况下工作;这样会使编译器变得更复杂而且只能为代码提供更少的保障,因为它不得不记录所有变量的多种可能的类型。

使用循环重复执行

多次执行一段代码是很常用的。为了这个功能,Rust 提供了多种循环loops)。一个循环执行循环体中的代码直到结尾并紧接着从回到开头继续执行。为了实验一下循环,让我们创建一个叫做 loops 的新项目。

Rust 有三种循环类型:loopwhilefor。让我们每一个都试试。

使用loop重复执行代码

loop关键字告诉 Rust 一遍又一遍的执行一段代码直到你明确要求停止。

作为一个例子,将 loops 目录中的 src/main.rs 文件修改为如下:

Filename: src/main.rs

fn main() {
    loop {
        println!("again!");
    }
}

当执行这个程序,我们会看到again!被连续的打印直到我们手动停止程序.大部分终端都支持一个键盘快捷键,ctrl-C,来终止一个陷入无限循环的程序。尝试一下:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
     Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!

符号^C代表你在这按下了 ctrl-C。在^C之后你可能看到again!也可能看不到,这依赖于在接收到终止信号时代码执行到了循环的何处。

幸运的是,Rust 提供了另一个更可靠的方式来退出循环。可以使用break关键字来告诉程序何时停止执行循环。还记得我们在第二章猜猜看游戏的“猜测正确后退出”部分使用过它来在用户猜对数字赢得游戏后退出程序吗。

while条件循环

在程序中计算循环的条件也很常见。当条件为真,执行循环。当条件不再为真,调用break停止循环。这个循环类型可以通过组合loopifelsebreak来实现;如果你喜欢的话,现在就可以在程序中试试。

然而,这个模式太常见了以至于 Rust 为此提供了一个内建的语言结构,它被称为while循环。下面的例子使用了while:程序循环三次,每次数字都减一。接着,在循环之后,打印出另一个信息并退出:

Filename: src/main.rs

fn main() {
    let mut number = 3;

    while number != 0  {
        println!("{}!", number);

        number = number - 1;
    }

    println!("LIFTOFF!!!");
}

这个结构消除了很多需要嵌套使用loopifelsebreak的代码,这样显得更加清楚。当条件为真就执行,否则退出循环。

使用for遍历集合

可以使用while结构来遍历一个元素集合,比如数组。例如:

Filename: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];
    let mut index = 0;

    while index < 5 {
        println!("the value is: {}", a[index]);

        index = index + 1;
    }
}

Listing 3-5: Looping through each element of a collection using a while loop

这里代码对数组中的元素进行计数。它从索引0开始,并接着循环直到遇到数组的最后一个索引(这时,index < 5不再为真)。运行这段代码会打印出数组中的每一个元素:

$ cargo run
   Compiling loops v0.1.0 (file:///projects/loops)
     Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50

所有数组中的五个元素都如期被打印出来。尽管index在某一时刻会到达值5,不过循环在其尝试从数组获取第六个值(会越界)之前就停止了。

不过这个过程是容易出错的;如果索引长度不正确会导致程序 panic。这也使程序更慢,因为编译器增加了运行时代码来对每次循环的每个元素进行条件检查。

可以使用for循环来对一个集合的每个元素执行一些代码,来作为一个更有效率替代。for循环看起来像这样:

Filename: src/main.rs

fn main() {
    let a = [10, 20, 30, 40, 50];

    for element in a.iter() {
        println!("the value is: {}", element);
    }
}

Listing 3-6: Looping through each element of a collection using a for loop

当运行这段代码,将看到与列表 3-5 一样的输出。更为重要的是,我们增强了代码安全性并消除了出现可能会导致超出数组的结尾或遍历长度不够而缺少一些元素这类 bug 机会。

例如,在列表 3-5 的代码中,如果从数组a中移除一个元素但忘记更新条件为while index < 4,代码将会 panic。使用for循环的话,就不需要惦记着在更新数组元素数量时修改其他的代码了。

for循环的安全性和简洁性使得它在成为 Rust 中使用最多的循环结构。即使是在想要循环执行代码特定次数时,例如列表 3-5 中使用while循环的倒计时例子,大部分 Rustacean 也会使用for循环。这么做的方式是使用Range,它是标准库提供的用来生成从一个数字开始到另一个数字结束的所有数字序列的类型。

下面是一个使用for循环来倒计时的例子,它还使用了一个我们还未讲到的方法,rev,用来反转 range:

Filename: src/main.rs

fn main() {
    for number in (1..4).rev() {
        println!("{}!", number);
    }
    println!("LIFTOFF!!!");
}

这段代码看起来更帅气不是吗?

总结

你做到了!这是一个相当可观的章节:你学习了变量,标量和if表达式,还有循环!如果你想要实践本章讨论的概念,尝试构建如下的程序:

  • 相互转换摄氏与华氏温度
  • 生成 n 阶斐波那契数列
  • 打印圣诞颂歌“The Twelve Days of Christmas”的歌词,并利用歌曲中的重复部分(编写循环)

当你准备好继续的时候,让我们讨论一个其他语言中并不常见的概念:所有权(ownership)。

认识所有权

ch04-00-understanding-ownership.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

所有权(系统)是 Rust 最独特的功能,它使得 Rust 可以无需垃圾回收(garbage collector)就能保障内存安全。因此,理解 Rust 中所有权如何工作是十分重要的。本章我们将讲到所有权以及相关功能:借用、slice 以及 Rust 如何在内存中摆放数据。

什么是所有权

ch04-01-what-is-ownership.md
commit 6d4ef020095a375483b2121d4fa2b1661062cc92

Rust 的核心功能(之一)是所有权ownership)。虽然这个功能理解起来很直观,不过它对语言的其余部分有着更深层的含义。

所有程序都必须管理他们运行时使用计算机内存的方式。一些语言中使用垃圾回收在程序运行过程中来时刻寻找不再被使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:内存被一个所有权系统管理,它拥有一系列的规则使编译器在编译时进行检查。任何所有权系统的功能都不会导致运行时开销。

因为所有权对很多程序员都是一个新概念,需要一些时间来适应。好消息是随着你对 Rust 和所有权系统的规则越来越有经验,你就越能自然地编写出安全和高效的代码。持之以恒!

当你理解了所有权系统,你就会对这个使 Rust 如此独特的功能有一个坚实的基础。在本章中,你将会通过一些例子来学习所有权,他们关注一个非常常见的数据结构:字符串。

栈(Stack)与堆(Heap)

在很多语言中并不经常需要考虑到栈与堆。不过在像 Rust 这样的系统编程语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出这样的选择。我们会在本章的稍后部分描述所有权与堆与栈相关的部分,所以这里只是一个用来预热的简要解释。

栈和堆都是代码在运行时可供使用的内存部分,不过他们以不同的结构组成。栈以放入值的顺序存储并以相反顺序取出值。这也被称作后进先出last in, first out)。想象一下一叠盘子:当增加更多盘子时,把他们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做进栈pushing onto the stack),而移出数据叫做出栈popping off the stack)。

操作栈是非常快的,因为它访问数据的方式:永远也不需要寻找一个位置放入新数据或者取出数据因为这个位置总是在栈顶。另一个使得栈快速的性质是栈中的所有数据都必须是一个已知的固定的大小。

相反对于在编译时未知大小或大小可能变化的数据,可以把他们储存在堆上。堆是缺乏组织的:当向堆放入数据时,我们请求一定大小的空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回给我们一个它位置的指针。这个过程称作在堆上分配内存allocating on the heap),并且有时这个过程就简称为“分配”(allocating)。向栈中放入数据并不被认为是分配。因为指针是已知的固定大小的,我们可以将指针储存在栈上,不过当需要实际数据时,必须访问指针。

想象一下去餐馆就坐吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。

访问堆上的数据要比访问栈上的数据要慢因为必须通过指针来访问。现代的处理器在内存中跳转越少就越快。继续类比,假设有一台服务器来处理来自多个桌子的订单。它在处理完一个桌子的所有订单后再移动到下一个桌子是最有效率的。从桌子 A 获取一个订单,接着再从桌子 B 获取一个订单,然后再从桌子 A,然后再从桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据之间彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间。

当调用一个函数,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。

记录何处的代码在使用堆上的什么数据,最小化堆上的冗余数据的数量以及清理堆上不再使用的数据以致不至于耗尽空间,这些所有的问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过理解如何管理堆内存可以帮助我们理解所有权为何存在以及为什么以这种方式工作。

所有权规则

首先,让我们看一下所有权的规则。请记住它们,我们将讲解一些它们的例子:

  1. 每一个值都被它的所有者owner)变量拥有。
  2. 值在任意时刻只能被一个所有者拥有。
  3. 当所有者离开作用域,这个值将被丢弃。

变量作用域

我们已经在第二章完成过一个 Rust 程序的例子。现在我们已经掌握了基本语法,所以不会在所有的例子中包含fn main() {代码了,所以如果你是一路跟过来的,必须手动将之后例子的代码放入一个main函数中。为此,例子将显得更加具体,使我们可以关注具体细节而不是样板代码。

作为所有权的第一个例子,我们看看一些变量的作用域scope)。作用域是一个项(原文:item)在程序中有效的范围。假如有一个这样的变量:

let s = "hello";

变量s绑定到了一个字符串字面值,这个字符串值是硬编码进我们程序代码中的。这个变量从声明的点开始直到当前作用域结束时都是有效的。列表 4-1 的注释标明了变量s在哪里是有效的:

{                      // s is not valid here, it’s not yet declared
    let s = "hello";   // s is valid from this point forward

    // do stuff with s
}                      // this scope is now over, and s is no longer valid

Listing 4-1: A variable and the scope in which it is valid

换句话说,这里有两个重要的点:

  1. s进入作用域,它就是有效的。
  2. 这一直持续到它离开作用域为止。

目前为止,变量是否有效与作用域的关系跟其他编程语言是类似的。现在我们在此基础上介绍String类型。

String类型

为了演示所有权的规则,我们需要一个比第三章讲到的任何一个都要复杂的数据类型。之前出现的数据类型都是储存在栈上的并且当离开作用域时被移出栈,不过我们需要寻找一个储存在堆上的数据来探索 Rust 如何知道该在何时清理数据的。

这里使用String作为例子并专注于String与所有权相关的部分。这些方面也同样适用于其他标准库提供的或你自己创建的复杂数据类型。在第八章会更深入地讲解String

我们已经见过字符串字面值了,它被硬编码进程序里。字符串字面值是很方便的,不过他们并不总是适合所有需要使用文本的场景。原因之一就是他们是不可变的。另一个原因是不是所有字符串的值都能在编写代码时就知道:例如,如果想要获取用户输入并储存该怎么办呢?为此,Rust 有第二个字符串类型,String。这个类型储存在堆上所以储存在编译时未知大小的文本。可以用from从字符串字面值来创建String,如下:

let s = String::from("hello");

这两个冒号(::)运算符允许将特定的from函数置于String类型的命名空间(namespace)下而不需要使用类似string_from这样的名字。在第五章的“方法语法”(“Method Syntax”)部分会着重讲解这个语法而且在第七章会讲到模块的命名空间。

这类字符串可以被修改:

let mut s = String::from("hello");

s.push_str(", world!"); // push_str() appends a literal to a String

println!("{}", s); // This will print `hello, world!`

那么这里有什么区别呢?为什么String可变而字面值却不行呢?区别在于两个类型对内存的处理上。

内存与分配

对于字符串字面值的情况,我们在编译时就知道内容所以它直接被硬编码进最终的可执行文件中,这使得字符串字面值快速和高效。不过这些属性都只来源于它的不可变形。不幸的是,我们不能为了每一个在编译时未知大小的文本而将一块内存放入二进制文件中而它的大小还可能随着程序运行而改变。

对于String类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

  1. 内存必须在运行时向操作系统请求
  2. 需要一个当我们处理完String时将内存返回给操作系统的方法

第一部分由我们完成:当调用String::from时,它的实现请求它需要的内存。这在编程语言中是非常通用的。

然而,第二部分实现起来就各有区别了。在有垃圾回收GC)的语言中, GC 记录并清除不再使用的内存,而我们作为程序员,并不需要关心他们。没有 GC 的话,识别出不再使用的内存并调用代码显式释放就是我们程序员的责任了,正如请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个 bug。我们需要allocatefree一一对应。

Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是列表 4-1 中作用域例子的一个使用String而不是字符串字面值的版本:

{
    let s = String::from("hello"); // s is valid from this point forward

    // do stuff with s
}                                  // this scope is now over, and s is no
                                   // longer valid

这里是一个将String需要的内存返回给操作系统的很自然的位置:当s离开作用域的时候。当变量离开作用域,Rust 为其调用一个特殊的函数。这个函数叫做 drop,在这里String的作者可以放置释放内存的代码。Rust 在结尾的}处自动调用drop

注意:在 C++ 中,这种 item 在生命周期结束时释放资源的方法有时被称作资源获取即初始化Resource Acquisition Is Initialization (RAII))。如果你使用过 RAII 模式的话应该对 Rust 的drop函数不陌生。

这个模式对编写 Rust 代码的方式有着深远的影响。它现在看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。

变量与数据交互:移动

Rust 中的多个变量以一种独特的方式与同一数据交互。让我们看看列表 4-2 中一个使用整型的例子:

let x = 5;
let y = x;

Listing 4-2: Assigning the integer value of variable x to y

根据其他语言的经验大致可以猜到这在干什么:“将5绑定到x;接着生成一个值x的拷贝并绑定到y”。现在有了两个变量,xy,都等于5。这也正是事实上发生了的,因为正数是有已知固定大小的简单值,所以这两个5被放入了栈中。

现在看看这个String版本:

let s1 = String::from("hello");
let s2 = s1;

这看起来与上面的代码非常类似,所以我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个s1的拷贝并绑定到s2上。不过,事实上并不完全是这样。

为了更全面的解释这个问题,让我们看看图 4-3 中String真正是什么样。String由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据储存在栈上。右侧则是堆上存放内容的内存部分。

String in memory

Figure 4-3: Representation in memory of a String holding the value "hello" bound to s1

长度代表当前String的内容使用了多少字节的内存。容量是String从操作系统总共获取了多少字节的内存。长度与容量的区别是很重要的,不过这在目前为止的场景中并不重要,所以可以暂时忽略容量。

当我们把s1赋值给s2String的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制堆上指针所指向的数据。换句话说,内存中数据的表现如图 4-4 所示。

s1 and s2 pointing to the same value

Figure 4-4: Representation in memory of the variable s2 that has a copy of the pointer, length, and capacity of s1

这个表现形式看起来并不像图 4-5 中的那样,它是如果 Rust 也拷贝了堆上的数据后内存看起来是怎么样的。如果 Rust 这么做了,那么操作s2 = s1在堆上数据比较大的时候可能会对运行时性能造成非常大的影响。

s1 and s2 to two places

Figure 4-5: Another possibility of what s2 = s1 might do if Rust copied the heap data as well

之前,我们提到过当变量离开作用域后 Rust 自动调用drop函数并清理变量的堆内存。不过图 4-4 展示了两个数据指针指向了同一位置。这就有了一个问题:当s2s1离开作用域,他们都会尝试释放相同的内存。这是一个叫做 double free 的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

为了确保内存安全,这种场景下 Rust 的处理有另一个细节值得注意。与其尝试拷贝被分配的内存,Rust 则认为s1不再有效,因此 Rust 不需要在s1离开作用域后清理任何东西。看看在s2被创建之后尝试使用s1会发生生么:

let s1 = String::from("hello");
let s2 = s1;

println!("{}", s1);

你会得到一个类似如下的错误,因为 Rust 禁止你使用无效的引用。

error[E0382]: use of moved value: `s1`
 --> src/main.rs:4:27
  |
3 |     let s2 = s1;
  |         -- value moved here
4 |     println!("{}, world!", s1);
  |                            ^^ value used here after move
  |
  = note: move occurs because `s1` has type `std::string::String`,
which does not implement the `Copy` trait

如果你在其他语言中听说过术语“浅拷贝”(“shallow copy”)和“深拷贝”(“deep copy”),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效化了,这个操作被称为移动move),而不是浅拷贝。上面的例子可以解读为s1移动到了s2中。那么具体发生了什么如图 4-6 所示。

s1 moved to s2

Figure 4-6: Representation in memory after s1 has been invalidated

这样就解决了我们的麻烦!因为只有s2是有效的,当其离开作用域,它就释放自己的内存,完毕。

另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的“深拷贝”。因此,任何自动的复制可以被认为对运行时性能影响较小。

变量与数据交互:克隆

如果我们确实需要深度复制String中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做clone的通用函数。第五章会讨论方法语法,不过因为方法在很多语言中是一个常见功能,所以之前你可能已经见过了。

这是一个实际使用clone方法的例子:

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

这段代码能正常运行,也是如何显式产生图 4-5 中行为的方式,这里堆上的数据被复制了

当出现clone调用时,你知道一些特有的代码被执行而且这些代码可能相当消耗资源。所以它作为一个可视化的标识代表了不同的行为。

只在栈上的数据:拷贝

这里还有一个没有提到的小窍门。这些代码使用了整型并且是有效的,他们是之前列表 4-2 中的一部分:

let x = 5;
let y = x;

println!("x = {}, y = {}", x, y);

他们似乎与我们刚刚学到的内容相抵触:没有调用clone,不过x依然有效且没有被移动到y中。

原因是像整型这样的在编译时已知大小的类型被整个储存在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量y后使x无效。换句话说,这里没有深浅拷贝的区别,所以调用clone并不会与通常的浅拷贝有什么不同,我们可以不用管它。

Rust 有一个叫做Copy trait 的特殊注解,可以用在类似整型这样的储存在栈上的类型(第十章详细讲解 trait)。如果一个类型拥有Copy trait,一个旧的变量在(重新)赋值后仍然可用。Rust 不允许自身或其任何部分实现了Drop trait 的类型使用Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用Copy注解,将会出现一个编译时错误。关于如何为你的类型增加Copy注解,请阅读附录 C 中的 Derivable Trait。

那么什么类型是Copy的呢?可以查看给定类型的文档来确认,不过作为一个通用的规则,任何简单标量值的组合可以是Copy的,任何不需要分配内存或类似形式资源的类型是Copy的,如下是一些Copy的类型:

  • 所有整数类型,比如u32
  • 布尔类型,bool,它的值是truefalse
  • 所有浮点数类型,比如f64
  • 元组,当且仅当其包含的类型也都是Copy的时候。(i32, i32)Copy的,不过(i32, String)就不是。

所有权与函数

将值传递给函数在语言上与给变量赋值相似。向函数传递值可能会移动或者复制,就像赋值语句一样。列表 4-7 是一个带有变量何时进入和离开作用域标注的例子:

Filename: src/main.rs

fn main() {
    let s = String::from("hello");  // s comes into scope.

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here.
    let x = 5;                      // x comes into scope.

    makes_copy(x);                  // x would move into the function,
                                    // but i32 is Copy, so it’s okay to still
                                    // use x afterward.

} // Here, x goes out of scope, then s. But since s's value was moved, nothing
  // special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope.
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope.
    println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

Listing 4-7: Functions with ownership and scope annotated

当尝试在调用takes_ownership后使用s时,Rust 会抛出一个编译时错误。这些静态检查使我们免于犯错。试试在main函数中添加使用sx的代码来看看哪里能使用他们,以及哪里所有权规则会阻止我们这么做。

返回值与作用域

返回值也可以转移作用域。这里是一个有与列表 4-7 中类似标注的例子:

Filename: src/main.rs

fn main() {
    let s1 = gives_ownership();         // gives_ownership moves its return
                                        // value into s1.

    let s2 = String::from("hello");     // s2 comes into scope.

    let s3 = takes_and_gives_back(s2);  // s2 is moved into
                                        // takes_and_gives_back, which also
                                        // moves its return value into s3.
} // Here, s3 goes out of scope and is dropped. s2 goes out of scope but was
  // moved, so nothing happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {             // gives_ownership will move its
                                             // return value into the function
                                             // that calls it.

    let some_string = String::from("hello"); // some_string comes into scope.

    some_string                              // some_string is returned and
                                             // moves out to the calling
                                             // function.
}

// takes_and_gives_back will take a String and return one.
fn takes_and_gives_back(a_string: String) -> String { // a_string comes into
                                                      // scope.

    a_string  // a_string is returned and moves out to the calling function.
}

变量的所有权总是遵循相同的模式:将值赋值给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过drop被清理掉,除非数据被移动为另一个变量所有。

在每一个函数中都获取并接着返回所有权是冗余乏味的。如果我们想要函数使用一个值但不获取所有权改怎么办呢?如果我们还要接着使用它的话,每次都传递出去再传回来就有点烦人了,另外我们也可能想要返回函数体产生的任何(不止一个)数据。

使用元组来返回多个值是可能的,像这样:

Filename: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String.

    (s, length)
}

但是这不免有些形式主义,同时这离一个通用的观点还有很长距离。幸运的是,Rust 对此提供了一个功能,叫做引用references)。

引用与借用

ch04-02-references-and-borrowing.md
commit 5e0546f53cce14b126527d9ba6d1b8eb212b4f3d

在上一部分的结尾处的使用元组的代码是有问题的,我们需要将String返回给调用者函数这样就可以在调用calculate_length后仍然可以使用String了,因为String先被移动到了calculate_length

下面是如何定义并使用一个(新的)calculate_length函数,它以一个对象的引用作为参数而不是获取值的所有权:

Filename: src/main.rs

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递&s1calculate_length,同时在函数定义中,我们获取&String而不是String

这些 & 符号就是引用,他们允许你使用值但不获取它的所有权。图 4-8 展示了一个图解。

&String s pointing at String s1

Figure 4-8: &String s pointing at String s1

仔细看看这个函数调用:

# fn calculate_length(s: &String) -> usize {
#     s.len()
# }
let s1 = String::from("hello");

let len = calculate_length(&s1);

&s1语法允许我们创建一个参考s1的引用,但是并不拥有它。因为并不拥有这个值,当引用离开作用域它指向的值也不会被丢弃。

同理,函数签名使用了&来表明参数s的类型是一个引用。让我们增加一些解释性的注解:

fn calculate_length(s: &String) -> usize { // s is a reference to a String
    s.len()
} // Here, s goes out of scope. But because it does not have ownership of what
  // it refers to, nothing happens.

变量s有效的作用域与函数参数的作用域一样,不过当引用离开作用域后并不丢弃它指向的数据因为我们没有所有权。函数使用引用而不是实际值作为参数意味着无需返回值来交还所有权,因为就不曾拥有它。

我们将获取引用作为函数参数称为借用borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从它哪里借来。当你使用完毕,必须还回去。

如果我们尝试修改借用的变量呢?尝试列表 4-9 中的代码。剧透:这行不通!

Filename: src/main.rs

fn main() {
    let s = String::from("hello");

    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str(", world");
}

Listing 4-9: Attempting to modify a borrowed value

这里是错误:

error: cannot borrow immutable borrowed content `*some_string` as mutable
 --> error.rs:8:5
  |
8 |     some_string.push_str(", world");
  |     ^^^^^^^^^^^

正如变量默认是不可变的,引用也一样。不允许修改引用的值。

可变引用

可以通过一个小调整来修复在列表 4-9 代码中的错误,在列表 4-9 的代码中:

Filename: src/main.rs

fn main() {
    let mut s = String::from("hello");

    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str(", world");
}

首先,必须将s改为mut。然后必须创建一个可变引用&mut s和接受一个可变引用some_string: &mut String

不过可变引用有一个很大的限制:在特定作用域中的特定数据有且只有一个可变引用。这些代码会失败:

Filename: src/main.rs

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

具体错误如下:

error[E0499]: cannot borrow `s` as mutable more than once at a time
 --> borrow_twice.rs:5:19
  |
4 |     let r1 = &mut s;
  |                   - first mutable borrow occurs here
5 |     let r2 = &mut s;
  |                   ^ second mutable borrow occurs here
6 | }
  | - first borrow ends here

这个限制允许可变性,不过是以一种受限制的方式。新 Rustacean 们经常与此作斗争,因为大部分语言任何时候都是可变的。这个限制的好处是 Rust 可以在编译时就避免数据竞争(data races)。

数据竞争是一种特定类型的竞争状态,它可由这三个行为造成:

  1. 两个或更多指针同时访问同一数据。
  2. 至少有一个指针被写入。
  3. 没有同步数据访问的机制。

数据竞争会导致未定义行为,在运行时难以追踪,并且难以诊断和修复;Rust 避免了这种情况,它拒绝编译存在数据竞争的代码!

一如既往,可以使用大括号来创建一个新的作用域来允许拥有多个可变引用,只是不能同时拥有:

let mut s = String::from("hello");

{
    let r1 = &mut s;

} // r1 goes out of scope here, so we can make a new reference with no problems.

let r2 = &mut s;

当结合可变和不可变引用时有一个类似的规则存在。这些代码会导致一个错误:

let mut s = String::from("hello");

let r1 = &s; // no problem
let r2 = &s; // no problem
let r3 = &mut s; // BIG PROBLEM

错误如下:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as
immutable
 --> borrow_thrice.rs:6:19
  |
4 |     let r1 = &s; // no problem
  |               - immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM
  |                   ^ mutable borrow occurs here
7 | }
  | - immutable borrow ends here

哇哦!我们不能在拥有不可变引用的同时拥有可变引用。不可变引用的用户可不希望在它的眼皮底下值突然就被改变了!然而,多个不可变引用是没有问题的因为没有哪个读取数据的人有能力影响其他人读取到的数据。

即使这些错误有时是使人沮丧的。记住这是 Rust 编译器在提早指出一个潜在的 bug(在编译时而不是运行时)并明确告诉你问题在哪而不是任由你去追踪为何有时数据并不是你想象中的那样。

悬垂引用

在存在指针的语言中,容易通过释放内存时保留指向它的指针而错误地生成一个悬垂指针dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当我们拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

让我们尝试创建一个悬垂引用:

Filename: src/main.rs

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

这里是错误:

error[E0106]: missing lifetime specifier
 --> dangle.rs:5:16
  |
5 | fn dangle() -> &String {
  |                ^^^^^^^
  |
  = help: this function's return type contains a borrowed value, but there is no
    value for it to be borrowed from
  = help: consider giving it a 'static lifetime

error: aborting due to previous error

错误信息引用了一个我们还未涉及到的功能:生命周期lifetimes)。第十章会详细介绍生命周期。不过,如果你不理会生命周期的部分,错误信息确实包含了为什么代码是有问题的关键:

this function's return type contains a borrowed value, but there is no value
for it to be borrowed from.

让我们仔细看看我们的dangle代码的每一步到底放生了什么:

fn dangle() -> &String { // dangle returns a reference to a String

    let s = String::from("hello"); // s is a new String

    &s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped. Its memory goes away.
  // Danger!

因为s是在dangle创建的,当dangle的代码执行完毕后,s将被释放。不过我们尝试返回一个它的引用。这意味着这个引用会指向一个无效的String!这可不好。Rust 不会允许我们这么做的。

这里的解决方法是直接返回String

fn no_dangle() -> String {
    let s = String::from("hello");

    s
}

这样就可以没有任何错误的运行了。所有权被移动出去,所以没有值被释放掉。

引用的规则

简要的概括一下对引用的讨论:

  1. 在任意给定时间,只能拥有如下中的一个:
  • 一个可变引用。
  • 任意数量的不可变引用。
  1. 引用必须总是有效的。

接下来,我们来看看一种不同类型的引用:slice。

Slices

ch04-03-slices.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

另一个没有所有权的数据类型是 slice。slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。

这里有一个小的编程问题:编写一个获取一个字符串并返回它在其中找到的第一个单词的函数。如果函数没有在字符串中找到一个空格,就意味着整个字符串是一个单词,所以整个字符串都应该返回。

让我们看看这个函数的签名:

fn first_word(s: &String) -> ?

first_word这个函数有一个参数&String。因为我们不需要所有权,所以这没有问题。不过应该返回什么呢?我们并没有一个真正获取部分字符串的办法。不过,我们可以返回单词结尾的索引。让我们试试如列表 4-10 所示的代码:

Filename: src/main.rs

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

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

    s.len()
}

Listing 4-10: The first_word function that returns a byte index value into the String parameter

让我们将代码分解成小块。因为需要一个元素一个元素的检查String中的值是否是空格,需要用as_bytes方法将String转化为字节数组:

let bytes = s.as_bytes();

接下来,使用iter方法在字节数据上创建一个迭代器:

for (i, &item) in bytes.iter().enumerate() {

第十三章将讨论迭代器的更多细节。现在,只需知道iter方法返回集合中的每一个元素,而enumerate包装iter的结果并返回一个元组,其中每一个元素是元组的一部分。返回元组的第一个元素是索引,第二个元素是集合中元素的引用。这比我们自己计算索引要方便一些。

因为enumerate方法返回一个元组,我们可以使用模式来解构它,就像 Rust 中其他地方一样。所以在for循环中,我们指定了一个模式,其中i是元组中的索引而&item是单个字节。因为从.iter().enumerate()中获取了集合元素的引用,我们在模式中使用了&

我们通过字节的字面值来寻找代表空格的字节。如果找到了,返回它的位置。否则,使用s.len()返回字符串的长度:

    if item == b' ' {
        return i;
    }
}
s.len()

现在有了一个找到字符串中第一个单词结尾索引的方法了,不过这有一个问题。我们返回了单单一个usize,不过它只在&String的上下文中才是一个有意义的数字。换句话说,因为它是一个与String相分离的值,无法保证将来它仍然有效。考虑一下列表 4-11 中使用了列表 4-10 first_word函数的程序:

Filename: src/main.rs

# fn first_word(s: &String) -> usize {
#     let bytes = s.as_bytes();
#
#     for (i, &item) in bytes.iter().enumerate() {
#         if item == b' ' {
#             return i;
#         }
#     }
#
#     s.len()
# }
#
fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // word will get the value 5.

    s.clear(); // This empties the String, making it equal to "".

    // word still has the value 5 here, but there's no more string that
    // we could meaningfully use the value 5 with. word is now totally invalid!
}

Listing 4-11: Storing the result from calling the first_word function then changing the String contents

这个程序编译时没有任何错误,而且在调用s.clear()之后使用word也不会出错。这时words状态就没有联系了,所以word仍然包含值5。可以尝试用值5来提取变量s的第一个单词,不过这是有 bug 的,因为在我们将5保存到word之后s的内容已经改变。

不得不担心word的索引与s中的数据不再同步是乏味且容易出错的!如果编写一个second_word函数的话管理索引将更加容易出问题。它的签名看起来像这样:

fn second_word(s: &String) -> (usize, usize) {

现在我们跟踪了一个开始索引一个结尾索引,同时有了更多从数据的某个特定状态计算而来的值,他们也完全没有与这个状态相关联。现在有了三个飘忽不定的不相关变量都需要被同步。

幸运的是,Rust 为这个问题提供了一个解决方案:字符串 slice。

字符串 slice

字符串 slicestring slice)是String中一部分值的引用,它看起来像这样:

let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

这类似于获取整个String的引用不过带有额外的[0..5]部分。不同于整个String的引用,这是一个包含String内部的一个位置和所需元素数量的引用。

我们使用一个 range [starting_index..ending_index]来创建 slice,不过 slice 的数据结构实际上储存了开始位置和 slice 的长度。所以就let world = &s[6..11];来说,world将是一个包含指向s第 6 个字节的指针和长度值 5 的 slice。

图 4-12 展示了一个图例

world containing a pointer to the 6th byte of String s and a length 5

Figure 4-12: String slice referring to part of a String

对于 Rust 的.. range 语法,如果想要从第一个索引(0)开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:

let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];

由此类推,如果 slice 包含String的最后一个字节,也可以舍弃尾部的数字。这意味着如下也是相同的:

let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];

在记住所有这些知识后,让我们重写first_word来返回一个 slice。“字符串 slice”的签名写作&str

Filename: src/main.rs

fn first_word(s: &String) -> &str {
    let bytes = s.as_bytes();

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

    &s[..]
}

我们使用跟列表 4-10 相同的方式获取单词结尾的索引,通过寻找第一个出现的空格。当我们找到一个空格,我们返回一个索引,它使用字符串的开始和空格的索引来作为开始和结束的索引。

现在当调用first_word时,会返回一个单独的与底层数据相联系的值。这个值由一个 slice 开始位置的引用和 slice 中元素的数量组成。

second_word函数也可以改为返回一个 slice:

fn second_word(s: &String) -> &str {

现在我们有了一个不易混杂的直观的 API 了,因为编译器会确保指向String的引用保持有效。还记得列表 4-11 程序中,那个当我们获取第一个单词结尾的索引不过接着就清除了字符串所以索引就无效了的 bug 吗?那些代码逻辑上时不正确的,不过却没有任何直观的错误。问题会在之后尝试对空字符串使用第一个单词的索引时出现。slice 就不可能出现这种 bug 并让我们更早的知道出问题了。使用 slice 版本的first_word会抛出一个编译时错误:

Filename: src/main.rs

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s);

    s.clear(); // Error!
}

这里是编译错误:

17:6 error: cannot borrow `s` as mutable because it is also borrowed as
            immutable [E0502]
    s.clear(); // Error!
    ^
15:29 note: previous borrow of `s` occurs here; the immutable borrow prevents
            subsequent moves or mutable borrows of `s` until the borrow ends
    let word = first_word(&s);
                           ^
18:2 note: previous borrow ends here
fn main() {

}
^

回忆一下借用规则,当拥有某值的不可变引用时。不能再获取一个可变引用。因为clear需要清空String,它尝试获取一个可变引用,它失败了。Rust 不仅使得我们的 API 简单易用,也在编译时就消除了一整个错误类型!

字符串字面值就是 slice

还记得我们讲到过字符串字面值被储存在二进制文件中吗。现在知道 slice 了,我们就可以正确的理解字符串字面值了:

let s = "Hello, world!";

这里s的类型是&str:它是一个指向二进制程序特定位置的 slice。这也就是为什么字符串字面值是不可变的;&str是一个不可变引用。

字符串 slice 作为参数

在知道了能够获取字面值和String的 slice 后引起了另一个对first_word的改进,这是它的签名:

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

相反一个更有经验的 Rustacean 会写下如下这一行,因为它使得可以对String&str使用相同的函数:

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

如果有一个字符串 slice,可以直接传递它。如果有一个String,则可以传递整个String的 slice。定义一个获取字符串 slice 而不是字符串引用的函数使得我们的 API 更加通用并且不会丢失任何功能:

Filename: src/main.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[..]
# }
fn main() {
    let my_string = String::from("hello world");

    // first_word works on slices of `String`s
    let word = first_word(&my_string[..]);

    let my_string_literal = "hello world";

    // first_word works on slices of string literals
    let word = first_word(&my_string_literal[..]);

    // since string literals *are* string slices already,
    // this works too, without the slice syntax!
    let word = first_word(my_string_literal);
}

其他 slice

字符串 slice,正如你想象的那样,是针对字符串的。不过也有更通用的 slice 类型。考虑一下这个数组:

let a = [1, 2, 3, 4, 5];

就跟我们想要获取字符串的一部分那样,我们也会想要引用数组的一部分,而我们可以这样做:

let a = [1, 2, 3, 4, 5];

let slice = &a[1..3];

这个 slice 的类型是&[i32]。它跟以跟字符串 slice 一样的方式工作,通过储存第一个元素的引用和一个长度。你可以对其他所有类型的集合使用这类 slice。第八章讲到 vector 时会详细讨论这些集合。

总结

所有权、借用和 slice 这些概念是 Rust 何以在编译时保障内存安全的关键所在。Rust 像其他系统编程语言那样给予你对内存使用的控制,但拥有数据所有者在离开作用域后自动清除其数据的功能意味着你无须额外编写和调试相关的控制代码。

所有权系统影响了 Rust 中其他很多部分如何工作,所以我们会继续讲到这些概念,贯穿本书的余下内容。让我们开始下一个章节,来看看如何将多份数据组合进一个struct中。

结构体

ch05-00-structs.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

struct,是 structure 的缩写,是一个允许我们命名并将多个相关值包装进一个有意义的组合的自定义类型。如果你来自一个面向对象编程语言背景,struct就像对象中的数据属性(字段)。在这一章的下一部分会讲到如何在结构体上定义方法;方法是如何为结构体数据指定行为的函数。structenum(将在第六章讲到)是为了充分利用 Rust 的编译时类型检查来在程序范围内创建新类型的基本组件。

对结构体的一种看法是他们与元组类似,这个我们在第三章讲过了。就像元组,结构体的每一部分可以是不同类型。可以命名各部分数据以便能更清楚的知道其值的意义。由于有了这些名字使得结构体更灵活:不需要依赖顺序来指定或访问实例中的值。

为了定义结构体,通过struct关键字并为整个结构体提供一个名字。结构体的名字需要描述它所组合的数据的意义。接着,在大括号中,定义每一部分数据的名字,他们被称作字段field),并定义字段类型。例如,列表 5-1 展示了一个储存用户账号信息的结构体:

struct User {
    username: String,
    email: String,
    sign_in_count: u64,
    active: bool,
}

Listing 5-1: A User struct definition

一旦定义了结构体后为了使用它,通过为每个字段指定具体值来创建这个结构体的实例。创建一个实例需要以结构体的名字开头,接着在大括号中使用key: value对的形式提供字段,其中 key 是字段的名字而 value 是需要储存在字段中的数据值。这时字段的顺序并不必要与在结构体中声明他们的顺序一致。换句话说,结构体的定义就像一个这个类型的通用模板,而实例则会在这个模板中放入特定数据来创建这个类型的值。例如,我们可以像这样来声明一个特定的用户:

# struct User {
#     username: String,
#     email: String,
#     sign_in_count: u64,
#     active: bool,
# }
#
let user1 = User {
    email: String::from("someone@example.com"),
    username: String::from("someusername123"),
    active: true,
    sign_in_count: 1,
};

为了从结构体中获取某个值,可以使用点号。如果我们只想要用户的邮箱地址,可以用user1.email

结构体数据的所有权

在列表 5-1 中的User结构体的定义中,我们使用了自身拥有所有权的String类型而不是&str字符串 slice 类型。这是一个有意而为之的选择,因为我们想要这个结构体拥有它所有的数据,为此只要整个结构体是有效的话其数据也应该是有效的。

可以使结构体储存被其他对象拥有的数据的引用,不过这么做的话需要用上生命周期lifetimes),这是第十章会讨论的一个 Rust 的功能。生命周期确保结构体引用的数据有效性跟结构体本身保持一致。如果你尝试在结构体中储存一个引用而不指定生命周期,比如这样:

Filename: src/main.rs

struct User {
    username: &str,
    email: &str,
    sign_in_count: u64,
    active: bool,
}

fn main() {
    let user1 = User {
        email: "someone@example.com",
        username: "someusername123",
        active: true,
        sign_in_count: 1,
    };
}

编译器会抱怨它需要生命周期说明符:

error[E0106]: missing lifetime specifier
 -->
  |
2 |     username: &str,
  |               ^ expected lifetime parameter

error[E0106]: missing lifetime specifier
 -->
  |
3 |     email: &str,
  |            ^ expected lifetime parameter

第十章会讲到如何修复这个问题以便在结构体中储存引用,不过现在,通过从像&str这样的引用切换到像String这类拥有所有权的类型来修改修改这个错误。

一个示例程序

为了理解何时会需要使用结构体,让我们编写一个计算长方形面积的程序。我们会从单独的变量开始,接着重构程序直到使用结构体替代他们为止。

使用 Cargo 来创建一个叫做 rectangles 的新二进制程序,它会获取一个长方形以像素为单位的长度和宽度并计算它的面积。列表 5-2 中是项目的 src/main.rs 文件中为此实现的一个小程序:

Filename: src/main.rs

fn main() {
    let length1 = 50;
    let width1 = 30;

    println!(
        "The area of the rectangle is {} square pixels.",
        area(length1, width1)
    );
}

fn area(length: u32, width: u32) -> u32 {
    length * width
}

Listing 5-2: Calculating the area of a rectangle specified by its length and width in separate variables

尝试使用cargo run运行程序:

The area of the rectangle is 1500 square pixels.

使用元组重构

我们的小程序能正常运行;它调用area函数用长方形的每个维度来计算出面积。不过我们可以做的更好。长度和宽度是相关联的,因为他们在一起才能定义一个长方形。

这个做法的问题突显在area的签名上:

fn area(length: u32, width: u32) -> u32 {

函数area本应该计算一个长方形的面积,不过函数却有两个参数。这两个参数是相关联的,不过程序自身却哪里也没有表现出这一点。将长度和宽度组合在一起将更易懂也更易处理。

第三章已经讨论过了一种可行的方法:元组。列表 5-3 是一个使用元组的版本:

Filename: src/main.rs

fn main() {
    let rect1 = (50, 30);

    println!(
        "The area of the rectangle is {} square pixels.",
        area(rect1)
    );
}

fn area(dimensions: (u32, u32)) -> u32 {
    dimensions.0 * dimensions.1
}

Listing 5-3: Specifying the length and width of the rectangle with a tuple

在某种程度上说这样好一点了。元组帮助我们增加了一些结构性,现在在调用area的时候只用传递一个参数。不过另一方面这个方法却更不明确了:元组并没有给出它元素的名称,所以计算变得更费解了,因为不得不使用索引来获取元组的每一部分:

dimensions.0 * dimensions.1

在面积计算时混淆长宽并没有什么问题,不过当在屏幕上绘制长方形时就有问题了!我们将不得不记住元组索引0length1width。如果其他人要使用这些代码,他们也不得不搞清楚后再记住他们。容易忘记或者混淆这些值而造成错误,因为我们没有表明代码中数据的意义。

使用结构体重构:增加更多意义

现在引入结构体的时候了。我们可以将元组转换为一个有整体名称而且每个部分也有对应名字的数据类型,如列表 5-4 所示:

Filename: src/main.rs

struct Rectangle {
    length: u32,
    width: u32,
}

fn main() {
    let rect1 = Rectangle { length: 50, width: 30 };

    println!(
        "The area of the rectangle is {} square pixels.",
        area(&rect1)
    );
}

fn area(rectangle: &Rectangle) -> u32 {
    rectangle.length * rectangle.width
}

Listing 5-4: Defining a Rectangle struct

这里我们定义了一个结构体并称其为Rectangle。在{}中定义了字段lengthwidth,都是u32类型的。接着在main中,我们创建了一个长度为 50 和宽度为 30 的Rectangle的具体实例。

函数area现在被定义为接收一个名叫rectangle的参数,它的类型是一个结构体Rectangle实例的不可变借用。第四章讲到过,我们希望借用结构体而不是获取它的所有权这样main函数就可以保持rect1的所有权并继续使用它,所以这就是为什么在函数签名和调用的地方会有&

area函数访问Rectanglelengthwidth字段。area的签名现在明确的表明了我们的意图:通过其lengthwidth字段,计算一个Rectangle的面积,。这表明了长度和宽度是相互联系的,并为这些值提供了描述性的名称而不是使用元组的索引值01。结构体胜在更清晰明了。

通过衍生 trait 增加实用功能

如果能够在调试程序时打印出Rectangle实例来查看其所有字段的值就更好了。列表 5-5 尝试像往常一样使用println!宏:

Filename: src/main.rs

struct Rectangle {
    length: u32,
    width: u32,
}

fn main() {
    let rect1 = Rectangle { length: 50, width: 30 };

    println!("rect1 is {}", rect1);
}

Listing 5-5: Attempting to print a Rectangle instance

如果运行代码,会出现带有如下核心信息的错误:

error[E0277]: the trait bound `Rectangle: std::fmt::Display` is not satisfied

println!宏能处理很多类型的格式,不过,{},默认告诉println!使用称为Display的格式:直接提供给终端用户查看的输出。目前为止见过的基本类型都默认实现了Display,所以它就是向用户展示1或其他任何基本类型的唯一方式。不过对于结构体,println!应该用来输出的格式是不明确的,因为这有更多显示的可能性:是否需要逗号?需要打印出结构体的{}吗?所有字段都应该显示吗?因为这种不确定性,Rust 不尝试猜测我们的意图所以结构体并没有提供一个Display的实现。

但是如果我们继续阅读错误,将会发现这个有帮助的信息:

note: `Rectangle` cannot be formatted with the default formatter; try using
`:?` instead if you are using a format string

让我们来试试!现在println!看起来像println!("rect1 is {:?}", rect1);这样。在{}中加入:?指示符告诉println!我们想要使用叫做Debug的输出格式。Debug是一个 trait,它允许我们在调试代码时以一种对开发者有帮助的方式打印出结构体。

让我们试试运行这个变化...见鬼了。仍然能看到一个错误:

error: the trait bound `Rectangle: std::fmt::Debug` is not satisfied

虽然编译器又一次给出了一个有帮助的信息!

note: `Rectangle` cannot be formatted using `:?`; if it is defined in your
crate, add `#[derive(Debug)]` or manually implement it

Rust 确实包含了打印出调试信息的功能,不过我们必须为结构体显式选择这个功能。为此,在结构体定义之前加上#[derive(Debug)]注解,如列表 5-6 所示:

#[derive(Debug)]
struct Rectangle {
    length: u32,
    width: u32,
}

fn main() {
    let rect1 = Rectangle { length: 50, width: 30 };

    println!("rect1 is {:?}", rect1);
}

Listing 5-6: Adding the annotation to derive the Debug trait and printing the Rectangle instance using debug formatting

此时此刻运行程序,运行这个程序,不会有任何错误并会出现如下输出:

rect1 is Rectangle { length: 50, width: 30 }

好极了!这不是最漂亮的输出,不过它显示这个实例的所有字段,毫无疑问这对调试有帮助。如果想要输出再好看和易读一点,这对更大的结构体会有帮助,可以将println!的字符串中的{:?}替换为{:#?}。如果在这个例子中使用了美化的调试风格的话,输出会看起来像这样:

rect1 is Rectangle {
    length: 50,
    width: 30
}

Rust 为我们提供了很多可以通过derive注解来使用的 trait,他们可以为我们的自定义类型增加有益的行为。这些 trait 和行为在附录 C 中列出。第十章会涉及到如何通过自定义行为来实现这些 trait,同时还有如何创建你自己的 trait。

我们的area函数是非常明确的————它只是计算了长方形的面积。如果这个行为与Rectangle结构体再结合得更紧密一些就更好了,因为这明显就是Rectangle类型的行为。现在让我们看看如何继续重构这些代码,来将area函数协调进Rectangle类型定义的area方法中。

方法语法

ch05-01-method-syntax.md
commit 8c1c1a55d5c0f9bc3c866ee79b267df9dc5c04e2

方法与函数类似:他们使用fn关键和名字声明,他们可以拥有参数和返回值,同时包含一些代码会在某处被调用时执行。不过方法与函数是不同的,因为他们在结构体(或者枚举或者 trait 对象,将分别在第六章和第十七章讲解)的上下文中被定义,并且他们第一个参数总是self,它代表方法被调用的结构体的实例。

定义方法

让我们将获取一个Rectangle实例作为参数的area函数改写成一个定义于Rectangle结构体上的area方法,如列表 5-7 所示:

Filename: src/main.rs

#[derive(Debug)]
struct Rectangle {
    length: u32,
    width: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.length * self.width
    }
}

fn main() {
    let rect1 = Rectangle { length: 50, width: 30 };

    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

Listing 5-7: Defining an area method on the Rectangle struct

为了使函数定义于Rectangle的上下文中,我们开始了一个impl块(implimplementation 的缩写)。接着将函数移动到impl大括号中,并将签名中的第一个(在这里也是唯一一个)参数和函数体中其他地方的对应参数改成self。然后在main中将我们调用area方法并传递rect1作为参数的地方,改成使用方法语法Rectangle实例上调用area方法。方法语法获取一个实例并加上一个点号后跟方法名、括号以及任何参数。

area的签名中,开始使用&self来替代rectangle: &Rectangle,因为该方法位于impl Rectangle 上下文中所以 Rust 知道self的类型是Rectangle。注意仍然需要在self前面加上&,就像&Rectangle一样。方法可以选择获取self的所有权,像我们这里一样不可变的借用self,或者可变的借用self,就跟其他别的参数一样。

这里选择&self跟在函数版本中使用&Rectangle出于同样的理由:我们并不想获取所有权,只希望能够读取结构体中的数据,而不是写入。如果想要能够在方法中改变调用方法的实例的话,需要将第一个参数改为&mut self。通过仅仅使用self作为第一个参数来使方法获取实例的所有权,不过这是很少见的;这通常用在当方法将self转换成别的实例的时候,同时我们想要防止调用者在转换之后使用原始的实例。

使用方法而不是函数,除了使用了方法语法和不需要在每个函数签名中重复self类型外,其主要好处在于组织性。我将某个类型实例能做的所有事情都一起放入impl块中,而不是让将来的用户在我们的代码中到处寻找Rectangle的功能。

->运算符到哪去了?

像在 C++ 这样的语言中,有两个不同的运算符来调用方法:.直接在对象上调用方法,而->在一个对象的指针上调用方法,这时需要先解引用(dereference)指针。换句话说,如果object是一个指针,那么object->something()就像(*object).something()一样。

Rust 并没有一个与->等效的运算符;相反,Rust 有一个叫自动引用和解引用automatic referencing and dereferencing)的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。

这是它如何工作的:当使用object.something()调用方法时,Rust 会自动增加&&mut*以便使object符合方法的签名。也就是说,这些代码是等同的:

# #[derive(Debug,Copy,Clone)]
# struct Point {
#     x: f64,
#     y: f64,
# }
#
# impl Point {
#    fn distance(&self, other: &Point) -> f64 {
#        let x_squared = f64::powi(other.x - self.x, 2);
#        let y_squared = f64::powi(other.y - self.y, 2);
#
#        f64::sqrt(x_squared + y_squared)
#    }
# }
# let p1 = Point { x: 0.0, y: 0.0 };
# let p2 = Point { x: 5.0, y: 6.5 };
p1.distance(&p2);
(&p1).distance(&p2);

第一行看起来简洁的多。这种自动引用的行为之所以能行得通是因为方法有一个明确的接收者————self的类型。在给出接收者和方法名的前提下,Rust 可以明确的计算出方法是仅仅读取(所以需要&self),做出修改(所以是&mut self)或者是获取所有权(所以是self)。Rust 这种使得借用对方法接收者来说是隐式的做法是其所有权系统程序员友好性实现的一大部分。

带有更多参数的方法

让我们更多的实践一下方法,通过为Rectangle结构体实现第二个方法。这回,我们让一个Rectangle的实例获取另一个Rectangle实例并返回self能否完全包含第二个长方形,如果能返回true若不能则返回false。当我们定义了can_hold方法,就可以运行列表 5-8 中的代码了:

Filename: src/main.rs

fn main() {
    let rect1 = Rectangle { length: 50, width: 30 };
    let rect2 = Rectangle { length: 40, width: 10 };
    let rect3 = Rectangle { length: 45, width: 60 };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

Listing 5-8: Demonstration of using the as-yet-unwritten can_hold method

我们希望看到如下输出,因为rect2的长宽都小于rect1,而rect3rect1要宽:

Can rect1 hold rect2? true
Can rect1 hold rect3? false

因为我们想定义一个方法,所以它应该位于impl Rectangle块中。方法名是can_hold,并且它会获取另一个Rectangle的不可变借用作为参数。通过观察调用点可以看出参数是什么类型的:rect1.can_hold(&rect2)传入了&rect2,它是一个Rectangle的实例rect2的不可变借用。这是可以理解的,因为我们只需要读取rect2(而不是写入,这意味着我们需要一个可变借用)而且希望main保持rect2的所有权这样就可以在调用这个方法后继续使用它。can_hold的返回值是一个布尔值,其实现会分别检查self的长宽是够都大于另一个Rectangle。让我们在列表 5-7 的impl块中增加这个新方法,如列表 5-9 所示:

Filename: src/main.rs

# #[derive(Debug)]
# struct Rectangle {
#     length: u32,
#     width: u32,
# }
#
impl Rectangle {
    fn area(&self) -> u32 {
        self.length * self.width
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.length > other.length && self.width > other.width
    }
}

Listing 5-9: Implementing the can_hold method on Rectangle that takes another Rectangle instance as an argument

如果结合列表 5-8 的main函数来运行,就会看到想要得到的输出!方法可以在self后增加多个参数,而且这些参数就像函数中的参数一样工作。

关联函数

impl块的另一个好用的功能是:允许在impl块中定义self作为参数的函数。这被称为关联函数associated functions),因为他们与结构体相关联。即便如此他们也是函数而不是方法,因为他们并不作用于一个结构体的实例。你已经使用过一个关联函数了:String::from

关联函数经常被用作返回一个结构体新实例的构造函数。例如我们可以提供一个关联函数,它接受一个维度参数并且用来作为长和宽,这样可以更轻松的创建一个正方形Rectangle而不必指定两次同样的值:

Filename: src/main.rs

# #[derive(Debug)]
# struct Rectangle {
#     length: u32,
#     width: u32,
# }
#
impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle { length: size, width: size }
    }
}

使用结构体名和::语法来调用这个关联函数:比如let sq = Rectangle::square(3);。这个方法位于结构体的命名空间中:::语法用于关联函数和模块创建的命名空间,第七章会讲到后者。

总结

结构体让我们可以在自己的范围内创建有意义的自定义类型。通过结构体,我们可以将相关联的数据片段联系起来并命名他们来使得代码更清晰。方法允许为结构体实例指定行为,而关联函数将特定功能置于结构体的命名空间中并且无需一个实例。

结构体并不是创建自定义类型的唯一方法;让我们转向 Rust 的enum功能并为自己的工具箱再填一个工具。

枚举和模式匹配

ch06-00-enums.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

本章介绍枚举,也被称作 enums。枚举允许你通过列举可能的值来定义一个类型。首先,我们会定义并使用一个枚举来展示它是如何连同数据一起编码信息的。接下来,我们会探索一个特别有用的枚举,叫做Option,它代表一个值要么是一些值要么什么都不是。然后会讲到match表达式中的模式匹配如何使对枚举不同的值运行不同的代码变得容易。最后会涉及到if let,另一个简洁方便处理代码中枚举的结构。

枚举是一个很多语言都有的功能,不过不同语言中的功能各不相同。Rust 的枚举与像F#、OCaml 和 Haskell这样的函数式编程语言中的代数数据类型algebraic data types)最为相似。

定义枚举

ch06-01-defining-an-enum.md
commit e6d6caab41471f7115a621029bd428a812c5260e

让我们通过一用代码来表现的场景,来看看为什么这里枚举是有用的而且比结构体更合适。比如我们要处理 IP 地址。目前被广泛使用的两个主要 IP 标准:IPv4(version four)和 IPv6(version six)。这是我们的程序只可能会遇到两种 IP 地址:所以可以枚举出所有可能的值,这也正是它名字的由来。

任何一个 IP 地址要么是 IPv4 的要么是 IPv6 的而不能两者都是。IP 地址的这个特性使得枚举数据结构非常适合这个场景,因为枚举值只可能是其中一个成员。IPv4 和 IPv6 从根本上讲仍是 IP 地址,所以当代码在处理申请任何类型的 IP 地址的场景时应该把他们当作相同的类型。

可以通过在代码中定义一个IpAddrKind枚举来表现这个概念并列出可能的 IP 地址类型,V4V6。这被称为枚举的成员variants):

enum IpAddrKind {
    V4,
    V6,
}

现在IpAddrKind就是一个可以在代码中使用的自定义类型了。

枚举值

可以像这样创建IpAddrKind两个不同成员的实例:

# enum IpAddrKind {
#     V4,
#     V6,
# }
#
let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

注意枚举的成员位于其标识符的命名空间中,并使用两个冒号分开。这么设计的益处是现在IpAddrKind::V4IpAddrKind::V6是相同类型的:IpAddrKind。例如,接着我们可以定义一个函数来获取IpAddrKind

# enum IpAddrKind {
#     V4,
#     V6,
# }
#
fn route(ip_type: IpAddrKind) { }

现在可以使用任意成员来调用这个函数:

# enum IpAddrKind {
#     V4,
#     V6,
# }
#
# fn route(ip_type: IpAddrKind) { }
#
route(IpAddrKind::V4);
route(IpAddrKind::V6);

使用枚举甚至还有更多优势。进一步考虑一下我们的 IP 地址类型,目前没有一个储存实际 IP 地址数据的方法;只知道它是什么类型的。考虑到已经在第五章学习过结构体了,你可以像列表 6-1 那样修改这个问题:

enum IpAddrKind {
    V4,
    V6,
}

struct IpAddr {
    kind: IpAddrKind,
    address: String,
}

let home = IpAddr {
    kind: IpAddrKind::V4,
    address: String::from("127.0.0.1"),
};

let loopback = IpAddr {
    kind: IpAddrKind::V6,
    address: String::from("::1"),
};

Listing 6-1: Storing the data and IpAddrKind variant of an IP address using a struct

这里我们定义了一个有两个字段的结构体IpAddrkind字段是IpAddrKind(之前定义的枚举)类型的而address字段是String类型的。这里有两个结构体的实例。第一个,home,它的kind的值是IpAddrKind::V4与之相关联的地址数据是127.0.0.1。第二个实例,loopbackkind的值是IpAddrKind的另一个成员,V6,关联的地址是::1。我们使用了要给结构体来将kindaddress打包在一起,现在枚举成员就与值相关联了。

我们可以使用一种更简洁的方式来表达相同的概念,仅仅使用枚举并将数据直接放进每一个枚举成员而不是将枚举作为结构体的一部分。IpAddr枚举的新定义表明了V4V6成员都关联了String值:

enum IpAddr {
    V4(String),
    V6(String),
}

let home = IpAddr::V4(String::from("127.0.0.1"));

let loopback = IpAddr::V6(String::from("::1"));

我们直接将数据附加到枚举的每个成员上,这样就不需要一个额外的结构体了。

使用枚举而不是结构体还有另外一个优势:每个成员可以处理不同类型和数量的数据。IPv4 版本的 IP 地址总是含有四个值在 0 和 255 之间的数字部分。如果我们想要将V4地址储存为四个u8值而V6地址仍然表现为一个String,这就不能使用结构体了。枚举可以轻易处理的这个情况:

enum IpAddr {
    V4(u8, u8, u8, u8),
    V6(String),
}

let home = IpAddr::V4(127, 0, 0, 1);

let loopback = IpAddr::V6(String::from("::1"));

这些代码展示了使用枚举来储存两种不同 IP 地址的几种可能的选择。然而,事实证明储存和编码 IP 地址实在是太常见了以致标准库提供了一个可供使用的定义!让我们看看标准库如何定义IpAddr的:它正有着跟我们定义和使用的一样的枚举和成员,不过它将成员中的地址数据嵌入到了两个不同形式的结构体中,他们对不同的成员的定义是不同的:

struct Ipv4Addr {
    // details elided
}

struct Ipv6Addr {
    // details elided
}

enum IpAddr {
    V4(Ipv4Addr),
    V6(Ipv6Addr),
}

这些代码展示了可以将任意类型的数据放入枚举成员中:例如字符串、数字类型或者结构体。甚至可以包含另一个枚举!另外,标准库中的类型通常并不比你设想出来的要复杂多少。

注意虽然标准库中包含一个IpAddr的定义,仍然可以创建和使用我们自己的定义而不会有冲突,因为我们并没有将标准库中的定义引入作用域。第七章会讲到如何导入类型。

来看看列表 6-2 中的另一个枚举的例子:它的成员中内嵌了多种多样的类型:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

Listing 6-2: A Message enum whose variants each store different amounts and types of values

这个枚举有四个含有不同类型的成员:

  • Quit没有关联任何数据。
  • Move包含一个匿名结构体
  • Write包含单独一个String
  • ChangeColor包含三个i32

定义一个像列表 6-2 中的枚举类似于定义不同类型的结构体,除了枚举不使用struct关键字而且所有成员都被组合在一起位于Message下之外。如下这些结构体可以包含与之前枚举成员中相同的数据:

struct QuitMessage; // unit struct
struct MoveMessage {
    x: i32,
    y: i32,
}
struct WriteMessage(String); // tuple struct
struct ChangeColorMessage(i32, i32, i32); // tuple struct

不过如果我们使用不同的结构体,他们都有不同的类型,将不能轻易的定义一个获取任何这些信息类型的函数,正如可以使用列表 6-2 中定义的Message枚举那样因为他们是一个类型的。

结构体和枚举还有另一个相似点:就像可以使用impl来为结构体定义方法那样,也可以在枚举上定义方法。这是一个定义于我们Message枚举上的叫做call的方法:

# enum Message {
#     Quit,
#     Move { x: i32, y: i32 },
#     Write(String),
#     ChangeColor(i32, i32, i32),
# }
#
impl Message {
    fn call(&self) {
        // method body would be defined here
    }
}

let m = Message::Write(String::from("hello"));
m.call();

方法体使用了self来获取调用方法的值。这个例子中,创建了一个拥有类型Message::Write("hello")的变量m,而且这就是当m.call()运行时call方法中的self的值。

让我们看看标准库中的另一个非常常见和实用的枚举:Option

Option枚举和其相对空值的优势

在之前的部分,我们看到了IpAddr枚举如何利用 Rust 的类型系统编码更多信息而不单单是程序中的数据。这一部分探索一个Option的案例分析,它是标准库定义的另一个枚举。Option类型应用广泛因为它编码了一个非常普遍的场景,就是一个值可能是某个值或者什么都不是。从类型系统的角度来表达这个概念就意味着编译器需要检查是否处理了所有应该处理的情况,这样就可以避免在其他编程语言中非常常见的 bug。

编程语言的设计经常从其包含功能的角度考虑问题,但是从其所没有的功能的角度思考也很重要。Rust 并没有很多其他语言中有的空值功能。空值Null )是一个值,它代表没有值。在有空值的语言中,变量总是这两种状态之一:空值和非空值。

在“Null References: The Billion Dollar Mistake”中,Tony Hoare,null 的发明者,曾经说到:

I call it my billion-dollar mistake. At that time, I was designing the first comprehensive type system for references in an object-oriented language. My goal was to ensure that all use of references should be absolutely safe, with checking performed automatically by the compiler. But I couldn't resist the temptation to put in a null reference, simply because it was so easy to implement. This has led to innumerable errors, vulnerabilities, and system crashes, which have probably caused a billion dollars of pain and damage in the last forty years.

我称之为我万亿美元的错误。当时,我在在一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的应有都应该是绝对安全的。不过我未能抗拒引入一个空引用的诱惑,仅仅是因为它是这么的容易实现。这引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数以万计美元的苦痛和伤害。

空值的问题在于当你尝试像一个非空值那样使用一个空值,会出现某种形式的错误。因为空和非空的属性是无处不在的,非常容易出现这类错误。

然而,空值尝试表达的概念仍然是有意义的:空值是一个因为某种原因目前无效或缺失的值。

问题不在于具体的概念而在于特定的实现。为此,Rust 并没有空值,不过它确实拥有一个可以编码存在或不存在概念的枚举。这个枚举是Option<T>,而且它定义于标准库中,如下:

enum Option<T> {
    Some(T),
    None,
}

Option<T>是如此有用以至于它甚至被包含在了 prelude 之中:不需要显式导入它。另外,它的成员也是如此:可以不需要Option::前缀来直接使用SomeNone。即便如此Option<T>也仍是常规的枚举,Some(T)None仍是Option<T>的成员。

<T>语法是一个我们还未讲到的 Rust 功能。它是一个泛型类型参数,第十章会更详细的讲解泛型。目前,所有你需要知道的就是<T>意味着Option枚举的Some成员可以包含任意类型的数据。这里是一些包含数字类型和字符串类型Option值的例子:

let some_number = Some(5);
let some_string = Some("a string");

let absent_number: Option<i32> = None;

如果使用None而不是Some,需要告诉 Rust Option<T>是什么类型的,因为编译器只通过None值无法推断出Some成员的类型。

当有一个Some值时,我们就知道存在一个值,而这个值保存在Some中。当有个None值时,在某种意义上它跟空值是相同的意义:并没有一个有效的值。那么,Option<T>为什么就比空值要好呢?

简而言之,因为Option<T>T(这里T可以是任何类型)是不同的类型,编译器不允许像一个被定义的有效的类型那样使用Option<T>。例如,这些代码不能编译,因为它尝试将Option<i8>i8相比:

let x: i8 = 5;
let y: Option<i8> = Some(5);

let sum = x + y;

如果运行这些代码,将得到类似这样的错误信息:

error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
 -->
  |
7 | let sum = x + y;
  |           ^^^^^
  |

哇哦!事实上,错误信息意味着 Rust 不知道该如何将Option<i8>i8相加。当在 Rust 中拥有一个像i8这样类型的值时,编译器确保它总是有一个有效的值。我们可以自信使用而无需判空。只有当使用Option<i8>(或者任何用到的类型)是需要担心可能没有一个值,而编译器会确保我们在使用值之前处理为空的情况。

换句话说,在对Option<T>进行T的运算之前必须转为T。通常这能帮助我们捕获空值最常见的问题之一:假设某值不为空但实际上为空的情况。

无需担心错过存在非空值的假设让我们对代码更加有信心,为了拥有一个可能为空的值,必须显式的将其放入对应类型的Option<T>中。接着,当使用这个值时,必须明确的处理值为空的情况。任何地方一个值不是Option<T>类型的话,可以安全的假设它的值不为空。这是 Rust 的一个有意为之的设计选择,来限制空值的泛滥和增加 Rust 代码的安全性。

那么当有一个Option<T>的值时,如何从Some成员中取出T的值来使用它呢?Option<T>枚举拥有大量用于各种情况的方法:你可以查看相关代码。熟悉Option<T>的方法将对你的 Rust 之旅提供巨大的帮助。

总的来说,为了使用Option<T>值,需要编写处理每个成员的代码。我们想要一些代码只当拥有Some(T)值时运行,这些代码允许使用其中的T。也希望一些代码当在None值时运行,这些代码并没有一个可用的T值。match表达式就是这么一个处理枚举的控制流结构:它会根据枚举的成员运行不同的代码,这些代码可以使用匹配到的值中的数据。

match控制流运算符

ch06-02-match.md
commit 64090418c23d615facfe49a8d548ad9baea6b097

Rust 有一个叫做match的极为强大的控制流运算符,它允许我们将一个值与一系列的模式相比较并根据匹配的模式执行代码。模式可由字面值、变量、通配符和许多其他内容构成;第十八章会涉及到所有不同种类的模式以及他们的作用。match的力量来源于模式的表现力以及编译器检查,它确保了所有可能的情况都得到处理。

match表达式想象成某种硬币分类器:硬币滑入有着不同大小孔洞的轨道,每一个硬币都会掉入符合它大小的孔洞。同样地,值也会检查match的每一个模式,并且在遇到第一个“符合”的模式时,值会进入相关联的代码块并在执行中被使用。

因为刚刚提到了硬币,让我们用他们来作为一个使用match的例子!我们可以编写一个函数来获取一个未知的(美国)硬币,并以一种类似验钞机的方式,确定它是何种硬币并返回它的美分值,如列表 6-3 中所示:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> i32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

Listing 6-3: An enum and a match expression that has the variants of the enum as its patterns.

拆开value_in_cents函数中的match来看。首先,我们列出match关键字后跟一个表达式,在这个例子中是coin的值。这看起来非常像if使用的表达式,不过这里有一个非常大的区别:对于if,表达式必须返回一个布尔值。而这里它可以是任何类型的。例子中的coin的类型是列表 6-3 中定义的Coin枚举。

接下来是match的分支。一个分支有两个部分:一个模式和一些代码。第一个分支的模式是值Coin::Penny而之后的=>运算符将模式和将要运行的代码分开。这里的代码就仅仅是值1。每一个分支之间使用逗号分隔。

match表达式执行时,它将结果值按顺序与每一个分支的模式相比较,如果模式匹配了这个值,这个模式相关联的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支,非常像一个硬币分类器。可以拥有任意多的分支:列表 6-3 中的match有四个分支。

每个分支相关联的代码是一个表达式,而表达式的结果值将作为整个match表达式的返回值。

如果分支代码较短的话通常不使用大括号,正如列表 6-3 中的每个分支都只是返回一个值。如果想要在分支中运行多行代码,可以使用大括号。例如,如下代码在每次使用Coin::Penny调用时都会打印出“Lucky penny!”,同时仍然返回代码块最后的值,1

# enum Coin {
#    Penny,
#    Nickel,
#    Dime,
#    Quarter,
# }
#
fn value_in_cents(coin: Coin) -> i32 {
    match coin {
        Coin::Penny => {
            println!("Lucky penny!");
            1
        },
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

绑定值的模式

匹配分支的另一个有用的功能是可以绑定匹配的模式的部分值。这也就是如何从枚举成员中提取值。

作为一个例子,让我们修改枚举的一个成员来存放数据。1999 年到 2008 年间,美帝在 25 美分的硬币的一侧为 50 个州的每一个都印刷了不同的设计。其他的硬币都没有这种区分州的设计,所以只有这些 25 美分硬币有特殊的价值。可以将这些信息加入我们的enum,通过改变Quarter成员来包含一个State值,列表 6-4 中完成了这些修改:

#[derive(Debug)] // So we can inspect the state in a minute
enum UsState {
    Alabama,
    Alaska,
    // ... etc
}

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState),
}

Listing 6-4: A Coin enum where the Quarter variant also holds a UsState value

想象一下我们的一个朋友尝试收集所有 50 个州的 25 美分硬币。在根据硬币类型分类零钱的同时,也可以报告出每个 25 美分硬币所对应的州名称,这样如果我们的朋友没有的话,他可以把它加入收藏。

在这些代码的匹配表达式中,我们在匹配Coin::Quarter成员的分支的模式中增加了一个叫做state的变量。当匹配到Coin::Quarter时,变量state将会绑定 25 美分硬币所对应州的值。接着在那个分支的代码中使用state,如下:

# #[derive(Debug)]
# enum UsState {
#    Alabama,
#    Alaska,
# }
#
# enum Coin {
#    Penny,
#    Nickel,
#    Dime,
#    Quarter(UsState),
# }
#
fn value_in_cents(coin: Coin) -> i32 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => {
            println!("State quarter from {:?}!", state);
            25
        },
    }
}

如果调用value_in_cents(Coin::Quarter(UsState::Alaska))coin将是Coin::Quarter(UsState::Alaska)。当将值与每个分支相比较时,没有分支会匹配直到遇到Coin::Quarter(state)。这时,state绑定的将会是值UsState::Alaska。接着就可以在println!表达式中使用这个绑定了,像这样就可以获取Coin枚举的Quarter成员中内部的州的值。

匹配Option<T>

在之前的部分在使用Option<T>时我们想要从Some中取出其内部的T值;也可以像处理Coin枚举那样使用match处理Option<T>!与其直接比较硬币,我们将比较Option<T>的成员,不过match表达式的工作方式保持不变。

比如我们想要编写一个函数,它获取一个Option<i32>并且如果其中有一个值,将其加一。如果其中没有值,函数应该返回None值并不尝试执行任何操作。

编写这个函数非常简单,得益于match,它将看起来像列表 6-5 中这样:

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);

Listing 6-5: A function that uses a match expression on an Option<i32>

匹配Some(T)

更仔细的检查plus_one的第一行操作。当调用plus_one(five)时,plus_one函数体中的x将会是值Some(5)。接着将其与每个分支比较。

None => None,

Some(5)并不匹配模式None,所以继续进行下一个分支。

Some(i) => Some(i + 1),

Some(5)Some(i)匹配吗?为什么不呢!他们是相同的成员。i绑定了Some中包含的值,所以i的值是5。接着匹配分支的代码被执行,所以我们将i的值加一并返回一个含有值6的新Some

匹配None

接着考虑下列表 6-5 中plus_one的第二个调用,这里xNone。我们进入match并与第一个分支相比较。

None => None,

匹配上了!这里没有值来加一,所以程序结束并返回=>右侧的值None,因为第一个分支就匹配到了,其他的分支将不再比较。

match与枚举相结合在很多场景中都是有用的。你会在 Rust 代码中看到很多这样的模式:match一个枚举,绑定其中的值到一个变量,接着根据其值执行代码。这在一开始有点复杂,不过一旦习惯了,你会希望所有语言都拥有它!这一直是用户的最爱。

匹配是穷尽的

match还有另一方面需要讨论。考虑一下plus_one函数的这个版本:

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        Some(i) => Some(i + 1),
    }
}

我们没有处理None的情况,所以这些代码会造成一个 bug。幸运的是,这是一个 Rust 知道如何处理的 bug。如果尝试编译这段代码,会得到这个错误:

error[E0004]: non-exhaustive patterns: `None` not covered
 -->
  |
6 |         match x {
  |               ^ pattern `None` not covered

Rust 知道我们没有覆盖所有可能的情况甚至知道那些模式被忘记了!Rust 中的匹配是穷尽的(*exhaustive):必须穷举到最后的可能性来使代码有效。特别的在这个Option<T>的例子中,Rust 防止我们忘记明确的处理None的情况,这使我们免于假设拥有一个实际上为空的值,这造成了之前提到过的价值亿万的错误。

_通配符

Rust 也提供了一个模式用于不想列举出所有可能值的场景。例如,u8可以拥有 0 到 255 的有效的值,如果我们只关心 1、3、5 和 7 这几个值,就并不想必须列出 0、2、4、6、8、9 一直到 255 的值。所幸我们不必这么做:可以使用特殊的模式_替代:

let some_u8_value = 0u8;
match some_u8_value {
    1 => println!("one"),
    3 => println!("three"),
    5 => println!("five"),
    7 => println!("seven"),
    _ => (),
}

_模式会匹配所有的值。通过将其放置于其他分支之后,_将会匹配所有之前没有指定的可能的值。()就是 unit 值,所以_的情况什么也不会发生。因此,可以说我们想要对_通配符之前没有列出的所有可能的值不做任何处理。

然而,match在只关心一个情况的场景中可能就有点啰嗦了。为此 Rust 提供了if let

if let简单控制流

ch06-03-if-let.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

if let语法让我们以一种不那么冗长的方式结合iflet,来处理匹配一个模式的值而忽略其他的值。考虑列表 6-6 中的程序,它匹配一个Option<u8>值并只希望当值是三时执行代码:

let some_u8_value = Some(0u8);
match some_u8_value {
    Some(3) => println!("three"),
    _ => (),
}

Listing 6-6: A match that only cares about executing code when the value is Some(3)

我们想要对Some(3)匹配进行操作不过不想处理任何其他Some<u8>值或None值。为了满足match表达式(穷尽性)的要求,必须在处理完这唯一的成员后加上_ => (),这样也要增加很多样板代码。

不过我们可以使用if let这种更短的方式编写。如下代码与列表 6-6 中的match行为一致:

# let some_u8_value = Some(0u8);
if let Some(3) = some_u8_value {
    println!("three");
}

if let获取通过=分隔的一个模式和一个表达式。它的工作方式与match相同,这里的表达式对应match而模式则对应第一个分支。

使用if let意味着编写更少代码,更少的缩进和更少的样板代码。然而,这样会失去match强制要求的穷尽性检查。matchif let之间的选择依赖特定的环境以及增加简洁度和失去穷尽性检查的权衡取舍。

换句话说,可以认为if letmatch的一个语法糖,它当值匹配某一模式时执行代码而忽略所有其他值。

可以在if let中包含一个elseelse块中的代码与match表达式中的_分支块中的代码相同,这样的match表达式就等同于if letelse。回忆一下列表 6-4 中Coin枚举的定义,它的Quarter成员包含一个UsState值。如果想要计数所有不是 25 美分的硬币的同时也报告 25 美分硬币所属的州,可以使用这样一个match表达式:

# #[derive(Debug)]
# enum UsState {
#    Alabama,
#    Alaska,
# }
#
# enum Coin {
#    Penny,
#    Nickel,
#    Dime,
#    Quarter(UsState),
# }
# let coin = Coin::Penny;
let mut count = 0;
match coin {
    Coin::Quarter(state) => println!("State quarter from {:?}!", state),
    _ => count += 1,
}

或者可以使用这样的if letelse表达式:

# #[derive(Debug)]
# enum UsState {
#    Alabama,
#    Alaska,
# }
#
# enum Coin {
#    Penny,
#    Nickel,
#    Dime,
#    Quarter(UsState),
# }
# let coin = Coin::Penny;
let mut count = 0;
if let Coin::Quarter(state) = coin {
    println!("State quarter from {:?}!", state);
} else {
    count += 1;
}

如果你的程序遇到一个使用match表达起来过于啰嗦的逻辑,记住if let也在你的 Rust 工具箱中。

总结

现在我们涉及到了如何使用枚举来创建有一系列可列举值的自定义类型。我们也展示了标准库的Option<T>类型是如何帮助你利用类型系统来避免出错。当枚举值包含数据时,你可以根据需要处理多少情况来选择使用matchif let来获取并使用这些值。

你的 Rust 程序现在能够使用结构体和枚举在自己的作用域内表现其内容了。在你的 API 中使用自定义类型保证了类型安全:编译器会确保你的函数只会得到它期望的类型的值。

为了向你的用户提供一个组织良好的 API,它使用起来很直观并且只向用户暴露他们确实需要的部分,那么现在就让我们转向 Rust 的模块系统吧。

模块

ch07-00-modules.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

在你刚开始编写 Rust 程序时,代码可能仅仅位于main函数里。随着代码数量的增长,最终你会将功能移动到其他函数中,为了复用也为了更好的组织。通过将代码分隔成更小的块,每一个块代码自身就更易于理解。不过当你发现自己有太多的函数了该怎么办呢?Rust 有一个模块系统来处理编写可复用代码同时保持代码组织度的问题。

就跟你将代码行提取到一个函数中一样,也可以将函数(和其他类似结构体和枚举的代码)提取到不同模块中。模块module)是一个包含函数或类型定义的命名空间,你可以选择这些定义是能(公有)还是不能(私有)在其模块外可见。这是一个模块如何工作的概括:

  • 使用mod关键字声明模块
  • 默认所有内容都是私有的(包括模块自身)。可以使用pub关键字将其变成公有并在其命名空间外可见。
  • use关键字允许引入模块、或模块中的定义到作用域中以便于引用他们。

我们会逐一了解这每一部分并学习如何将他们结合在一起。

mod和文件系统

ch07-01-mod-and-the-filesystem.md
commit b0481ac44ff2594c6c240baa36357737739db445

我们将通过使用 Cargo 创建一个新项目来开始我们的模块之旅,不过不再创建一个二进制 crate,而是创建一个库 crate:一个其他人可以作为依赖导入的项目。第二章我们见过的rand就是这样的 crate。

我们将创建一个提供一些通用网络功能的项目的骨架结构;我们将专注于模块和函数的组织,而不担心函数体中的具体代码。这个项目叫做communicator。Cargo 默认会创建一个库 crate 除非指定其他项目类型,所以如果不像一直以来那样加入--bin参数则项目将会是一个库:

$ cargo new communicator
$ cd communicator

注意 Cargo 生成了 src/lib.rs 而不是 src/main.rs。在 src/lib.rs 中我们会找到这些:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
    }
}

Cargo 创建了一个空的测试来帮助我们开始库项目,不像使用--bin参数那样创建一个“Hello, world!”二进制项目。稍后一点会介绍#[]mod tests语法,目前只需确保他们位于 src/lib.rs 中。

因为没有 src/main.rs 文件,所以没有可供 Cargo 的cargo run执行的东西。因此,我们将使用cargo build命令只是编译库 crate 的代码。

我们将学习根据编写代码的意图来选择不同的织库项目代码组织来适应多种场景。

模块定义

对于communicator网络库,首先要定义一个叫做network的模块,它包含一个叫做connect的函数定义。Rust 中所有模块的定义以关键字mod开始。在 src/lib.rs 文件的开头在测试代码的上面增加这些代码:

Filename: src/lib.rs

mod network {
    fn connect() {
    }
}

mod关键字的后面是模块的名字,network,接着是位于大括号中的代码块。代码块中的一切都位于network命名空间中。在这个例子中,只有一个函数,connect。如果想要在network模块外面的代码中调用这个函数,需要指定模块名并使用命名空间语法::,像这样:network::connect(),而不是只是connect()

也可以在 src/lib.rs 文件中同时存在多个模块。例如,再拥有一个client模块,它也有一个叫做connect的函数,如列表 7-1 中所示那样增加这个模块:

Filename: src/lib.rs

mod network {
    fn connect() {
    }
}

mod client {
    fn connect() {
    }
}

Listing 7-1: The network module and the client module defined side-by-side in src/lib.rs

现在我们有了network::connect函数和client::connect函数。他们可能有着完全不同的功能,同时他们也不会彼此冲突,因为他们位于不同的模块。

虽然在这个例子中,我们构建了一个库,但是 src/lib.rs 并没有什么特殊意义。也可以在 src/main.rs 中使用子模块。事实上,也可以将模块放入其他模块中。这有助于随着模块的增长,将相关的功能组织在一起并又保持各自独立。如何选择组织代码依赖于如何考虑代码不同部分之间的关系。例如,对于库的用户来说,client模块和它的函数connect可能放在network命名空间里显得更有道理,如列表 7-2 所示:

Filename: src/lib.rs

mod network {
    fn connect() {
    }

    mod client {
        fn connect() {
        }
    }
}

Listing 7-2: Moving the client module inside of the network module

src/lib.rs 文件中,将现有的mod networkmod client的定义替换为client模块作为network的一个内部模块。现在我们有了network::connectnetwork::client::connect函数:又一次,这两个connect函数也不相冲突,因为他们在不同的命名空间中。

这样,模块之间形成了一个层次结构。src/lib.rs 的内容位于最顶层,而其子模块位于较低的层次。这是列表 7-1 中的例子以这种方式考虑的组织结构:

communicator
 ├── network
 └── client

而这是列表 7-2 中例子的的结构:

communicator
 └── network
     └── client

可以看到列表 7-2 中,clientnetwork的子模块,而不是它的同级模块。更为负责的项目可以有很多的模块,所以他们需要符合逻辑地组合在一起以便记录他们。在项目中“符合逻辑”的意义全凭你得理解和库的用户对你项目领域的认识。利用我们这里讲到的技术来创建同级模块和嵌套的模块将是你会喜欢的结构。

将模块移动到其他文件

位于层级结构中的模块,非常类似计算机领域的另一个我们非常熟悉的结构:文件系统!我们可以利用 Rust 的模块系统连同多个文件一起分解 Rust 项目,这样就不是所有的内容都落到 src/lib.rs 中了。作为例子,我们将从列表 7-3 中的代码开始:

Filename: src/lib.rs

mod client {
    fn connect() {
    }
}

mod network {
    fn connect() {
    }

    mod server {
        fn connect() {
        }
    }
}

Listing 7-3: Three modules, client, network, and network::server, all defined in src/lib.rs

这是模块层次结构:

communicator
 ├── client
 └── network
     └── server

如果这些模块有很多函数,而这些函数又很长,将难以在文件中寻找我们需要的代码。因为这些函数被嵌套进一个或多个模块中,同时函数中的代码也会开始变长。这就有充分的理由将clientnetworkserver每一个模块从 src/lib.rs 抽出并放入他们自己的文件中。

让我们开始把client模块提取到另一个文件中。首先,将 src/lib.rs 中的client模块代码替换为如下:

Filename: src/lib.rs

mod client;

mod network {
    fn connect() {
    }

    mod server {
        fn connect() {
        }
    }
}

这里我们仍然定义client模块,不过去掉了大括号和client模块中的定义并替换为一个分号,这使得 Rust 知道去其他地方寻找模块中定义的代码。

那么现在需要创建对应模块名的外部文件。在 src/ 目录创建一个 client.rs 文件,接着打开它并输入如下内容,它是上一步client模块中被去掉的connect函数:

Filename: src/client.rs

fn connect() {
}

注意这个文件中并不需要一个mod声明;因为已经在 src/lib.rs 中已经使用mod声明了client模块。这个文件仅仅提供client模块的内容。如果在这里加上一个mod client,那么就等于给client模块增加了一个叫做client的子模块了!

Rust 默认只知道 src/lib.rs 中的内容。如果想要对项目加入更多文件,我们需要在 src/lib.rs 中告诉 Rust 去寻找其他文件;这就是为什么mod client需要被定义在 src/lib.rs 而不是在 src/client.rs

现在,一切应该能成功编译,虽然会有一些警告。记住使用cargo build而不是cargo run因为这是一个库 crate 而不是二进制 crate:

$ cargo build
   Compiling communicator v0.1.0 (file:///projects/communicator)

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/client.rs:1:1
  |
1 | fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/lib.rs:4:5
  |
4 |     fn connect() {
  |     ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/lib.rs:8:9
  |
8 |         fn connect() {
  |         ^

这些警告提醒我们有从未被使用的函数。目前不用担心这些警告;在本章的后面会解决他们。好消息是,他们仅仅是警告;我们的项目能够被成功编译。

下面使用相同的模式将network模块提取到它自己的文件中。删除 src/lib.rsnetwork模块的内容并在声明后加上一个分号,像这样:

Filename: src/lib.rs

mod client;

mod network;

接着新建 src/network.rs 文件并输入如下内容:

Filename: src/network.rs

fn connect() {
}

mod server {
    fn connect() {
    }
}

注意这个模块文件中我们也使用了一个mod声明;这是因为我们希望server成为network的一个子模块。

现在再次运行cargo build。成功!不过我们还需要再提取出另一个模块:server。因为这是一个子模块——也就是模块中的模块——目前的将模块提取到对应名字的文件中的策略就不管用了。如果我们仍这么尝试则会出现错误。对 src/network.rs 的第一个修改是用mod server;替换server模块的内容:

Filename: src/network.rs

fn connect() {
}

mod server;

接着创建 src/server.rs 文件并输入需要提取的server模块的内容:

Filename: src/server.rs

fn connect() {
}

当尝试运行cargo build时,会出现如列表 7-4 中所示的错误:

$ cargo build
   Compiling communicator v0.1.0 (file:///projects/communicator)
error: cannot declare a new module at this location
 --> src/network.rs:4:5
  |
4 | mod server;
  |     ^^^^^^
  |
note: maybe move this module `network` to its own directory via `network/mod.rs`
 --> src/network.rs:4:5
  |
4 | mod server;
  |     ^^^^^^
note: ... or maybe `use` the module `server` instead of possibly redeclaring it
 --> src/network.rs:4:5
  |
4 | mod server;
  |     ^^^^^^

Listing 7-4: Error when trying to extract the server submodule into src/server.rs

这个错误说明“不能在这个位置新声明一个模块”并指出 src/network.rs 中的mod server;这一行。看来 src/network.rssrc/lib.rs 在某些方面是不同的;让我们继续阅读以理解这是为什么。

列表 7-4 中间的记录事实上是非常有帮助的,因为它指出了一些我们还未讲到的操作:

note: maybe move this module `network` to its own directory via `network/mod.rs`

我们可以按照记录所建议的去操作,而不是继续使用之前的与模块同名的文件的模式:

  1. 新建一个叫做 network目录,这是父模块的名字
  2. src/network.rs 移动到新建的 network 目录中并重命名,现在它是 src/network/mod.rs
  3. 将子模块文件 src/server.rs 移动到 network 目录中

如下是执行这些步骤的命令:

$ mkdir src/network
$ mv src/network.rs src/network/mod.rs
$ mv src/server.rs src/network

现在如果运行cargo build的话将顺利编译(虽然仍有警告)。现在模块的布局看起来仍然与列表 7-3 中所有代码都在 src/lib.rs 中时完全一样:

communicator
 ├── client
 └── network
     └── server

对应的文件布局现在看起来像这样:

├── src
│   ├── client.rs
│   ├── lib.rs
│   └── network
│       ├── mod.rs
│       └── server.rs

那么,当我们想要提取network::server模块时,为什么也必须将 src/network.rs 文件改名成 src/network/mod.rs 文件呢,还有为什么要将network::server的代码放入 network 目录的 src/network/server.rs 文件中,而不能将network::server模块提取到 src/server.rs 中呢?原因是如果 server.rs 文件在 src 目录中那么 Rust 就不能知道server应当是network的子模块。为了更清楚得说明为什么 Rust 不知道,让我们考虑一下有着如下层级的另一个例子,它的所有定义都位于 src/lib.rs 中:

communicator
 ├── client
 └── network
     └── client

在这个例子中,仍然有这三个模块,clientnetworknetwork::client。如果按照与上面最开始将模块提取到文件中相同的步骤来操作,对于client模块会创建 src/client.rs。对于network模块,会创建 src/network.rs。但是接下来不能将network::client模块提取到 src/client.rs 文件中,因为它已经存在了,对应顶层的client模块!如果将clientnetwork::client的代码都放入 src/client.rs 文件,Rust 将无从可知这些代码是属于client还是network::client的。

因此,一旦想要将network模块的子模块network::client提取到一个文件中,需要为network模块新建一个目录替代 src/network.rs 文件。接着network模块的代码将进入 src/network/mod.rs 文件,而子模块network::client将拥有其自己的文件 src/network/client.rs。现在顶层的 src/client.rs 中的代码毫无疑问的都属于client模块。

模块文件系统的规则

与文件系统相关的模块规则总结如下:

  • 如果一个叫做foo的模块没有子模块,应该将foo的声明放入叫做 foo.rs 的文件中。
  • 如果一个叫做foo的模块有子模块,应该将foo的声明放入叫做 foo/mod.rs 的文件中。

这些规则适用于递归(嵌套),所以如果foo模块有一个子模块barbar没有子模块,则 src 目录中应该有如下文件:

├── foo
│   ├── bar.rs (contains the declarations in `foo::bar`)
│   └── mod.rs (contains the declarations in `foo`, including `mod bar`)

模块自身则应该使用mod关键字定义于父模块的文件中。

接下来,我们讨论一下pub关键字,并除掉那些警告!

使用pub控制可见性

ch07-02-controlling-visibility-with-pub.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

我们通过将networknetwork::server的代码分别移动到 src/network/mod.rssrc/network/server.rs 文件中解决了列表 7-4 中出现的错误信息。现在,cargo build能够构建我们的项目,不过仍然有一些警告信息,表示client::connectnetwork::connectnetwork::server::connect函数没有被使用:

warning: function is never used: `connect`, #[warn(dead_code)] on by default
src/client.rs:1:1
  |
1 | fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/mod.rs:1:1
  |
1 | fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

那么为什么会出现这些错误信息呢?我们构建的是一个库,它的函数的目的是被用户使用,而不一定要被项目自身使用,所以不应该担心这些connect函数是未使用的。创建他们的意义就在于被另一个项目而不是被自己使用。

为了理解为什么这个程序出现了这些警告,尝试作为另一个项目来使用这个connect库,从外部调用他们。为此,通过创建一个包含这些代码的 src/main.rs 文件,在与库 crate 相同的目录创建一个二进制 crate:

Filename: src/main.rs

extern crate communicator;

fn main() {
    communicator::client::connect();
}

使用extern crate指令将communicator库 crate 引入到作用域,因为事实上我们的包包含两个 crate。Cargo 认为 src/main.rs 是一个二进制 crate 的根文件,与现存的以 src/lib.rs 为根文件的库 crate 相区分。这个模式在可执行项目中非常常见:大部分功能位于库 crate 中,而二进制 crate 使用这个库 crate。通过这种方式,其他程序也可以使用这个库 crate,这是一个很好的关注分离(separation of concerns)。

从一个外部 crate 的视角观察communicator库的内部,我们创建的所有模块都位于一个与 crate 同名的模块内部,communicator。这个顶层的模块被称为 crate 的根模块root module)。

另外注意到即便在项目的子模块中使用外部 crate,extern crate也应该位于根模块(也就是 src/main.rssrc/lib.rs)。接着,在子模块中,我们就可以像顶层模块那样引用外部 crate 中的项了。

我们的二进制 crate 如今正好调用了库中client模块的connect函数。然而,执行cargo build会在之前的警告之后出现一个错误:

error: module `client` is private
 --> src/main.rs:4:5
  |
4 |     communicator::client::connect();
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

啊哈!这告诉了我们client模块是私有的,这也正是那些警告的症结所在。这也是我们第一次在 Rust 上下文中涉及到公有私有的概念。Rust 所有代码的默认状态是私有的:除了自己之外别人不允许使用这些代码。如果不在自己的项目中使用一个私有函数,因为程序自身是唯一允许使用这个函数的代码,Rust 会警告说函数未被使用。

一旦我们指定一个像client::connect的函数为公有,不光二进制 crate 中的函数调用是允许的,函数未被使用的警告也会消失。将其标记为公有让 Rust 知道了我们意在使函数在我们程序的外部被使用。现在这个可能的理论上的外部可用性使得 Rust 认为这个函数“已经被使用”。因此。当某项被标记为公有,Rust 不再要求它在程序自身被使用并停止警告某项未被使用。

标记函数为公有

为了告诉 Rust 某项为公有,在想要标记为公有的项的声明开头加上pub关键字。现在我们将致力于修复client::connect未被使用的警告,以及二进制 crate 中“模块client是私有的”的错误。像这样修改 src/lib.rs 使client模块公有:

Filename: src/lib.rs

pub mod client;

mod network;

pub写在mod之前。再次尝试构建:

<warnings>
error: function `connect` is private
 --> src/main.rs:4:5
  |
4 |     communicator::client::connect();
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

非常好!另一个不同的错误!好的,不同的错误信息是值得庆祝的(可能是程序员被黑的最惨的一次)。新错误表明“函数connect是私有的”,那么让我们修改 src/client.rsclient::connect也设为公有:

Filename: src/client.rs

pub fn connect() {
}

再再一次运行cargo build

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/mod.rs:1:1
  |
1 | fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

编译通过了,关于client::connect未被使用的警告消失了!

未被使用的代码并不总是意味着他们需要被设为公有的:如果你希望这些函数成为公有 API 的一部分,未被使用的代码警告可能是在警告你这些代码不再需要并可以安全的删除他们。这也可能是警告你出 bug,如果你刚刚不小心删除了库中所有这个函数的调用。

当然我们的情况是,确实希望另外两个函数也作为 crate 公有 API 的一部分,所以让我们也将其标记为pub并去掉剩余的警告。修改 src/network/mod.rs 为:

Filename: src/network/mod.rs

pub fn connect() {
}

mod server;

并编译:

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/mod.rs:1:1
  |
1 | pub fn connect() {
  | ^

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

恩,虽然将network::connect设为pub了我们仍然得到了一个未被使用函数的警告。这是因为模块中的函数是公有的,不过函数所在的network模块却不是公有的。这回我们是自内向外修改库文件的,而client::connect的时候是自外向内修改的。我们需要修改 src/lib.rsnetwork 也是公有的:

Filename: src/lib.rs

pub mod client;

pub mod network;

现在再编译的话,那个警告就消失了:

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

只剩一个警告了!尝试自食其力修改它吧!

私有性规则

总的来说,有如下项的可见性规则:

  1. 如果一个项是公有的,它能被任何父模块访问
  2. 如果一个项是私有的,它只能被当前模块或其子模块访问

私有性示例

让我们看看更多例子作为练习。创建一个新的库项目并在新项目的 src/lib.rs 输入列表 7-5 中的代码:

Filename: src/lib.rs

mod outermost {
    pub fn middle_function() {}

    fn middle_secret_function() {}

    mod inside {
        pub fn inner_function() {}

        fn secret_function() {}
    }
}

fn try_me() {
    outermost::middle_function();
    outermost::middle_secret_function();
    outermost::inside::inner_function();
    outermost::inside::secret_function();
}

Listing 7-5: Examples of private and public functions, some of which are incorrect

在尝试编译这些代码之前,猜测一下try_me函数的哪一行会出错。接着编译项目来看看是否猜对了,然后继续阅读后面关于错误的讨论!

检查错误

try_me函数位于项目的根模块。叫做outermost的模块是私有的,不过第二条私有性规则说明try_me函数允许访问outermost模块,因为outermost位于当前(根)模块,try_me也是。

outermost::middle_function的调用是正确的。因为middle_function是公有的,而try_me通过其父模块访问middle_functionoutermost。根据上一段的规则我们可以确定这个模块是可访问的。

outermost::middle_secret_function的调用会造成一个编译错误。middle_secret_function是私有的,所以第二条(私有性)规则生效了。根模块既不是middle_secret_function的当前模块(outermost是),也不是middle_secret_function当前模块的子模块。

叫做inside的模块是私有的且没有子模块,所以它只能被当前模块outermost访问。这意味着try_me函数不允许调用outermost::inside::inner_functionoutermost::inside::secret_function任何一个。

修改错误

这里有一些尝试修复错误的代码修改意见。在你尝试他们之前,猜测一下他们哪个能修复错误,接着编译查看你是否猜对了,并结合私有性规则理解为什么。

  • 如果inside模块是公有的?
  • 如果outermost是公有的而inside是私有的?
  • 如果在inner_function函数体中调用::outermost::middle_secret_function()?(开头的两个冒号意味着从根模块开始引用模块。)

请随意设计更多的实验并尝试理解他们!

接下来,让我们讨论一下使用use关键字将模块项目引入作用域。

导入命名

ch07-03-importing-names-with-use.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

我们已经讲到了如何使用模块名称作为调用的一部分,来调用模块中的函数,如列表 7-6 中所示的nested_modules函数调用。

Filename: src/main.rs

pub mod a {
    pub mod series {
        pub mod of {
            pub fn nested_modules() {}
        }
    }
}

fn main() {
    a::series::of::nested_modules();
}

Listing 7-6: Calling a function by fully specifying its enclosing module’s namespaces

如你所见,指定函数的完全限定名称可能会非常冗长。所幸 Rust 有一个关键字使得这些调用显得更简洁。

使用use的简单导入

Rust 的use关键字的工作是缩短冗长的函数调用,通过将想要调用的函数所在的模块引入到作用域中。这是一个将a::series::of模块导入一个二进制 crate 的根作用域的例子:

Filename: src/main.rs

pub mod a {
    pub mod series {
        pub mod of {
            pub fn nested_modules() {}
        }
    }
}

use a::series::of;

fn main() {
    of::nested_modules();
}

use a::series::of;这一行的意思是每当想要引用of模块时,不用使用完整的a::series::of路径,可以直接使用of

use关键字只将指定的模块引入作用域;它并不会将其子模块也引入。这就是为什么想要调用nested_modules函数时仍然必须写成of::nested_modules

也可以将函数本身引入到作用域中,通过如下在use中指定函数的方式:

pub mod a {
    pub mod series {
        pub mod of {
            pub fn nested_modules() {}
        }
    }
}

use a::series::of::nested_modules;

fn main() {
    nested_modules();
}

这使得我们可以忽略所有的模块并直接引用函数。

因为枚举也像模块一样组成了某种命名空间,也可以使用use来导入枚举的成员。对于任何类型的use语句,如果从一个命名空间导入多个项,可以使用大括号和逗号来列举他们,像这样:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

use TrafficLight::{Red, Yellow};

fn main() {
    let red = Red;
    let yellow = Yellow;
    let green = TrafficLight::Green; // because we didn’t `use` TrafficLight::Green
}

使用*的全局引用导入

为了一次导入某个命名空间的所有项,可以使用*语法。例如:

enum TrafficLight {
    Red,
    Yellow,
    Green,
}

use TrafficLight::*;

fn main() {
    let red = Red;
    let yellow = Yellow;
    let green = Green;
}

*被称为全局导入glob),它会导入命名空间中所有可见的项。全局导入应该保守的使用:他们是方便的,但是也可能会引入多于你预期的内容从而导致命名冲突。

使用super访问父模块

正如我们已经知道的,当创建一个库 crate 时,Cargo 会生成一个tests模块。现在让我们来深入了解一下。在communicator项目中,打开 src/lib.rs

Filename: src/lib.rs

pub mod client;

pub mod network;

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
    }
}

第十一章会更详细的解释测试,不过其部分内容现在应该可以理解了:有一个叫做tests的模块紧邻其他模块,同时包含一个叫做it_works的函数。即便存在一些特殊注解,tests也不过是另外一个模块!所以我们的模块层次结构看起来像这样:

communicator
 ├── client
 ├── network
 |   └── client
 └── tests

测试是为了检验库中的代码而存在的,所以让我们尝试在it_works函数中调用client::connect函数,即便现在不准备测试任何功能:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        client::connect();
    }
}

使用cargo test命令运行测试:

$ cargo test
   Compiling communicator v0.1.0 (file:///projects/communicator)
error[E0433]: failed to resolve. Use of undeclared type or module `client`
 --> src/lib.rs:9:9
  |
9 |         client::connect();
  |         ^^^^^^^^^^^^^^^ Use of undeclared type or module `client`

warning: function is never used: `connect`, #[warn(dead_code)] on by default
 --> src/network/server.rs:1:1
  |
1 | fn connect() {
  | ^

编译失败了,不过为什么呢?并不需要像 src/main.rs 那样将communicator::置于函数前,因为这里肯定是在communicator库 crate 之内的。之所以失败的原因是路径是相对于当前模块的,在这里就是tests。唯一的例外就是use语句,它默认是相对于 crate 根模块的。我们的tests模块需要client模块位于其作用域中!

那么如何在模块层次结构中回退一级模块,以便在tests模块中能够调用client::connect函数呢?在tests模块中,要么可以在开头使用双冒号来让 Rust 知道我们想要从根模块开始并列出整个路径:

::client::connect();

要么可以使用super在层级中获取当前模块的上一级模块:

super::client::connect();

在这个例子中这两个选择看不出有多么大的区别,不过随着模块层次的更加深入,每次都从根模块开始就会显得很长了。在这些情况下,使用super来获取当前模块的同级模块是一个好的捷径。再加上,如果在代码中的很多地方指定了从根开始的路径,那么当通过移动子树或到其他位置来重新排列模块时,最终就需要更新很多地方的路径,这就非常乏味无趣了。

在每一个测试中总是不得不编写super::也会显得很恼人,不过你已经见过解决这个问题的利器了:usesuper::的功能改变了提供给use的路径,使其不再相对于根模块而是相对于父模块。

为此,特别是在tests模块,use super::something是常用的手段。所以现在的测试看起来像这样:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    use super::client;

    #[test]
    fn it_works() {
        client::connect();
    }
}

如果再次运行cargo test,测试将会通过而且测试结果输出的第一部分将会是:

$ cargo test
   Compiling communicator v0.1.0 (file:///projects/communicator)
     Running target/debug/communicator-92007ddb5330fa5a

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

总结

现在你掌握了组织代码的核心科技!利用他们将相关的代码组合在一起、防止代码文件过长并将一个整洁的公有 API 展现给库的用户。

接下来,让我们看看一些标准库提供的集合数据类型,你可以利用他们编写出漂亮整洁的代码。

通用集合类型

ch08-00-common-collections.md
commit e6d6caab41471f7115a621029bd428a812c5260e

Rust 标准库中包含一系列被称为集合collections)的非常有用的数据结构。大部分其他数据类型都代表一个特定的值,不过集合可以包含多个值。不同于内建的数组和元组类型,这些集合指向的数据是储存在堆上的,这意味着数据的数量不必在编译时就可知并且可以随着程序的运行增长或缩小。每种集合都有着不同能力和代价,而为所处的场景选择合适的集合则是你将要始终发展的技能。在这一章里,我们将详细的了解三个在 Rust 程序中被广泛使用的集合:

  • vector 允许我们一个挨着一个地储存一系列数量可变的值
  • 字符串string)是一个字符的集合。我们之前见过String类型,现在将详细介绍它。
  • 哈希 maphash map)允许我们将值与一个特定的键(key)相关联。这是一个叫做 map 的更通用的数据结构的特定实现。

对于标准库提供的其他类型的集合,请查看文档

我们将讨论如何创建和更新 vector、字符串和哈希 map,以及他们有什么不同。

vector

ch08-01-vectors.md
commit 6c24544ba718bce0755bdaf03423af86280051d5

我们要讲到的第一个类型是Vec<T>,也被称为 vector。vector 允许我们在一个单独的数据结构中储存多于一个值,它在内存中彼此相邻的排列所有的值。vector 只能储存相同类型的值。他们在拥有一系列项的场景下非常实用,例如文件中的文本行或是购物车中商品的价格。

新建 vector

为了创建一个新的,空的 vector,可以调用Vec::new函数:

let v: Vec<i32> = Vec::new();

注意这里我们增加了一个类型注解。因为没有向这个 vector 中插入任何值,Rust 并不知道我们想要储存什么类型的元素。这是一个非常重要的点。vector 是同质的(homogeneous):他们可以储存很多值,不过这些值必须都是相同类型的。vector 是用泛型实现的,第十章会涉及到如何对你自己的类型使用他们。现在,所有你需要知道的就是Vec是一个由标准库提供的类型,它可以存放任何类型,而当Vec存放某个特定类型时,那个类型位于尖括号中。这里我们告诉 Rust v这个Vec将存放i32类型的元素。

在实际的代码中,一旦插入值 Rust 就可以推断出想要存放的类型,所以你很少会需要这些类型注解。更常见的做法是使用初始值来创建一个Vec,而且为了方便 Rust 提供了vec!宏。这个宏会根据我们提供的值来创建一个新的Vec。如下代码会新建一个拥有值123Vec<i32>

let v = vec![1, 2, 3];

因为我们提供了i32类型的初始值,Rust 可以推断出v的类型是Vec<i32>,因此类型注解就不是必须的。接下来让我们看看如何修改一个 vector。

更新 vector

对于新建一个 vector 并向其增加元素,可以使用push方法:

let mut v = Vec::new();

v.push(5);
v.push(6);
v.push(7);
v.push(8);

如第三章中讨论的任何变量一样,如果想要能够改变它的值,必须使用mut关键字使其可变。放入其中的所有值都是i32类型的,而且 Rust 也根据数据如此判断,所以不需要Vec<i32>注解。

丢弃 vector 时也会丢弃其所有元素

类似于任何其他的struct,vector 在其离开作用域时会被释放:

{
    let v = vec![1, 2, 3, 4];

    // do stuff with v

} // <- v goes out of scope and is freed here

当 vector 被丢弃时,所有其内容也会被丢弃,这意味着这里它包含的整数将被清理。这可能看起来非常直观,不过一旦开始使用 vector 元素的引用情况就变得有些复杂了。下面让我们处理这种情况!

读取 vector 的元素

现在你知道如何创建、更新和销毁 vector 了,接下来的一步最好了解一下如何读取他们的内容。有两种方法引用 vector 中储存的值。为了更加清楚的说明这个例子,我们标注这些函数返回的值的类型。

这个例子展示了访问 vector 中一个值的两种方式,索引语法或者get方法:

let v = vec![1, 2, 3, 4, 5];

let third: &i32 = &v[2];
let third: Option<&i32> = v.get(2);

这里有一些需要注意的地方。首先,我们使用索引值2来获取第三个元素,索引是从 0 开始的。其次,这两个不同的获取第三个元素的方式分别为:使用&[]返回一个引用;或者使用get方法以索引作为参数来返回一个Option<&T>

Rust 有两个引用元素的方法的原因是程序可以选择如何处理当索引值在 vector 中没有对应值的情况。例如如下情况,如果有一个有五个元素的 vector 接着尝试访问索引为 100 的元素,程序该如何处理:

let v = vec![1, 2, 3, 4, 5];

let does_not_exist = &v[100];
let does_not_exist = v.get(100);

当运行这段代码,你会发现对于第一个[]方法,当引用一个不存在的元素时 Rust 会造成panic!。这个方法更适合当程序认为尝试访问超过 vector 结尾的元素是一个严重错误的情况,这时应该使程序崩溃。

get方法被传递了一个数组外的索引时,它不会 panic 而是返回None。当偶尔出现超过 vector 范围的访问属于正常情况的时候可以考虑使用它。接着你的代码可以有处理Some(&element)None的逻辑,如第六章讨论的那样。例如,索引可能来源于用户输入的数字。如果他们不慎输入了一个过大的数字那么程序就会得到None值,你可以告诉用户Vec当前元素的数量并再请求他们输入一个有效的值。这就比因为输入错误而使程序崩溃要友好的多!

无效引用

一旦程序获取了一个有效的引用,借用检查器将会执行第四章讲到的所有权和借用规则来确保 vector 内容的这个引用和任何其他引用保持有效。回忆一下不能在相同作用域中同时存在可变和不可变引用的规则。这个规则适用于这个例子,当我们获取了 vector 的第一个元素的不可变引用并尝试在 vector 末尾增加一个元素的时候:

let mut v = vec![1, 2, 3, 4, 5];

let first = &v[0];

v.push(6);

编译会给出这个错误:

error[E0502]: cannot borrow `v` as mutable because it is also borrowed as
immutable
  |
4 | let first = &v[0];
  |              - immutable borrow occurs here
5 |
6 | v.push(6);
  | ^ mutable borrow occurs here
7 | }
  | - immutable borrow ends here

这些代码看起来应该能够运行:为什么第一个元素的引用会关心 vector 结尾的变化?不能这么做的原因是由于 vector 的工作方式。在 vector 的结尾增加新元素是,在没有足够空间将所有所有元素依次相邻存放的情况下,可能会要求分配新内存并将老的元素拷贝到新的空间中。这时,第一个元素的引用就指向了被释放的内存。借用规则阻止程序陷入这种状况。

注意:关于更多内容,查看 Nomicon https://doc.rust-lang.org/stable/nomicon/vec.html

使用枚举来储存多种类型

在本章的开始,我们提到 vector 只能储存相同类型的值。这是很不方便的;绝对会有需要储存一系列不同类型的值的用例。幸运的是,枚举的成员都被定义为相同的枚举类型,所以当需要在 vector 中储存不同类型值时,我们可以定义并使用一个枚举!

例如,假如我们想要从电子表格的一行中获取值,而这一行的有些列包含数字,有些包含浮点值,还有些是字符串。我们可以定义一个枚举,其成员会存放这些不同类型的值,同时所有这些枚举成员都会被当作相同类型,那个枚举的类型。接着可以创建一个储存枚举值的 vector,这样最终就能够储存不同类型的值了:

enum SpreadsheetCell {
    Int(i32),
    Float(f64),
    Text(String),
}

let row = vec![
    SpreadsheetCell::Int(3),
    SpreadsheetCell::Text(String::from("blue")),
    SpreadsheetCell::Float(10.12),
];

Listing 8-1: Defining an enum to be able to hold different types of data in a vector

Rust 在编译时就必须准确的知道 vector 中类型的原因是它需要知道储存每个元素到底需要多少内存。第二个好处是可以准确的知道这个 vector 中允许什么类型。如果 Rust 允许 vector 存放任意类型,那么当对 vector 元素执行操作时一个或多个类型的值就有可能会造成错误。使用枚举外加match意味着 Rust 能在编译时就保证总是会处理所有可能的情况,正如第六章讲到的那样。

如果在编写程序时不能确切无遗的知道运行时会储存进 vector 的所有类型,枚举技术就行不通了。相反,你可以使用 trait 对象,第十七章会讲到它。

现在我们了解了一些使用 vector 的最常见的方式,请一定去看看标准库中Vec定义的很多其他实用方法的 API 文档。例如,除了push之外还有一个pop方法,它会移除并返回 vector 的最后一个元素。让我们继续下一个集合类型:String

字符串

ch08-02-strings.md
commit d362dadae60a7cc3212b107b9e9562769b0f20e3

第四章已经讲过一些字符串的内容,不过现在让我们更深入地了解一下它。字符串是新晋 Rustacean 们通常会被困住的领域。这是由于三方面内容的结合:Rust 倾向于确保暴露出可能的错误,字符串是比很多程序员所想象的要更为复杂的数据结构,以及 UTF-8。所有这些结合起来对于来自其他语言背景的程序员就可能显得很困难了。

字符串出现在集合章节的原因是,字符串是作为字节的集合外加一些方法实现的,当这些字节被解释为文本时,这些方法提供了实用的功能。在这一部分,我们会讲到String那些任何集合类型都有的操作,比如创建、更新和读取。也会讨论String于其他集合不一样的地方,例如索引String是很复杂的,由于人和计算机理解String数据的不同方式。

什么是字符串?

在开始深入这些方面之前,我们需要讨论一下术语字符串的具体意义。Rust 的核心语言中事实上就只有一种字符串类型:str,字符串 slice,它通常以被借用的形式出现,&str。第四章讲到了字符串 slice:他们是一些储存在别处的 UTF-8 编码字符串数据的引用。比如字符串字面值被储存在程序的二进制输出中,字符串 slice 也是如此。

称作String的类型是由标准库提供的,而没有写进核心语言部分,它是可增长的、可变的、有所有权的、UTF-8 编码的字符串类型。当 Rustacean 们谈到 Rust 的“字符串”时,他们通常指的是String和字符串 slice &str类型,而不是其中一个。这一部分大部分是关于String的,不过这些类型在 Rust 标准库中都被广泛使用。String和字符串 slice 都是 UTF-8 编码的。

Rust 标准库中还包含一系列其他字符串类型,比如OsStringOsStrCStringCStr。相关库 crate 甚至会提供更多储存字符串数据的选择。与*String/*Str的命名类似,他们通常也提供有所有权和可借用的变体,就比如说String/&str。这些字符串类型在储存的编码或内存表现形式上可能有所不同。本章将不会讨论其他这些字符串类型;查看 API 文档来更多的了解如何使用他们以及各自适合的场景。

新建字符串

很多Vec可用的操作在String中同样可用,从以new函数创建字符串开始,像这样:

let s = String::new();

这新建了一个叫做s的空的字符串,接着我们可以向其中装载数据。

通常字符串会有初始数据因为我们希望一开始就有这个字符串。为此,使用to_string方法,它能用于任何实现了Display trait 的类型,对于字符串字面值是这样:

let data = "initial contents";

let s = data.to_string();

// the method also works on a literal directly:
let s = "initial contents".to_string();

这会创建一个包好initial contents的字符串。

也可以使用String::from函数来从字符串字面值创建String。如下等同于使用to_string

let s = String::from("initial contents");

因为字符串使用广泛,这里有很多不同的用于字符串的通用 API 可供选择。他们有些可能显得有些多余,不过都有其用武之地!在这个例子中,String::from.to_string最终做了完全相同的工作,所以如何选择就是风格问题了。

记住字符串是 UTF-8 编码的,所以可以包含任何可以正确编码的数据:

let hello = "السلام عليكم";
let hello = "Dobrý den";
let hello = "Hello";
let hello = "שָׁלוֹם";
let hello = "नमस्ते";
let hello = "こんにちは";
let hello = "안녕하세요";
let hello = "你好";
let hello = "Olá";
let hello = "Здравствуйте";
let hello = "Hola";

更新字符串

String的大小可以增长其内容也可以改变,就像可以放入更多数据来改变Vec的内容一样。另外,String实现了+运算符作为级联运算符以便于使用。

使用 push 附加字符串

可以通过push_str方法来附加字符串 slice,从而使String变长:

let mut s = String::from("foo");
s.push_str("bar");

执行这两行代码之后s将会包含“foobar”。push_str方法获取字符串 slice,因为并不需要获取参数的所有权。例如,如果将s2的内容附加到s1中后自身不能被使用就糟糕了:

let mut s1 = String::from("foo");
let s2 = String::from("bar");
s1.push_str(&s2);

push方法被定义为获取一个单独的字符作为参数,并附加到String中:

let mut s = String::from("lo");
s.push('l');

执行这些代码之后,s将会包含“lol”。

使用 + 运算符或format!宏级联字符串

通常我们希望将两个已知的字符串合并在一起。一种办法是像这样使用+运算符:

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // Note that s1 has been moved here and can no longer be used

执行完这些代码之后字符串s3将会包含Hello, world!s1在相加后不再有效的原因,和使用s2的引用的原因与使用+运算符时调用的方法签名有关,这个函数签名看起来像这样:

fn add(self, s: &str) -> String {

这并不是标准库中实际的签名;那个add使用泛型定义。这里的签名使用具体类型代替了泛型,这也正是当使用String值调用这个方法会发生的。第十章会讨论泛型。这个签名提供了理解+运算那奇怪的部分的线索。

首先,s2使用了&,意味着我们使用第二个字符串的引用与第一个字符串相加。这是因为add函数的s参数:只能将&strString相加,不能将两个String值相加。不过等一下——正如add的第二个参数所指定的,&s2的类型是&String而不是&str。那么为什么代码还能编译呢?之所以能够在add调用中使用&s2是因为&String可以被强转coerced)成 &str——当add函数被调用时,Rust 使用了一个被称为解引用强制多态deref coercion)的技术,你可以将其理解为它把&s2变成了&s2[..]以供add函数使用。第十五章会更深入的讨论解引用强制多态。因为add没有获取参数的所有权,所以s2在这个操作后仍然是有效的String

其次,可以发现签名中add获取了self的所有权,因为self没有使用&。这意味着上面例子中的s1的所有权将被移动到add调用中,之后就不再有效。所以虽然let s3 = s1 + &s2;看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取s1的所有权,附加上从s2中拷贝的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多拷贝不过实际上并没有:这个实现比拷贝要更高效。

如果想要级联多个字符串,+的行为就显得笨重了:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = s1 + "-" + &s2 + "-" + &s3;

这时s的内容会是“tic-tac-toe”。在有这么多+"字符的情况下,很难理解具体发生了什么。对于更为复杂的字符串链接,可以使用format!宏:

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");

let s = format!("{}-{}-{}", s1, s2, s3);

这些代码也会将s设置为“tic-tac-toe”。format!println!的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果的String。这个版本就好理解的多,并且不会获取任何参数的所有权。

索引字符串

在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在 Rust 中,如果我们尝试使用索引语法访问String的一部分,会出现一个错误。比如如下代码:

let s1 = String::from("hello");
let h = s1[0];

会导致如下错误:

error: the trait bound `std::string::String: std::ops::Index<_>` is not
satisfied [--explain E0277]
  |>
  |>     let h = s1[0];
  |>             ^^^^^
note: the type `std::string::String` cannot be indexed by `_`

错误和提示说明了全部问题:Rust 的字符串不支持索引。那么接下来的问题是,为什么不支持呢?为了回答这个问题,我们必须先聊一聊 Rust 是如何在内存中储存字符串的。

内部表示

String是一个Vec<u8>的封装。让我们看看之前一些正确编码的字符串的例子。首先是这一个:

let len = String::from("Hola").len();

在这里,len的值是四,这意味着储存字符串“Hola”的Vec的长度是四个字节:每一个字符的 UTF-8 编码都占用一个字节。那下面这个例子又如何呢?

let len = String::from("Здравствуйте").len();

当问及这个字符是多长的时候有人可能会说是 12。然而,Rust 的回答是 24。这是使用 UTF-8 编码 “Здравствуйте” 所需要的字节数,这是因为每个 Unicode 标量值需要两个字节存储。因此一个字符串字节值的索引并不总是对应一个有效的 Unicode 标量值。

作为演示,考虑如下无效的 Rust 代码:

let hello = "Здравствуйте";
let answer = &hello[0];

answer的值应该是什么呢?它应该是第一个字符З吗?当使用 UTF-8 编码时,З的第一个字节是208,第二个是151,所以answer实际上应该是208,不过208自身并不是一个有效的字母。返回208可不是一个请求字符串第一个字母的人所希望看到的,不过它是 Rust 在字节索引零位置所能提供的唯一数据。返回字节值可能不是人们希望看到的,即便是只有拉丁字母时:&"hello"[0]会返回104而不是h。为了避免返回意想不到值并造成不能立刻发现的 bug。Rust 选择不编译这些代码并及早杜绝了误会的放生。

字节、标量值和字形簇!天呐!

这引起了关于 UTF-8 的另外一个问题:从 Rust 的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中字母的概念)。

比如这个用梵文书写的印度语单词“नमस्ते”,最终它储存在Vec中的u8值看起来像这样:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

这里有 18 个字节,也就是计算机最终会储存的数据。如果从 Unicode 标量值的角度理解他们,也就像 Rust 的char类型那样,这些字节看起来像这样:

['न', 'म', 'स', '्', 'त', 'े']

这里有六个char,不过第四个和第六个都不是字母,他们是发音符号本身并没有任何意义。最后,如果以字形簇的角度理解,就会得到人们所说的构成这个单词的四个字母:

["न", "म", "स्", "ते"]

Rust 提供了多种不同的方式来解释计算机储存的原始字符串数据,这样程序就可以选择它需要的表现方式,而无所谓是何种人类语言。

最后一个 Rust 不允许使用索引获取String字符的原因是索引操作预期总是需要常数时间 (O(1))。但是对于String不可能保证这样的性能,因为 Rust 不得不检查从字符串的开头到索引位置的内容来确定这里有多少有效的字符。

字符串 slice

因为字符串索引应该返回的类型是不明确的,而且索引字符串通常也是一个坏点子,所以 Rust 不建议这么做,而如果你确实需要它的话则需要更加明确一些。比使用[]和单个值的索引更加明确的方式是使用[]和一个 range 来创建包含特定字节的字符串 slice:

let hello = "Здравствуйте";

let s = &hello[0..4];

这里,s是一个&str,它包含字符串的头四个字节。早些时候,我们提到了这些字母都是两个字节长的,所以这意味着s将会是“Зд”。

如果获取&hello[0..1]会发生什么呢?答案是:在运行时会 panic,就跟访问 vector 中的无效索引时一样:

thread 'main' panicked at 'index 0 and/or 1 in `Здравствуйте` do not lie on
character boundary', ../src/libcore/str/mod.rs:1694

你应该小心谨慎的使用这个操作,因为它可能会使你的程序崩溃。

遍历字符串的方法

幸运的是,这里还有其他获取字符串元素的方式。

如果你需要操作单独的 Unicode 标量值,最好的选择是使用chars方法。对“नमस्ते”调用chars方法会将其分开并返回六个char类型的值,接着就可以遍历结果来访问每一个元素了:

for c in "नमस्ते".chars() {
    println!("{}", c);
}

这些代码会打印出如下内容:

न
म
स
्
त
े

bytes方法返回每一个原始字节,这可能会适合你的使用场景:

for b in "नमस्ते".bytes() {
    println!("{}", b);
}

这些代码会打印出组成String的 18 个字节,开头是这样的:

224
164
168
224
// ... etc

不过请记住有效的 Unicode 标量值可能会由不止一个字节组成。

从字符串中获取字形簇是很复杂的,所以标准库并没有提供这个功能。crates.io 上有些提供这样功能的 crate。

字符串并不简单

总而言之,字符串还是很复杂的。不同的语言选择了不同的向程序员展示其复杂性的方式。Rust 选择了以准确的方式处理String数据作为所有 Rust 程序的默认行为,这意味着程序员们必须更多的思考如何在前台处理 UTF-8 数据。这种权衡取舍相比其他语言更多的暴露出了字符串的复杂性,不过也使你在开发生命周期中免于处理涉及非 ASCII 字符的错误。

现在让我们转向一些不太复杂的集合:哈希 map!

哈希 map

ch08-03-hash-maps.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

最后介绍的常用集合类型是 哈希 maphash map)。HashMap<K, V> 类型储存了一个键类型 K 对应一个值类型 V 的映射。它通过一个哈希函数hashing function)来实现映射,决定如何将键和值放入内存中。很多编程语言支持这种数据结构,不过通常有不同的名字:哈希、map、对象、哈希表或者关联数组,仅举几例。

哈希 map 可以用于需要任何类型作为键来寻找数据的情况,而不是像 vector 那样通过索引。例如,在一个游戏中,你可以将每个团队的分数记录到哈希 map 中,其中键是队伍的名字而值是每个队伍的分数。给出一个队名,就能得到他们的得分。

本章我们会介绍哈希 map 的基本 API,不过还有更多吸引人的功能隐藏于标准库中的HashMap定义的函数中。请一如既往地查看标准库文档来了解更多信息。

新建一个哈希 map

可以使用new创建一个空的HashMap,并使用insert来增加元素。这里我们记录两支队伍的分数,分别是蓝队和黄队。蓝队开始有 10 分而黄队开始有 50 分:

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

注意必须首先 use 标准库中集合部分的 HashMap。在这三个常用集合中,HashMap 是最不常用的,所以并没有被 prelude 自动引用。标准库中对 HashMap 的支持也相对较少,例如,并没有内建的构建宏。 像 vector 一样,哈希 map 将他们的数据储存在堆上,这个 HashMap 的键类型是 String 而值类型是 i32。同样类似于 vector,哈希 map 是同质的:所有的键必须是相同类型,值也必须都是相同类型。

另一个构建哈希 map 的方法是使用一个元组的 vector 的 collect 方法,其中每个元组包含一个键值对。collect 方法可以将数据收集进一系列的集合类型,包括 HashMap。例如,如果队伍的名字和初始分数分别在两个 vector 中,可以使用 zip 方法来创建一个元组的 vector,其中“Blue”与 10 是一对,依此类推。接着就可以使用 collect 方法将这个元组 vector 转换成一个 HashMap

use std::collections::HashMap;

let teams  = vec![String::from("Blue"), String::from("Yellow")];
let initial_scores = vec![10, 50];

let scores: HashMap<_, _> = teams.iter().zip(initial_scores.iter()).collect();

这里HashMap<_, _>类型注解是必要的,因为可能collect进很多不同的数据结构,而除非显式指定 Rust 无从得知你需要的类型。但是对于键和值的类型参数来说,可以使用下划线占位,而 Rust 能够根据 vector 中数据的类型推断出 HashMap 所包含的类型。

哈希 map 和所有权

对于像i32这样的实现了Copy trait 的类型,其值可以拷贝进哈希 map。对于像String这样拥有所有权的值,其值将被移动而哈希 map 会成为这些值的所有者:

use std::collections::HashMap;

let field_name = String::from("Favorite color");
let field_value = String::from("Blue");

let mut map = HashMap::new();
map.insert(field_name, field_value);
// field_name and field_value are invalid at this point

insert调用将field_namefield_value移动到哈希 map 中后,将不能使用这两个绑定。

如果将值的引用插入哈希 map,这些值本身将不会被移动进哈希 map。但是这些引用指向的值必须至少在哈希 map 有效时也是有效的。第十章生命周期部分将会更多的讨论这个问题。

访问哈希 map 中的值

可以通过get方法并提供对应的键来从哈希 map 中获取值:

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

let team_name = String::from("Blue");
let score = scores.get(&team_name);

这里,score 是与蓝队分数相关的值,应为 Some(10)。因为 get 返回 Option<V>,所以结果被装进 Some;如果某个键在哈希 map 中没有对应的值,get 会返回 None。这时就要用某种第六章提到的方法来处理 Option

可以使用与 vector 类似的方式来遍历哈希 map 中的每一个键值对,也就是for循环:

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Yellow"), 50);

for (key, value) in &scores {
    println!("{}: {}", key, value);
}

这会以任意顺序打印出每一个键值对:

Yellow: 50
Blue: 10

更新哈希 map

尽管键值对的数量是可以增长的,不过任何时候,每个键只能关联一个值。当你想要改变哈希 map 中的数据时,根据目标键是否有值以及值的更新策略分成多种情况,下面我们了解一下:

覆盖一个值

如果我们插入了一个键值对,接着用相同的键插入一个不同的值,与这个键相关联的旧值将被替换。即便下面的代码调用了两次insert,哈希 map 也只会包含一个键值对,因为两次都是对蓝队的键插入的值:

use std::collections::HashMap;

let mut scores = HashMap::new();

scores.insert(String::from("Blue"), 10);
scores.insert(String::from("Blue"), 25);

println!("{:?}", scores);

这会打印出{"Blue": 25}。原始的值 10 将被覆盖。

只在键没有对应值时插入

我们经常会检查某个特定的键是否有值,如果没有就插入一个值。为此哈希 map 有一个特有的 API,叫做entry,它获取我们想要检查的键作为参数。entry函数的返回值是一个枚举,Entry,它代表了可能存在也可能不存在的值。比如说我们想要检查黄队的键是否关联了一个值。如果没有,就插入值 50,对于蓝队也是如此。使用 entry API 的代码看起来像这样:

use std::collections::HashMap;

let mut scores = HashMap::new();
scores.insert(String::from("Blue"), 10);

scores.entry(String::from("Yellow")).or_insert(50);
scores.entry(String::from("Blue")).or_insert(50);

println!("{:?}", scores);

Entryor_insert方法在键对应的值存在时就返回这个值的Entry,如果不存在则将参数作为新值插入并返回修改过的Entry。这比编写自己的逻辑要简明的多,另外也与借用检查器结合得更好。

这段代码会打印出{"Yellow": 50, "Blue": 10}。第一个entry调用会插入黄队的键和值 50,因为黄队并没有一个值。第二个entry调用不会改变哈希 map 因为蓝队已经有了值 10。

根据旧值更新一个值

另一个常见的哈希 map 的应用场景是找到一个键对应的值并根据旧的值更新它。例如,如果我们想要计数一些文本中每一个单词分别出现了多少次,就可以使用哈希 map,以单词作为键并递增其值来记录我们遇到过几次这个单词。如果是第一次看到某个单词,就插入值0

use std::collections::HashMap;

let text = "hello world wonderful world";

let mut map = HashMap::new();

for word in text.split_whitespace() {
    let count = map.entry(word).or_insert(0);
    *count += 1;
}

println!("{:?}", map);

这会打印出{"world": 2, "hello": 1, "wonderful": 1}or_insert方法事实上会返回这个键的值的一个可变引用(&mut V)。这里我们将这个可变引用储存在count变量中,所以为了赋值必须首先使用星号(*)解引用count。这个可变引用在for循环的结尾离开作用域,这样所有这些改变都是安全的并符合借用规则。

哈希函数

HashMap默认使用一种密码学安全的哈希函数,它可以抵抗拒绝服务(Denial of Service, DoS)攻击。然而并不是最快的,不过为了更高的安全性值得付出一些性能的代价。如果性能监测显示此哈希函数非常慢,以致于你无法接受,你可以指定一个不同的 hasher 来切换为其它函数。hasher 是一个实现了BuildHasher trait 的类型。第十章会讨论 trait 和如何实现他们。你并不需要从头开始实现你自己的 hasher;crates.io 有其他人分享的实现了许多常用哈希算法的 hasher 的库。

总结

vector、字符串和哈希 map 会在你的程序需要储存、访问和修改数据时帮助你。这里有一些你应该能够解决的练习问题:

  • 给定一系列数字,使用 vector 并返回这个列表的平均数(mean, average)、中位数(排列数组后位于中间的值)和众数(mode,出现次数最多的值;这里哈希函数会很有帮助)。
  • 将字符串转换为 Pig Latin,也就是每一个单词的第一个辅音字母被移动到单词的结尾并增加“ay”,所以“first”会变成“irst-fay”。元音字母开头的单词则在结尾增加 “hay”(“apple”会变成“apple-hay”)。牢记 UTF-8 编码!
  • 使用哈希 map 和 vector,创建一个文本接口来允许用户向公司的部门中增加员工的名字。例如,“Add Sally to Engineering”或“Add Amir to Sales”。接着让用户获取一个部门的所有员工的列表,或者公司每个部门的所有员工按照字母顺排序的列表。

标准库 API 文档中描述的这些类型的方法将有助于你进行这些练习!

我们已经开始接触可能会有失败操作的复杂程序了,这也意味着接下来是一个了解错误处理的绝佳时机!

错误处理

ch09-00-error-handling.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

Rust 对可靠性的执着也扩展到了错误处理。错误对于软件来说是不可避免的,所以 Rust 有很多功能来处理当现错误的情况。在很多情况下,Rust 要求你承认出错的可能性并在编译代码之前就采取行动。通过确保不会只有在将代码部署到生产环境之后才会发现错误来使得程序更可靠。

Rust 将错误组合成两个主要类别:可恢复错误recoverable)和不可恢复错误unrecoverable)。可恢复错误通常代表向用户报告错误和重试操作是合理的情况,比如未找到文件。不可恢复错误通常是 bug 的同义词,比如尝试访问超过数组结尾的位置。

大部分语言并不区分这两类错误,并采用类似异常这样方式统一处理他们。Rust 并没有异常。相反,对于可恢复错误有Result<T, E>值和panic!,它在遇到不可恢复错误时停止程序执行。这一章会首先介绍panic!调用,接着会讲到如何返回Result<T, E>。最后,我们会讨论当决定是尝试从错误中恢复还是停止执行时需要顾及的权衡考虑。

panic!与不可恢复的错误

ch09-01-unrecoverable-errors-with-panic.md
commit e26bb338ab14b98a850c3464e821d54940a45672

突然有一天,糟糕的事情发生了,而你对此束手无策。对于这种情况,Rust 有panic!宏。当执行这个宏时,程序会打印出一个错误信息,展开并清理栈数据,然后接着退出。出现这种情况的场景通常是检测到一些类型的 bug 而且程序员并不清楚该如何处理它。

Panic 中的栈展开与终止

当出现panic!时,程序默认会开始展开unwinding),这意味着 Rust 会回溯栈并清理它遇到的每一个函数的数据,不过这个回溯并清理的过程有很多工作。另一种选择是直接终止abort),这会不清理数据就退出程序。那么程序所使用的内存需要由操作系统来清理。如果你需要项目的最终二进制文件越小越好,可以由 panic 时展开切换为终止,通过在 Cargo.toml[profile]部分增加panic = 'abort'。例如,如果你想要在发布模式中 panic 时直接终止:

[profile.release]
panic = 'abort'

让我们在一个简单的程序中调用panic!

Filename: src/main.rs

fn main() {
    panic!("crash and burn");
}

运行程序将会出现类似这样的输出:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished debug [unoptimized + debuginfo] target(s) in 0.25 secs
     Running `target/debug/panic`
thread 'main' panicked at 'crash and burn', src/main.rs:2
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/panic` (exit code: 101)

最后三行包含panic!造成的错误信息。第一行显示了 panic 提供的信息并指明了源码中 panic 出现的位置:src/main.rs:2 表明这是 src/main.rs 文件的第二行。

在这个例子中,被指明的那一行是我们代码的一部分,而且查看这一行的话就会发现panic!宏的调用。换句话说,panic!可能会出现在我们的代码调用的代码中。错误信息报告的文件名和行号可能指向别人代码中的panic!宏调用,而不是我们代码中最终导致panic!的那一行。可以使用panic!被调用的函数的 backtrace 来寻找(我们代码中出问题的地方)。

使用panic!的 backtrace

让我们来看看另一个因为我们代码中的 bug 引起的别的库中panic!的例子,而不是直接的宏调用:

Filename: src/main.rs

fn main() {
    let v = vec![1, 2, 3];

    v[100];
}

这里尝试访问 vector 的第一百个元素,不过它只有三个元素。这种情况下 Rust 会 panic。[]应当返回一个元素,不过如果传递了一个无效索引,就没有可供 Rust 返回的正确的元素。

这种情况下其他像 C 这样语言会尝直接试提供所要求的值,即便这可能不是你期望的:你会得到对任何应 vector 中这个元素的内存位置的值,甚至是这些内存并不属于 vector 的情况。这被称为缓冲区溢出buffer overread),并可能会导致安全漏洞,比如攻击者可以像这样操作索引来读取储存在数组后面不被允许的数据。

为了使程序远离这类漏洞,如果尝试读取一个索引不存在的元素,Rust 会停止执行并拒绝继续。尝试运行上面的程序会出现如下:

$ cargo run
   Compiling panic v0.1.0 (file:///projects/panic)
    Finished debug [unoptimized + debuginfo] target(s) in 0.27 secs
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is
100', /stable-dist-rustc/build/src/libcollections/vec.rs:1362
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/panic` (exit code: 101)

这指向了一个不是我们编写的文件,libcollections/vec.rs。这是标准库中Vec<T>的实现。这是当对 vector v使用[]libcollections/vec.rs 中会执行的代码,也是真正出现panic!的地方。

接下来的几行提醒我们可以设置RUST_BACKTRACE环境变量来得到一个 backtrace 来调查究竟是什么导致了错误。让我们来试试看。列表 9-1 显示了其输出:

$ RUST_BACKTRACE=1 cargo run
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 100', /stable-dist-rustc/build/src/libcollections/vec.rs:1392
stack backtrace:
   1:     0x560ed90ec04c - std::sys::imp::backtrace::tracing::imp::write::hf33ae72d0baa11ed
                        at /stable-dist-rustc/build/src/libstd/sys/unix/backtrace/tracing/gcc_s.rs:42
   2:     0x560ed90ee03e - std::panicking::default_hook::{{closure}}::h59672b733cc6a455
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:351
   3:     0x560ed90edc44 - std::panicking::default_hook::h1670459d2f3f8843
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:367
   4:     0x560ed90ee41b - std::panicking::rust_panic_with_hook::hcf0ddb069e7abcd7
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:555
   5:     0x560ed90ee2b4 - std::panicking::begin_panic::hd6eb68e27bdf6140
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:517
   6:     0x560ed90ee1d9 - std::panicking::begin_panic_fmt::abcd5965948b877f8
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:501
   7:     0x560ed90ee167 - rust_begin_unwind
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:477
   8:     0x560ed911401d - core::panicking::panic_fmt::hc0f6d7b2c300cdd9
                        at /stable-dist-rustc/build/src/libcore/panicking.rs:69
   9:     0x560ed9113fc8 - core::panicking::panic_bounds_check::h02a4af86d01b3e96
                        at /stable-dist-rustc/build/src/libcore/panicking.rs:56
  10:     0x560ed90e71c5 - <collections::vec::Vec<T> as core::ops::Index<usize>>::index::h98abcd4e2a74c41
                        at /stable-dist-rustc/build/src/libcollections/vec.rs:1392
  11:     0x560ed90e727a - panic::main::h5d6b77c20526bc35
                        at /home/you/projects/panic/src/main.rs:4
  12:     0x560ed90f5d6a - __rust_maybe_catch_panic
                        at /stable-dist-rustc/build/src/libpanic_unwind/lib.rs:98
  13:     0x560ed90ee926 - std::rt::lang_start::hd7c880a37a646e81
                        at /stable-dist-rustc/build/src/libstd/panicking.rs:436
                        at /stable-dist-rustc/build/src/libstd/panic.rs:361
                        at /stable-dist-rustc/build/src/libstd/rt.rs:57
  14:     0x560ed90e7302 - main
  15:     0x7f0d53f16400 - __libc_start_main
  16:     0x560ed90e6659 - _start
  17:                0x0 - <unknown>

Listing 9-1: The backtrace generated by a call to panic! displayed when the environment variable RUST_BACKTRACE is set

这里有大量的输出!backtrace 第 11 行指向了我们程序中引起错误的行:src/main.rs 的第四行。backtrace 是一个执行到目前位置所有被调用的函数的列表。Rust 的 backtrace 跟其他语言中的一样:阅读 backtrace 的关键是从头开始读直到发现你编写的文件。这就是问题的发源地。这一行往上是你的代码调用的代码;往下则是调用你的代码的代码。这些行可能包含核心 Rust 代码,标准库代码或用到的 crate 代码。

如果你不希望我们的程序 panic,第一个提到我们编写的代码行的位置是你应该开始调查的,以便查明是什么值如何在这个地方引起了 panic。在上面的例子中,我们故意编写会 panic 的代码来演示如何使用 backtrace,修复这个 panic 的方法就是不要尝试在一个只包含三个项的 vector 中请求索引是 100 的元素。当将来你得代码出现了 panic,你需要搞清楚在这特定的场景下代码中执行了什么操作和什么值导致了 panic,以及应当如何处理才能避免这个问题。

本章的后面会再次回到panic!并讲到何时应该何时不应该使用这个方式。接下来,我们来看看如何使用Result来从错误中恢复。

Result与可恢复的错误

ch09-01-unrecoverable-errors-with-panic.md
commit e6d6caab41471f7115a621029bd428a812c5260e

大部分错误并没有严重到需要程序完全停止执行。有时,一个函数会因为一个容易理解并做出反映的原因失败。例如,如果尝试打开一个文件不过由于文件并不存在而操作就失败,这是我们可能想要创建这个文件而不是终止进程。

回忆一下第二章“使用Result类型来处理潜在的错误”部分中的那个Result枚举,它定义有如下两个成员,OkErr

enum Result<T, E> {
    Ok(T),
    Err(E),
}

TE是泛型类型参数;第十章会详细介绍泛型。现在你需要知道的就是T代表成功时返回的Ok成员中的数据的类型,而E代表失败时返回的Err成员中的错误的类型。因为Result有这些泛型类型参数,我们可以将Result类型和标准库中为其定义的函数用于很多不同的场景,这些情况中需要返回的成功值和失败值可能会各不相同。

让我们调用一个返回Result的函数,因为它可能会失败:如列表 9-2 所示打开一个文件:

Filename: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");
}

Listing 9-2: Opening a file

如何知道File::open返回一个Result呢?我们可以查看标准库 API 文档,或者可以直接问编译器!如果给f某个我们知道不是函数返回值类型的类型注解,接着尝试编译代码,编译器会告诉我们类型不匹配。然后错误信息会告诉我们f的类型应该是什么,为此我们将let f语句改为:

let f: u32 = File::open("hello.txt");

现在尝试编译会给出如下错误:

error[E0308]: mismatched types
 --> src/main.rs:4:18
  |
4 |     let f: u32 = File::open("hello.txt");
  |                  ^^^^^^^^^^^^^^^^^^^^^^^ expected u32, found enum
`std::result::Result`
  |
  = note: expected type `u32`
  = note:    found type `std::result::Result<std::fs::File, std::io::Error>`

这就告诉我们了File::open函数的返回值类型是Result<T, E>。这里泛型参数T放入了成功值的类型std::fs::File,它是一个文件句柄。E被用在失败值上其类型是std::io::Error

这个返回值类型说明File::open调用可能会成功并返回一个可以进行读写的文件句柄。这个函数也可能会失败:例如,文件可能并不存在,或者可能没有访问文件的权限。File::open需要一个方式告诉我们是成功还是失败,并同时提供给我们文件句柄或错误信息。而这些信息正是Result枚举可以提供的。

File::open成功的情况下,变量f的值将会是一个包含文件句柄的Ok实例。在失败的情况下,f会是一个包含更多关于出现了何种错误信息的Err实例。

我们需要在列表 9-2 的代码中增加根据File::open返回值进行不同处理的逻辑。列表 9-3 展示了一个处理Result的基本工具:第六章学习过的match表达式。

Filename: src/main.rs

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(error) => {
            panic!("There was a problem opening the file: {:?}", error)
        },
    };
}

Listing 9-3: Using a match expression to handle the Result variants we might have

注意与Option枚举一样,Result枚举和其成员也被导入到了 prelude 中,所以就不需要在match分支中的OkErr之前指定Result::

这里我们告诉 Rust 当结果是Ok,返回Ok成员中的file值,然后将这个文件句柄赋值给变量fmatch之后,我们可以利用这个文件句柄来进行读写。

match的另一个分支处理从File::open得到Err值的情况。在这种情况下,我们选择调用panic!宏。如果当前目录没有一个叫做 hello.txt 的文件,当运行这段代码时会看到如下来自panic!宏的输出:

thread 'main' panicked at 'There was a problem opening the file: Error { repr:
Os { code: 2, message: "No such file or directory" } }', src/main.rs:8

匹配不同的错误

列表 9-3 中的代码不管File::open是因为什么原因失败都会panic!。我们真正希望的是对不同的错误原因采取不同的行为:如果File::open因为文件不存在而失败,我们希望创建这个文件并返回新文件的句柄。如果File::open因为任何其他原因失败,例如没有打开文件的权限,我们仍然希望像列表 9-3 那样panic!。让我们看看列表 9-4,其中match增加了另一个分支:

Filename: src/main.rs

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let f = File::open("hello.txt");

    let f = match f {
        Ok(file) => file,
        Err(ref error) if error.kind() == ErrorKind::NotFound => {
            match File::create("hello.txt") {
                Ok(fc) => fc,
                Err(e) => {
                    panic!(
                        "Tried to create file but there was a problem: {:?}",
                        e
                    )
                },
            }
        },
        Err(error) => {
            panic!(
                "There was a problem opening the file: {:?}",
                error
            )
        },
    };
}

Listing 9-4: Handling different kinds of errors in different ways

File::open返回的Err成员中的值类型io::Error,它是一个标准库中提供的结构体。这个结构体有一个返回io::ErrorKind值的kind方法可供调用。io::ErrorKind是一个标准库提供的枚举,它的成员对应io操作可能导致的不同错误类型。我们感兴趣的成员是ErrorKind::NotFound,它代表尝试打开的文件并不存在。

条件if error.kind() == ErrorKind::NotFound被称作 match guard:它是一个进一步完善match分支模式的额外的条件。这个条件必须为真才能使分支的代码被执行;否则,模式匹配会继续并考虑match中的下一个分支。模式中的ref是必须的,这样error就不会被移动到 guard 条件中而只是仅仅引用它。第十八章会详细解释为什么在模式中使用ref而不是&来获取一个引用。简而言之,在模式的上下文中,&匹配一个引用并返回它的值,而ref匹配一个值并返回一个引用。

在 match guard 中我们想要检查的条件是error.kind()是否是ErrorKind枚举的NotFound成员。如果是,尝试用File::create创建文件。然而File::create也可能会失败,我们还需要增加一个内部match语句。当文件不能被打开,会打印出一个不同的错误信息。外部match的最后一个分支保持不变这样对任何除了文件不存在的错误会使程序 panic。

失败时 panic 的捷径:unwrapexpect

match能够胜任它的工作,不过它可能有点冗长并且并不总是能很好的表明意图。Result<T, E>类型定义了很多辅助方法来处理各种情况。其中之一叫做unwrap,它的实现就类似于列表 9-3 中的match语句。如果Result值是成员Okunwrap会返回Ok中的值。如果Result是成员Errunwrap会为我们调用panic!

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").unwrap();
}

如果调用这段代码时不存在 hello.txt 文件,我们将会看到一个unwrap调用panic!时提供的错误信息:

thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error {
repr: Os { code: 2, message: "No such file or directory" } }',
/stable-dist-rustc/build/src/libcore/result.rs:868

还有另一个类似于unwrap的方法它还允许我们选择panic!的错误信息:expect。使用expect而不是unwrap并提供一个好的错误信息可以表明你的意图并有助于追踪 panic 的根源。expect的语法看起来像这样:

use std::fs::File;

fn main() {
    let f = File::open("hello.txt").expect("Failed to open hello.txt");
}

expectunwrap的使用方式一样:返回文件句柄或调用panic!宏。expect用来调用panic!的错误信息将会作为传递给expect的参数,而不像unwrap那样使用默认的panic!信息。它看起来像这样:

thread 'main' panicked at 'Failed to open hello.txt: Error { repr: Os { code:
2, message: "No such file or directory" } }',
/stable-dist-rustc/build/src/libcore/result.rs:868

传播错误

当编写一个其实现会调用一些可能会失败的操作的函数时,除了在这个函数中处理错误外,还可以选择让调用者知道这个错误并决定该如何处理。这被称为传播propagating)错误,这样能更好的控制代码调用,因为比起你代码所拥有的上下文,调用者可能拥有更多信息或逻辑来决定应该如何处理错误。

例如,列表 9-5 展示了一个从文件中读取用户名的函数。如果文件不存在或不能读取,这个函数会将这些错误返回给调用它的代码:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
    let f = File::open("hello.txt");

    let mut f = match f {
        Ok(file) => file,
        Err(e) => return Err(e),
    };

    let mut s = String::new();

    match f.read_to_string(&mut s) {
        Ok(_) => Ok(s),
        Err(e) => Err(e),
    }
}

Listing 9-5: A function that returns errors to the calling code using match

首先让我们看看函数的返回值:Result<String, io::Error>。这意味着函数返回一个Result<T, E>类型的值,其中泛型参数T的具体类型是String,而E的具体类型是io::Error。如果这个函数没有出任何错误成功返回,函数的调用者会收到一个包含StringOk值————函数从文件中读取到的用户名。如果函数遇到任何错误,函数的调用者会收到一个Err值,它储存了一个包含更多这个问题相关信息的io::Error实例。我们选择io::Error作为函数的返回值是因为它正好是函数体中那两个可能会失败的操作的错误返回值:File::open函数和read_to_string方法。

函数体以File::open函数开头。接着使用match处理返回值Result,类似于列表 9-3 中的match,唯一的区别是不再当Err时调用panic!,而是提早返回并将File::open返回的错误值作为函数的错误返回值传递给调用者。如果File::open成功了,我们将文件句柄储存在变量f中并继续。

接着我们在变量s中创建了一个新String并调用文件句柄fread_to_string方法来将文件的内容读取到s中。read_to_string方法也返回一个Result因为它也可能会失败:哪怕是File::open已经成功了。所以我们需要另一个match来处理这个Result:如果read_to_string成功了,那么这个函数就成功了,并返回文件中的用户名,它现在位于被封装进Oks中。如果read_to_string失败了,则像之前处理File::open的返回值的match那样返回错误值。并不需要显式的调用return,因为这是函数的最后一个表达式。

调用这个函数的代码最终会得到一个包含用户名的Ok值,亦或一个包含io::ErrorErr值。我们无从得知调用者会如何处理这些值。例如,如果他们得到了一个Err值,他们可能会选择panic!并使程序崩溃、使用一个默认的用户名或者从文件之外的地方寻找用户名。我们没有足够的信息知晓调用者具体会如何尝试,所以将所有的成功或失败信息向上传播,让他们选择合适处理方法。

这种传播错误的模式在 Rust 是如此的常见,以至于有一个更简便的专用语法:?

传播错误的捷径:?

列表 9-6 展示了一个read_username_from_file的实现,它实现了与列表 9-5 中的代码相同的功能,不过这个实现是使用了问号运算符的:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt")?;
    let mut s = String::new();
    f.read_to_string(&mut s)?;
    Ok(s)
}

Listing 9-6: A function that returns errors to the calling code using ?

Result值之后的?被定义为与列表 9-5 中定义的处理Result值的match表达式有着完全相同的工作方式。如果Result的值是Ok,这个表达式将会返回Ok中的值而程序将继续执行。如果值是ErrErr中的值将作为整个函数的返回值,就好像使用了return关键字一样,这样错误值就被传播给了调用者。

在列表 9-6 的上下文中,File::open调用结尾的?将会把Ok中的值返回给变量f。如果出现了错误,?会提早返回整个函数并将任何Err值传播给调用者。同理也适用于read_to_string调用结尾的?

?消除了大量样板代码并使得函数的实现更简单。我们甚至可以在?之后直接使用链式方法调用来进一步缩短代码:

use std::io;
use std::io::Read;
use std::fs::File;

fn read_username_from_file() -> Result<String, io::Error> {
    let mut s = String::new();

    File::open("hello.txt")?.read_to_string(&mut s)?;

    Ok(s)
}

s中创建新的String被放到了函数开头;这没有什么变化。我们对File::open("hello.txt")?的结果直接链式调用了read_to_string,而不再创建变量f。仍然需要read_to_string调用结尾的?,而且当File::openread_to_string都成功没有失败时返回包含用户名sOk值。其功能再一次与列表 9-5 和列表 9-5 保持一致,不过这是一个与众不同且更符合工程学的写法。

?只能被用于返回Result的函数

?只能被用于返回值类型为Result的函数,因为他被定义为与列表 9-5 中的match表达式有着完全相同的工作方式。matchreturn Err(e)部分要求返回值类型是Result,所以函数的返回值必须是Result才能与这个return相兼容。

让我们看看在main函数中使用?会发生什么,如果你还记得的话它的返回值类型是()

use std::fs::File;

fn main() {
    let f = File::open("hello.txt")?;
}

为了消除重复,我们可以创建一层抽象,在这个例子中将表现为一个获取任意整型列表作为参数并对其进行处理的函数。这将增加代码的简洁性并让我们将表达和推导寻找列表中最大值的这个概念与使用这个概念的特定位置相互独立。

在列表 10-3 的程序中将寻找最大值的代码提取到了一个叫做largest的函数中。这个程序可以找出两个不同数字列表的最大值,不过列表 10-1 中的代码只存在于一个位置:

Filename: src/main.rs

fn largest(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest(&numbers);
    println!("The largest number is {}", result);
#    assert_eq!(result, 100);

    let numbers = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&numbers);
    println!("The largest number is {}", result);
#    assert_eq!(result, 6000);
}

Listing 10-3: Abstracted code to find the largest number in two lists

这个函数有一个参数list,它代表会传递给函数的任何具体i32值的 slice。函数定义中的list代表任何&[i32]。当调用largest函数时,其代码实际上运行于我们传递的特定值上。

从列表 10-2 到列表 10-3 中涉及的机制经历了如下几步:

  1. 我们注意到了重复代码。
  2. 我们将重复代码提取到了一个函数中,并在函数签名中指定了代码中的输入和返回值。
  3. 我们将两个具体的存在重复代码的位置替换为了函数调用。

在不同的场景使用不同的方式泛型也可以利用相同的步骤来减少重复代码。与函数体中现在作用于一个抽象的list而不是具体值一样,使用泛型的代码也作用于抽象类型。支持泛型背后的概念与你已经了解的支持函数的概念是一样的,不过是实现方式不同。

如果我们有两个函数,一个寻找一个i32值的 slice 中的最大项而另一个寻找char值的 slice 中的最大项该怎么办?该如何消除重复呢?让我们拭目以待!

泛型数据类型

ch10-01-syntax.md
commit 55d9e75ffec92e922273c997026bb10613a76578

泛型用于通常我们放置类型的位置,比如函数签名或结构体,允许我们创建可以代替许多具体数据类型的结构体定义。让我们看看如何使用泛型定义函数、结构体、枚举和方法,并且在本部分的结尾我们会讨论泛型代码的性能。

在函数定义中使用泛型

定义函数时可以在函数签名的参数数据类型和返回值中使用泛型。以这种方式编写的代码将更灵活并能向函数调用者提供更多功能,同时不引入重复代码。

回到largest函数上,列表 10-4 中展示了两个提供了相同的寻找 slice 中最大值功能的函数。第一个是从列表 10-3 中提取的寻找 slice 中i32最大值的函数。第二个函数寻找 slice 中char的最大值:

Filename: src/main.rs

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&numbers);
    println!("The largest number is {}", result);
#    assert_eq!(result, 100);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest_char(&chars);
    println!("The largest char is {}", result);
#    assert_eq!(result, 'y');
}

Listing 10-4: Two functions that differ only in their names and the types in their signatures

这里largest_i32largest_char有着完全相同的函数体,所以能够将这两个函数变成一个来减少重复就太好了。所幸通过引入一个泛型参数就能实现!

为了参数化要定义的函数的签名中的类型,我们需要像给函数的值参数起名那样为这类型参数起一个名字。这里选择了名称T。任何标识符都可以作为类型参数名,选择T是因为 Rust 的类型命名规范是骆驼命名法(CamelCase)。另外泛型类型参数的规范也倾向于简短,经常仅仅是一个字母。T作为“type”的缩写是大部分 Rust 程序员的首选。

当需要在函数体中使用一个参数时,必须在函数签名中声明这个参数以便编译器能知道函数体中这个名称的意义。同理,当在函数签名中使用一个类型参数时,必须在使用它之前就声明它。类型参数声明位于函数名称与参数列表中间的尖括号中。

我们将要定义的泛型版本的largest函数的签名看起来像这样:

fn largest<T>(list: &[T]) -> T {

这可以理解为:函数largest有泛型类型T。它有一个参数list,它的类型是一个T值的 slice。largest函数将会返回一个与T相同类型的值。

列表 10-5 展示一个在签名中使用了泛型的统一的largest函数定义,并向我们展示了如何对i32值的 slice 或char值的 slice 调用largest函数。注意这些代码还不能编译!

Filename: src/main.rs

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest(&numbers);
    println!("The largest number is {}", result);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest(&chars);
    println!("The largest char is {}", result);
}

Listing 10-5: A definition of the largest function that uses generic type parameters but doesn't compile yet

如果现在就尝试编译这些代码,会出现如下错误:

error[E0369]: binary operation `>` cannot be applied to type `T`
  |
5 |         if item > largest {
  |            ^^^^
  |
note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

注释中提到了std::cmp::PartialOrd,这是一个 trait。下一部分会讲到 trait,不过简单来说,这个错误表明largest的函数体不能适用于T的所有可能的类型;因为在函数体需要比较T类型的值,不过它只能用于我们知道如何排序的类型。标准库中定义的std::cmp::PartialOrd trait 可以实现类型的排序功能。在下一部分会再次回到 trait 并讲解如何为泛型指定一个 trait,不过让我们先把这个例子放在一边并探索其他那些可以使用泛型类型参数的地方。

结构体定义中的泛型

同样也可以使用<>语法来定义拥有一个或多个泛型参数类型字段的结构体。列表 10-6 展示了如何定义和使用一个可以存放任何类型的xy坐标值的结构体Point

Filename: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

Listing 10-6: A Point struct that holds x and y values of type T

其语法类似于函数定义中的泛型应用。首先,必须在结构体名称后面的尖括号中声明泛型参数的名称。接着在结构体定义中可以指定具体数据类型的位置使用泛型类型。

注意Point的定义中只使用了一个泛型类型,我们想要表达的是结构体Point对于一些类型T是泛型的,而且字段xy都是相同类型的,无论它具体是何类型。如果尝试创建一个有不同类型值的Point的实例,像列表 10-7 中的代码就不能编译:

Filename: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

Listing 10-7: The fields x and y must be the same type because both have the same generic data type T

尝试编译会得到如下错误:

error[E0308]: mismatched types
 -->
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integral variable, found
  floating-point variable
  |
  = note: expected type `{integer}`
  = note:    found type `{float}`

当我们将 5 赋值给x,编译器就知道这个Point实例的泛型类型T是一个整型。接着我们将y指定为 4.0,而它被定义为与x有着相同的类型,所以出现了类型不匹配的错误。

如果想要一个xy可以有不同类型且仍然是泛型的Point结构体,我们可以使用多个泛型类型参数。在列表 10-8 中,我们修改Point的定义为拥有两个泛型类型TU。其中字段xT类型的,而字段yU类型的:

Filename: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

Listing 10-8: A Point generic over two types so that x and y may be values of different types

现在所有这些Point实例都是被允许的了!你可以在定义中使用任意多的泛型类型参数,不过太多的话代码将难以阅读和理解。如果你处于一个需要很多泛型类型的位置,这可能是一个需要重新组织代码并分隔成一些更小部分的信号。

枚举定义中的泛型数据类型

类似于结构体,枚举也可以在其成员中存放泛型数据类型。第六章我们使用过了标准库提供的Option<T>枚举,现在这个定义看起来就更容易理解了。让我们再看看:

enum Option<T> {
    Some(T),
    None,
}

换句话说Option<T>是一个拥有泛型T的枚举。它有两个成员:Some,它存放了一个类型T的值,和不存在任何值的None。标准库中只有这一个定义来支持创建任何具体类型的枚举值。“一个可能的值”是一个比具体类型的值更抽象的概念,而 Rust 允许我们不引入重复代码就能表现抽象的概念。

枚举也可以拥有多个泛型类型。第九章使用过的Result枚举定义就是一个这样的例子:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Result枚举有两个泛型类型,TEResult有两个成员:Ok,它存放一个类型T的值,而Err则存放一个类型E的值。这个定义使得Result枚举能很方便的表达任何可能成功(返回T类型的值)也可能失败(返回E类型的值)的操作。回忆一下列表 9-2 中打开一个文件的场景,当文件被成功打开T被放入了std::fs::File类型而当打开文件出现问题时E被放入了std::io::Error类型。

当发现代码中有多个只有存放的值的类型有所不同的结构体或枚举定义时,你就应该像之前的函数定义中那样引入泛型类型来减少重复代码。

方法定义中的枚举数据类型

可以像第五章介绍的那样来为其定义中带有泛型的结构体或枚举实现方法。列表 10-9 中展示了列表 10-6 中定义的结构体Point<T>。接着我们在Point<T>上定义了一个叫做x的方法来返回字段x中数据的引用:

Filename: src/main.rs

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

Listing 10-9: Implementing a method named x on the Point<T> struct that will return a reference to the x field, which is of type T.

注意必须在impl后面声明T,这样就可以在Point<T>上实现的方法中使用它了。

结构体定义中的泛型类型参数并不总是与结构体方法签名中使用的泛型是同一类型。列表 10-10 中在列表 10-8 中的结构体Point<T, U>上定义了一个方法mixup。这个方法获取另一个Point作为参数,而它可能与调用mixupself是不同的Point类型。这个方法用selfPoint类型的x值(类型T)和参数的Point类型的y值(类型W)来创建一个新Point类型的实例:

Filename: src/main.rs

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c'};

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

Listing 10-10: Methods that use different generic types than their struct's definition

main函数中,定义了一个有i32类型的x(其值为5)和f64y(其值为10.4)的Pointp2则是一个有着字符串 slice 类型的x(其值为"Hello")和char类型的y(其值为c)的Point。在p1上以p2调用mixup会返回一个p3,它会有一个i32类型的x,因为x来自p1,并拥有一个char类型的y,因为y来自p2println!会打印出p3.x = 5, p3.y = c

注意泛型参数TU声明于impl之后,因为他们于结构体定义相对应。而泛型参数VW声明于fn mixup之后,因为他们只是相对于方法本身的。

泛型代码的性能

在阅读本部分的内容的同时你可能会好奇使用泛型类型参数是否会有运行时消耗。好消息是:Rust 实现泛型泛型的方式意味着你的代码使用泛型类型参数相比指定具体类型并没有任何速度上的损失。

Rust 通过在编译时进行泛型代码的单态化monomorphization)来保证效率。单态化是一个将泛型代码转变为实际放入的具体类型的特定代码的过程。

编译器所做的工作正好与列表 10-5 中我们创建泛型函数的步骤相反。编译器寻找所有泛型代码被调用的位置并使用泛型代码针对具体类型生成代码。

让我们看看一个使用标准库中Option枚举的例子:

let integer = Some(5);
let float = Some(5.0);

当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给Option的值并发现有两种Option<T>:一个对应i32另一个对应f64。为此,它会将泛型定义Option<T>展开为Option_i32Option_f64,接着将泛型定义替换为这两个具体的定义。

编译器生成的单态化版本的代码看起来像这样,并包含将泛型Option替换为编译器创建的具体定义后的用例代码:

Filename: src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

我们可以使用泛型来编写不重复的代码,而 Rust 将会为每一个实例编译其特定类型的代码。这意味着在使用泛型时没有运行时开销;当代码运行,它的执行效率就跟好像手写每个具体定义的重复代码一样。这个单态化过程正是 Rust 泛型在运行时极其高效的原因。

trait:定义共享的行为

ch10-02-traits.md
commit e5a987f5da3fba24e55f5c7102ec63f9dc3bc360

trait 允许我们进行另一种抽象:他们让我们可以抽象类型所通用的行为。trait 告诉 Rust 编译器某个特定类型拥有可能与其他类型共享的功能。在使用泛型类型参数的场景中,可以使用 trait bounds 在编译时指定泛型可以是任何实现了某个 trait 的类型,并由此在这个场景下拥有我们希望的功能。

注意:trait 类似于其他语言中的常被称为接口interfaces)的功能,虽然有一些不同。

定义 trait

一个类型的行为由其可供调用的方法构成。如果可以对不同类型调用相同的方法的话,这些类型就可以共享相同的行为了。trait 定义是一种将方法签名组合起来的方法,目的是定义一个实现某些目的所必需的行为的集合。

例如,这里有多个存放了不同类型和属性文本的结构体:结构体NewsArticle用于存放发生于世界各地的新闻故事,而结构体Tweet最多只能存放 140 个字符的内容,以及像是否转推或是否是对推友的回复这样的元数据。

我们想要创建一个多媒体聚合库用来显示可能储存在NewsArticleTweet实例中的数据的总结。每一个结构体都需要的行为是他们是能够被总结的,这样的话就可以调用实例的summary方法来请求总结。列表 10-11 中展示了一个表现这个概念的Summarizable trait 的定义:

Filename: lib.rs

pub trait Summarizable {
    fn summary(&self) -> String;
}

Listing 10-11: Definition of a Summarizable trait that consists of the behavior provided by a summary method

使用trait关键字来定义一个 trait,后面是 trait 的名字,在这个例子中是Summarizable。在大括号中声明描述实现这个 trait 的类型所需要的行为的方法签名,在这个例子中是是fn summary(&self) -> String。在方法签名后跟分号而不是在大括号中提供其实现。接着每一个实现这个 trait 的类型都需要提供其自定义行为的方法体,编译器也会确保任何实现Summarizable trait 的类型都拥有与这个签名的定义完全一致的summary方法。

trait 体中可以有多个方法,一行一个方法签名且都以分号结尾。

为类型实现 trait

现在我们定义了Summarizable trait,接着就可以在多媒体聚合库中需要拥有这个行为的类型上实现它了。列表 10-12 中展示了NewsArticle结构体上Summarizable trait 的一个实现,它使用标题、作者和创建的位置作为summary的返回值。对于Tweet结构体,我们选择将summary定义为用户名后跟推文的全部文本作为返回值,并假设推文内容已经被限制为 140 字符以内。

Filename: lib.rs

# pub trait Summarizable {
#     fn summary(&self) -> String;
# }
#
pub struct NewsArticle {
    pub headline: String,
    pub location: String,
    pub author: String,
    pub content: String,
}

impl Summarizable for NewsArticle {
    fn summary(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summarizable for Tweet {
    fn summary(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

Listing 10-12: Implementing the Summarizable trait on the NewsArticle and Tweet types

在类型上实现 trait 类似与实现与 trait 无关的方法。区别在于impl关键字之后,我们提供需要实现 trait 的名称,接着是for和需要实现 trait 的类型的名称。在impl块中,使用 trait 定义中的方法签名,不过不再后跟分号,而是需要在大括号中编写函数体来为特定类型实现 trait 方法所拥有的行为。

一旦实现了 trait,我们就可以用与NewsArticleTweet实例的非 trait 方法一样的方式调用 trait 方法了:

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from("of course, as you probably already know, people"),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summary());

这会打印出1 new tweet: horse_ebooks: of course, as you probably already know, people

注意因为列表 10-12 中我们在相同的lib.rs里定义了Summarizable trait 和NewsArticleTweet类型,所以他们是位于同一作用域的。如果这个lib.rs是对应aggregator crate 的,而别人想要利用我们 crate 的功能外加为其WeatherForecast结构体实现Summarizable trait,在实现Summarizable trait 之前他们首先就需要将其导入其作用域中,如列表 10-13 所示:

Filename: lib.rs

extern crate aggregator;

use aggregator::Summarizable;

struct WeatherForecast {
    high_temp: f64,
    low_temp: f64,
    chance_of_precipitation: f64,
}

impl Summarizable for WeatherForecast {
    fn summary(&self) -> String {
        format!("The high will be {}, and the low will be {}. The chance of
        precipitation is {}%.", self.high_temp, self.low_temp,
        self.chance_of_precipitation)
    }
}

Listing 10-13: Bringing the Summarizable trait from our aggregator crate into scope in another crate

另外这段代码假设Summarizable是一个公有 trait,这是因为列表 10-11 中trait之前使用了pub关键字。

trait 实现的一个需要注意的限制是:只能在 trait 或对应类型位于我们 crate 本地的时候为其实现 trait。换句话说,不允许对外部类型实现外部 trait。例如,不能Vec上实现Display trait,因为DisplayVec都定义于标准库中。允许在像Tweet这样作为我们aggregatorcrate 部分功能的自定义类型上实现标准库中的 trait Display。也允许在aggregatorcrate中为Vec实现Summarizable,因为Summarizable定义与此。这个限制是我们称为 orphan rule 的一部分,如果你感兴趣的可以在类型理论中找到它。简单来说,它被称为 orphan rule 是因为其父类型不存在。没有这条规则的话,两个 crate 可以分别对相同类型是实现相同的 trait,因而这两个实现会相互冲突:Rust 将无从得知应该使用哪一个。因为 Rust 强制执行 orphan rule,其他人编写的代码不会破坏你代码,反之亦是如此。

默认实现

有时为 trait 中的某些或全部提供默认的行为,而不是在每个类型的每个实现中都定义自己的行为是很有用的。这样当为某个特定类型实现 trait 时,可以选择保留或重载每个方法的默认行为。

列表 10-14 中展示了如何为Summarize trait 的summary方法指定一个默认的字符串值,而不是像列表 10-11 中那样只是定义方法签名:

Filename: lib.rs

pub trait Summarizable {
    fn summary(&self) -> String {
        String::from("(Read more...)")
    }
}

Listing 10-14: Definition of a Summarizable trait with a default implementation of the summary method

如果想要对NewsArticle实例使用这个默认实现,而不是像列表 10-12 中那样定义一个自己的实现,则可以指定一个空的impl块:

impl Summarizable for NewsArticle {}

即便选择不再直接为NewsArticle定义summary方法了,因为summary方法有一个默认实现而且NewsArticle被指定为实现了Summarizable trait,我们仍然可以对NewsArticle的实例调用summary方法:

let article = NewsArticle {
    headline: String::from("Penguins win the Stanley Cup Championship!"),
    location: String::from("Pittsburgh, PA, USA"),
    author: String::from("Iceburgh"),
    content: String::from("The Pittsburgh Penguins once again are the best
    hockey team in the NHL."),
};

println!("New article available! {}", article.summary());

这段代码会打印New article available! (Read more...)

Summarizable trait 改变为拥有默认summary实现并不要求对列表 10-12 中的Tweet和列表 10-13 中的WeatherForecastSummarizable的实现做任何改变:重载一个默认实现的语法与实现没有默认实现的 trait 方法时完全一样的。

默认实现允许调用相同 trait 中的其他方法,哪怕这些方法没有默认实现。通过这种方法,trait 可以实现很多有用的功能而只需实现一小部分特定内容。我们可以选择让Summarizable trait 也拥有一个要求实现的author_summary方法,接着summary方法则提供默认实现并调用author_summary方法:

pub trait Summarizable {
    fn author_summary(&self) -> String;

    fn summary(&self) -> String {
        format!("(Read more from {}...)", self.author_summary())
    }
}

为了使用这个版本的Summarizable,只需在实现 trait 时定义author_summary即可:

impl Summarizable for Tweet {
    fn author_summary(&self) -> String {
        format!("@{}", self.username)
    }
}

一旦定义了author_summary,我们就可以对Tweet结构体的实例调用summary了,而summary的默认实现会调用我们提供的author_summary定义。

let tweet = Tweet {
    username: String::from("horse_ebooks"),
    content: String::from("of course, as you probably already know, people"),
    reply: false,
    retweet: false,
};

println!("1 new tweet: {}", tweet.summary());

这会打印出1 new tweet: (Read more from @horse_ebooks...)

注意在重载过的实现中调用默认实现是不可能的。

trait bounds

现在我们定义了 trait 并在类型上实现了这些 trait,也可以对泛型类型参数使用 trait。我们可以限制泛型不再适用于任何类型,编译器会确保其被限制为那些实现了特定 trait 的类型,由此泛型就会拥有我们希望其类型所拥有的功能。这被称为指定泛型的 trait bounds

例如在列表 10-12 中为NewsArticleTweet类型实现了Summarizable trait。我们可以定义一个函数notify来调用summary方法,它拥有一个泛型类型T的参数item。为了能够在item上调用summary而不出现错误,我们可以在T上使用 trait bounds 来指定item必须是实现了Summarizable trait 的类型:

pub fn notify<T: Summarizable>(item: T) {
    println!("Breaking news! {}", item.summary());
}

trait bounds 连同泛型类型参数声明一同出现,位于尖括号中的冒号后面。由于T上的 trait bounds,我们可以传递任何NewsArticleTweet的实例来调用notify函数。列表 10-13 中使用我们aggregator crate 的外部代码也可以传递一个WeatherForecast的实例来调用notify函数,因为WeatherForecast同样也实现了Summarizable。使用任何其他类型,比如Stringi32,来调用notify的代码将不能编译,因为这些类型没有实现Summarizable

可以通过+来为泛型指定多个 trait bounds。如果我们需要能够在函数中使用T类型的显示格式的同时也能使用summary方法,则可以使用 trait bounds T: Summarizable + Display。这意味着T可以是任何实现了SummarizableDisplay的类型。

对于拥有多个泛型类型参数的函数,每一个泛型都可以有其自己的 trait bounds。在函数名和参数列表之间的尖括号中指定很多的 trait bound 信息将是难以阅读的,所以有另外一个指定 trait bounds 的语法,它将其移动到函数签名后的where从句中。所以相比这样写:

fn some_function<T: Display + Clone, U: Clone + Debug>(t: T, u: U) -> i32 {

我们也可以使用where从句:

fn some_function<T, U>(t: T, u: U) -> i32
    where T: Display + Clone,
          U: Clone + Debug
{

这就显得不那么杂乱,同时也使这个函数看起来更像没有很多 trait bounds 的函数。这时函数名、参数列表和返回值类型都离得很近。

使用 trait bounds 来修复largest函数

所以任何想要对泛型使用 trait 定义的行为的时候,都需要在泛型参数类型上指定 trait bounds。现在我们就可以修复列表 10-5 中那个使用泛型类型参数的largest函数定义了!当我们将其放置不管的时候,它会出现这个错误:

error[E0369]: binary operation `>` cannot be applied to type `T`
  |
5 |         if item > largest {
  |            ^^^^
  |
note: an implementation of `std::cmp::PartialOrd` might be missing for `T`

largest函数体中我们想要使用大于运算符比较两个T类型的值。这个运算符被定义为标准库中 trait std::cmp::PartialOrd 的一个默认方法。所以为了能够使用大于运算符,需要在T的 trait bounds 中指定PartialOrd,这样largest函数可以用于任何可以比较大小的类型的 slice。因为PartialOrd位于 prelude 中所以并不需要手动将其引入作用域。

fn largest<T: PartialOrd>(list: &[T]) -> T {

但是如果编译代码的话,会出现不同的错误:

error[E0508]: cannot move out of type `[T]`, a non-copy array
 --> src/main.rs:4:23
  |
4 |     let mut largest = list[0];
  |         -----------   ^^^^^^^ cannot move out of here
  |         |
  |         hint: to prevent move, use `ref largest` or `ref mut largest`

error[E0507]: cannot move out of borrowed content
 --> src/main.rs:6:9
  |
6 |     for &item in list.iter() {
  |         ^----
  |         ||
  |         |hint: to prevent move, use `ref item` or `ref mut item`
  |         cannot move out of borrowed content

错误的核心是cannot move out of type [T], a non-copy array,对于非泛型版本的largest函数,我们只尝试了寻找最大的i32char。正如第四章讨论过的,像i32char这样的类型是已知大小的并可以储存在栈上,所以他们实现了Copy trait。当我们将largest函数改成使用泛型后,现在list参数的类型就有可能是没有实现Copy trait 的,这意味着我们可能不能将list[0]的值移动到largest变量中。

如果只想对实现了Copy的类型调用这些代码,可以在T的 trait bounds 中增加Copy!列表 10-15 中展示了一个可以编译的泛型版本的largest函数的完整代码,只要传递给largest的 slice 值的类型实现了PartialOrdCopy这两个 trait,例如i32char

Filename: src/main.rs

use std::cmp::PartialOrd;

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];

    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];

    let result = largest(&numbers);
    println!("The largest number is {}", result);

    let chars = vec!['y', 'm', 'a', 'q'];

    let result = largest(&chars);
    println!("The largest char is {}", result);
}

Listing 10-15: A working definition of the largest function that works on any generic type that implements the PartialOrd and Copy traits

如果并不希望限制largest函数只能用于实现了Copy trait 的类型,我们可以在T的 trait bounds 中指定Clone而不是Copy,并克隆 slice 的每一个值使得largest函数拥有其所有权。但是使用clone函数潜在意味着更多的堆分配,而且堆分配在涉及大量数据时可能会相当缓慢。另一种largest的实现方式是返回 slice 中一个T值的引用。如果我们将函数返回值从T改为&T并改变函数体使其能够返回一个引用,我们将不需要任何CloneCopy的 trait bounds 而且也不会有任何的堆分配。尝试自己实现这种替代解决方式吧!

trait 和 trait bounds 让我们使用泛型类型参数来减少重复,并仍然能够向编译器明确指定泛型类型需要拥有哪些行为。因为我们向编译器提供了 trait bounds 信息,它就可以检查代码中所用到的具体类型是否提供了正确的行为。在动态类型语言中,如果我们尝试调用一个类型并没有实现的方法,会在运行时出现错误。Rust 将这些错误移动到了编译时,甚至在代码能够运行之前就强迫我们修复错误。另外,我们也无需编写运行时检查行为的代码,因为在编译时就已经检查过了,这样相比其他那些不愿放弃泛型灵活性的语言有更好的性能。

这里还有一种泛型,我们一直在使用它甚至都没有察觉它的存在,这就是生命周期lifetimes)。不同于其他泛型帮助我们确保类型拥有期望的行为,生命周期则有助于确保引用在我们需要他们的时候一直有效。让我们学习生命周期是如何做到这些的。

生命周期与引用有效性

ch10-03-lifetime-syntax.md
commit 9fbbfb23c2cd1686dbd3ce7950ae1eda300937f6

当在第四章讨论引用时,我们遗漏了一个重要的细节:Rust 中的每一个引用都有其生命周期,也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,正如大部分时候类型也是可以推断的一样。类似于当因为有多种可能类型的时候必须注明类型,也会出现引用的生命周期以多种不同方式相关联的情况,所以 Rust 需要我们使用泛型生命周期参数来注明他们的关系,这样就能确保运行时实际使用的引用绝对是有效的。

好吧,这有点不太寻常,而且也不同于其他语言中使用的工具。生命周期,从某种意义上说,是 Rust 最与众不同的功能。

生命周期是一个很广泛的话题,本章不可能涉及到它全部的内容,所以这里我们会讲到一些通常你可能会遇到的生命周期语法以便你熟悉这个概念。第十九章会包含生命周期所有功能的更高级的内容。

生命周期避免了悬垂引用

生命周期的主要目标是避免悬垂引用,它会导致程序引用了并非其期望引用的数据。考虑一下列表 10-16 中的程序,它有一个外部作用域和一个内部作用域,外部作用域声明了一个没有初值的变量r,而内部作用域声明了一个初值为 5 的变量x。在内部作用域中,我们尝试将r的值设置为一个x的引用。接着在内部作用域结束后,尝试打印出r的值:

{
    let r;

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

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

Listing 10-16: An attempt to use a reference whose value has gone out of scope

未初始化变量不能被使用

接下来的一些例子中声明了没有初始值的变量,以便这些变量存在于外部作用域。这看起来好像和 Rust 不允许存在空值相冲突。然而这是可以的,如果我们尝试在给它一个值之前使用这个变量,会出现一个编译时错误。请自行尝试!

当编译这段代码时会得到一个错误:

error: `x` does not live long enough
   |
6  |         r = &x;
   |              - borrow occurs here
7  |     }
   |     ^ `x` dropped here while still borrowed
...
10 | }
   | - borrowed value needs to live until here

变量x并没有“存在的足够久”。为什么呢?好吧,x在到达第 7 行的大括号的结束时就离开了作用域,这也是内部作用域的结尾。不过r在外部作用域也是有效的;作用域越大我们就说它“存在的越久”。如果 Rust 允许这段代码工作,r将会引用在x离开作用域时被释放的内存,这时尝试对r做任何操作都会不能正常工作。那么 Rust 是如何决定这段代码是不被允许的呢?

借用检查器

编译器的这一部分叫做借用检查器borrow checker),它比较作用域来确保所有的借用都是有效的。列表 10-17 展示了与列表 10-16 相同的例子不过带有变量声明周期的注释:

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

Listing 10-17: Annotations of the lifetimes of r and x, named 'a and 'b respectively

我们将r的声明周期标记为'a而将x的生命周期标记为'b。如你所见,内部的'b块要比外部的生命周期'a小得多。在编译时,Rust 比较这两个生命周期的大小,并发现r拥有声明周期'a,不过它引用了一个拥有生命周期'b的对象。程序被拒绝编译,因为生命周期'b比生命周期'a要小:被引用的对象比它的引用者存活的时间更短。

让我们看看列表 10-18 中这个并没有产生悬垂引用且可以正常编译的例子:

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

Listing 10-18: A valid reference because the data has a longer lifetime than the reference

x拥有生命周期 'b,在这里它比 'a要大。这就意味着r可以引用x:Rust 知道r中的引用在x有效的时候也会一直有效。

现在我们已经在一个具体的例子中展示了引用的声明周期位于何处,并讨论了 Rust 如何分析生命周期来保证引用总是有效的,接下来让我们聊聊在函数的上下文中参数和返回值的泛型生命周期。

函数中的泛型生命周期

让我们来编写一个返回两个字符串 slice 中最长的那一个的函数。我们希望能够通过传递两个字符串 slice 来调用这个函数,并希望返回一个字符串 slice。一旦我们实现了longest函数,列表 10-19 中的代码应该会打印出The longest string is abcd

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

Listing 10-19: A main function that calls the longest function to find the longest of two string slices

注意函数期望获取字符串 slice(如第四章所讲到的这是引用)因为我们并不希望longest函数获取其参数的引用。我们希望函数能够接受String的 slice(也就是变量string1的类型)和字符串字面值(也就是变量string2包含的值)。

参考之前第四章中的“字符串 slice 作为参数”部分中更多关于为什么上面例子中的参数正是我们想要的讨论。

如果尝试像列表 10-20 中那样实现longest函数,它并不能编译:

Filename: src/main.rs

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

Listing 10-20: An implementation of the longest function that returns the longest of two string slices, but does not yet compile

将会出现如下有关生命周期的错误:

error[E0106]: missing lifetime specifier
   |
1  | fn longest(x: &str, y: &str) -> &str {
   |                                 ^ expected 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`

提示文本告诉我们返回值需要一个泛型生命周期参数,因为 Rust 并不知道将要返回的引用是指向xy。事实上我们也不知道,因为函数体中if块返回一个x的引用而else块返回一个y的引用。

虽然我们定义了这个函数,但是并不知道传递给函数的具体值,所以也不知道到底是if还是else会被执行。我们也不知道传入的引用的具体生命周期,所以也就不能像列表 10-17 和 10-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 多个引用的泛型生命周期参数如何相互联系。如果函数有一个生命周期'ai32的引用的参数first,还有另一个同样是生命周期'ai32的引用的参数second,这两个生命周期注解有相同的名称意味着firstsecond必须与这相同的泛型生命周期存在得一样久。

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

来看看我们编写的longest函数的上下文中的生命周期。就像泛型类型参数,泛型生命周期参数需要声明在函数名和参数列表间的尖括号中。这里我们想要告诉 Rust 关于参数中的引用和返回值之间的限制是他们都必须拥有相同的生命周期,就像列表 10-21 中在每个引用中都加上了'a那样:

Filename: src/main.rs

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

Listing 10-21: The longest function definition that specifies all the references in the signature must have the same lifetime, 'a

这段代码能够编译并会产生我们想要使用列表 10-19 中的main函数得到的结果。

现在函数签名表明对于某些生命周期'a,函数会获取两个参数,他们都是与生命周期'a存在的一样长的字符串 slice。函数会返回一个同样也与生命周期'a存在的一样长的字符串 slice。这就是我们告诉 Rust 需要其保证的协议。

通过在函数签名中指定生命周期参数,我们不会改变任何参数或返回值的生命周期,不过我们说过任何不坚持这个协议的类型都将被借用检查器拒绝。这个函数并不知道(或需要知道)xy具体会存在多久,不过只需要知道一些可以使用'a替代的作用域将会满足这个签名。

当在函数中使用生命周期注解时,这些注解出现在函数签名中,而不存在于函数体中的任何代码中。这是因为 Rust 能够分析函数中代码而不需要任何协助,不过当函数引用或被函数之外的代码引用时,参数或返回值的生命周期可能在每次函数被调用时都不同。这可能会产生惊人的消耗并且对于 Rust 来说经常都是不可能分析的。在这种情况下,我们需要自己标注生命周期。

当具体的引用被传递给longest时,具体被'a所替代的生命周期是x的作用域与y的作用域相重叠的那一部分。因为作用域总是嵌套的,所以换一种说法就是泛型生命周期'a的具体生命周期等同于xy的生命周期中较小的那一个。因为我们用相同的生命周期参数标注了返回的引用值,所以返回的引用值就能保证在xy中较短的那个生命周期结束之前保持有效。

让我们如何通过传递拥有不同具体生命周期的引用来观察他们是如何限制longest函数的使用的。列表 10-22 是一个应该在任何编程语言中都很直观的例子:string1直到外部作用域结束都是有效的,string2则在内部作用域中是有效的,而result则引用了一些直到内部作用域结束都是有效的值。借用检查器赞同这些代码;它能够编译和运行,并打印出The longest string is long string is long

Filename: src/main.rs

# fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
#     if x.len() > y.len() {
#         x
#     } else {
#         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);
    }
}

Listing 10-22: Using the longest function with references to String values that have different concrete lifetimes

接下来,让我们尝试一个result的引用的生命周期必须比两个参数的要短的例子。将result变量的声明从内部作用域中移动出来,不过将resultstring2变量的赋值语句一同放在内部作用域里。接下来,我们将使用resultprintln!移动到内部作用域之外,就在其结束之后。注意列表 10-23 中的代码不能编译:

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

Listing 10-23: Attempting to use result after string2 has gone out of scope won't compile

如果尝试编译会出现如下错误:

error: `string2` does not live long enough
   |
6  |         result = longest(string1.as_str(), string2.as_str());
   |                                            ------- borrow occurs here
7  |     }
   |     ^ `string2` dropped here while still borrowed
8  |     println!("The longest string is {}", result);
9  | }
   | - borrowed value needs to live until here

错误表明为了保证println!中的result是有效的,string2需要直到外部作用域结束都是有效的。Rust 知道这些是因为(longest)函数的参数和返回值都使用了相同的生命周期参数'a

以我们的理解string1更长,因此result会包含指向string1的引用。因为string1还未离开作用域,对于println!来说string1的引用仍然是有效的。然而,我们通过生命周期参数告诉 Rust 的是longest函数返回的引用的生命周期应该与传入参数的生命周期中较短那个保持一致。因此,借用检查器不允许列表 10-23 中的代码,因为它可能会存在无效的引用。

请尝试更多采用不同的值和不同生命周期的引用作为longest函数的参数和返回值的实验。并在开始编译前猜想你的实验能否通过借用检查器,接着编译一下看看你是否是正确的!

深入理解生命周期

指定生命周期参数的正确方式依赖函数具体的功能。例如,如果将longest函数的实现修改为总是返回第一个参数而不是最长的字符串 slice,就不需要为参数y指定一个生命周期。如下代码将能够编译:

Filename: src/main.rs

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

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

当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值,它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。尝试考虑这个并不能编译的longest函数实现:

Filename: src/main.rs

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

即便我们为返回值指定了生命周期参数'a,这个实现却编译失败了,因为返回值的生命周期与参数完全没有关联。这里是会出现的错误信息:

error: `result` does not live long enough
  |
3 |     result.as_str()
  |     ^^^^^^ does not live long enough
4 | }
  | - borrowed value only lives until here
  |
note: borrowed value must be valid for the lifetime 'a as defined on the block
at 1:44...
  |
1 | fn longest<'a>(x: &str, y: &str) -> &'a str {
  |                                             ^

出现的问题是result在函数的结尾将离开作用域并被清理,而我们尝试从函数返回一个result的引用。无法指定生命周期参数来改变悬垂引用,而且 Rust 也不允许我们创建一个悬垂引用。在这种情况,最好的解决方案是返回一个有所有权的数据类型而不是一个引用,这样函数调用者就需要负责清理这个值了。

从结果上看,生命周期语法是关于如何联系函数不同参数和返回值的生命周期的。一旦他们形成了某种联系,Rust 就有了足够的信息来允许内存安全的操作并阻止会产生悬垂指针亦或是违反内存安全的行为。

结构体定义中的生命周期注解

目前为止,我们只定义过有所有权类型的结构体。也可以定义存放引用的结构体,不过需要为结构体定义中的每一个引用添加生命周期注解。列表 10-24 中有一个存放了一个字符串 slice 的结构体ImportantExcerpt

Filename: 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 };
}

Listing 10-24: A struct that holds a reference, so its definition needs a lifetime annotation

这个结构体有一个字段,part,它存放了一个字符串 slice,这是一个引用。类似于泛型参数类型,必须在结构体名称后面的尖括号中声明泛型生命周期参数,以便在结构体定义中使用生命周期参数。

这里的main函数创建了一个ImportantExcerpt的实例,它存放了变量novel所拥有的String的第一个句子的引用。

生命周期省略

在这一部分,我们知道了每一个引用都有一个生命周期,而且需要为使用了引用的函数或结构体指定生命周期。然而,第四章的“字符串 slice”部分有一个函数,我们在列表 10-25 中再次展示它,没有生命周期注解却能成功编译:

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

Listing 10-25: A function we defined in Chapter 4 that compiled without lifetime annotations, even though the parameter and return type are references

这个函数没有生命周期注解却能编译是由于一些历史原因:在早期 1.0 之前的版本的 Rust 中,这的确是不能编译的。每一个引用都必须有明确的生命周期。那时的函数签名将会写成这样:

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

在编写了很多 Rust 代码后,Rust 团队发现在特定情况下 Rust 程序员们总是重复地编写一模一样的生命周期注解。这些场景是可预测的并且遵循几个明确的模式。接着 Rust 团队就把这些模式编码进了 Rust 编译器中,如此借用检查器在这些情况下就能推断出生命周期而不再强制程序员显式的增加注解。

这里我们提到一些 Rust 的历史是因为更多的明确的模式将被合并和添加到编译器中是完全可能的。未来将会需要越来越少的生命周期注解。

被编码进 Rust 引用分析的模式被称为生命周期省略规则lifetime elision rules)。这并不是需要程序员遵守的规则;这些规则是一系列特定的场景,此时编译器会考虑,如果代码符合这些场景,就不需要明确指定生命周期。

这些规则并不提供完整的推断:如果 Rust 在明确遵守这些规则的前提下变量的生命周期仍然是模棱两可的话,它不会猜测剩余引用的生命周期应该是什么。在这种情况,编译器会给出一个错误,这可以通过增加对应引用之间相联系的生命周期注解来解决。

首先,介绍一些定义:函数或方法的参数的生命周期被称为输入生命周期input lifetimes),而返回值的生命周期被称为输出生命周期output lifetimes)。

现在介绍编译器用于判断引用何时不需要明确生命周期注解的规则。第一条规则适用于输入生命周期,而后两条规则则适用于输出生命周期。如果编译器检查完这三条规则并仍然存在没有计算出生命周期的引用,编译器将会停止并生成错误。

  1. 每一个是引用的参数都有它自己的生命周期参数。话句话说就是,有一个引用参数的函数有一个生命周期参数:fn foo<'a>(x: &'a i32),有两个引用参数的函数有两个不同的生命周期参数,fn foo<'a, 'b>(x: &'a i32, y: &'b i32),依此类推。

  2. 如果只有一个输入生命周期参数,那么它被赋给所有输出生命周期参数:fn foo<'a>(x: &'a i32) -> &'a i32

  3. 如果方法有多个输入生命周期参数,不过其中之一因为方法的缘故是&self&mut self,那么self的生命周期被赋给所有输出生命周期参数。这使得方法写起来更简洁。

假设我们自己就是编译器并来计算列表 10-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 {

现在这个函数签名中的所有引用都有了生命周期,而编译器可以继续它的分析而无须程序员标记这个函数签名中的生命周期。

让我们再看看另一个例子,这次我们从列表 10-20 中没有生命周期参数的longest函数开始:

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

再次假设我们自己就是编译器并应用第一条规则:每个引用参数都有其自己的生命周期。这次有两个参数,所有就有两个生命周期:

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

再来应用第二条规则,它并不适用因为存在多于一个输入生命周期。再来看第三条规则,它同样也不适用因为没有self参数。然后我们就没有更多规则了,不过还没有计算出返回值的类型的生命周期。这就是为什么在编译列表 10-20 的代码时会出现错误的原因:编译器适用所有已知的生命周期省略规则,不过仍然不能计算出签名中所有引用的生命周期。

因为第三条规则真正能够适用的就只有方法签名,现在就让我们看看那种情况中的生命周期,并看看为什么这条规则意味着我们经常不需要在方法签名中标注生命周期。

方法定义中的生命周期注解

当为带有生命周期的结构体实现方法时,其语法依然类似列表 10-10 中展示的泛型类型参数的语法:包括声明生命周期参数的位置和生命周期参数是否与结构体字段或方法的参数与返回值相关联。

(实现方法时)结构体字段的生命周期必须总是在impl关键字之后声明并在结构体名称之后被使用,因为这些生命周期是结构体类型的一部分。

impl块里的方法签名中,引用可能与结构体字段中的引用相关联,也可能是独立的。另外,生命周期省略规则也经常让我们无需在方法签名中使用生命周期注解。让我们看看一些使用列表 10-24 中定义的结构体ImportantExcerpt的例子。

首先,这里有一个方法level。其唯一的参数是self的引用,而且返回值只是一个i32,并不引用任何值:

# struct ImportantExcerpt<'a> {
#     part: &'a str,
# }
#
impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

impl之后和类型名称之后的生命周期参数是必要的,不过因为第一条生命周期规则我们并不必须标注self引用的生命周期。

这里是一个适用于第三条生命周期省略规则的例子:

# struct ImportantExcerpt<'a> {
#     part: &'a str,
# }
#
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生命周期存活于整个程序期间。所有的字符串字面值都拥有'static生命周期,我们也可以选择像下面这样标注出来:

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

这个字符串的文本被直接储存在程序的二进制文件中而这个文件总是可用的。因此所有的字符串字面值都是'static的。

你可能在错误信息的帮助文本中见过使用'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
    }
}

这个是列表 10-21 中那个返回两个字符串 slice 中最长者的longest函数,不过带有一个额外的参数annann的类型是泛型T,它可以被放入任何实现了where从句中指定的Display trait 的类型。这个额外的参数会在函数比较字符串 slice 的长度之前被打印出来,这也就是为什么Display trait bound 是必须的。因为生命周期也是泛型,生命周期参数'a和泛型类型参数T都位于函数名后的同一尖括号列表中。

总结

这一章介绍了很多的内容!现在你知道了泛型类型参数、trait 和 trait bounds 以及 泛型生命周期类型,你已经准备编写既不重复又能适用于多种场景的代码了。泛型类型参数意味着代码可以适用于不同的类型。trait 和 trait bounds 保证了即使类型是泛型的,这些类型也会拥有所需要的行为。由生命周期注解所指定的引用生命周期之间的关系保证了这些灵活多变的代码不会出现悬垂引用。而所有的这一切发生在编译时所以不会影响运行时效率!

你可能不会相信,这个领域还有更多需要学习的内容:第十七章会讨论 trait 对象,这是另一种使用 trait 的方式。第十九章会涉及到生命周期注解更复杂的场景。第二十章讲解一些高级的类型系统功能。不过接下来,让我们聊聊如何在 Rust 中编写测试,来确保代码的所有功能能像我们希望的那样工作!

测试

ch11-00-testing.md
commit b7ab6668bbcb73b93c6464d8354c94a8e6c90395

Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.

Edsger W. Dijkstra, "The Humble Programmer" (1972)

软件测试是证明 bug 存在的有效方法,而证明它们不存在时则显得令人绝望的不足。

Edsger W. Dijkstra,【谦卑的程序员】(1972)

程序的正确性意味着代码如我们期望的那样运行。Rust 是一个非常注重正确性的编程语言,不过正确性是一个难以证明的复杂主题。Rust 的类型系统在此问题上下了很大的功夫,不过它不可能捕获所有类型的错误。为此,Rust 也包含为语言自身编写软件测试的支持。

例如,我们可以编写一个叫做add_two的将传递给它的值加二的函数。它的签名有一个整型参数并返回一个整型值。当实现和编译这个函数时,Rust 会进行所有目前我们已经见过的的类型检查和借用检查。例如,这些检查会确保我们不会传递String或无效的引用给这个函数。Rust 所不能检查的是这个函数是否会准确的完成我们期望的工作:返回参数加二后的值,而不是比如说参数加 10 或减 50 的值!这也就是测试出场的地方。

我们可以编写测试断言,比如说,当传递3add_two函数时,应该得到5。当对代码进行修改时可以运行测试来确保任何现存的正确行为没有被改变。

测试是一项复杂的技能,而且我们也不能期望在一本书的一个章节中就涉及到编写好的测试的所有内容,所以这里仅仅讨论 Rust 测试功能的机制。我们会讲到编写测试时会用到的注解和宏,Rust 提供用来运行测试的默认行为和选项,以及如何将测试组织成单元测试和集成测试。

编写测试

ch11-01-writing-tests.md
commit c6162d22288253b2f2a017cfe96cf1aa765c2955

测试用来验证非测试的代码按照期望的方式运行的 Rust 函数。测试函数体通常包括一些设置,运行需要测试的代码,接着断言其结果是我们所期望的。让我们看看 Rust 提供的具体用来编写测试的功能:test属性、一些宏和should_panic属性。

测试函数剖析

作为最简单例子,Rust 中的测试就是一个带有test属性注解的函数。属性(attribute)是关于 Rust 代码片段的元数据:第五章中结构体中用到的derive属性就是一个例子。为了将一个函数变成测试函数,需要在fn行之前加上#[test]。当使用cargo test命令运行测试函数时,Rust 会构建一个测试执行者二进制文件用来运行标记了test属性的函数并报告每一个测试是通过还是失败。

第七章当使用 Cargo 新建一个库项目时,它会自动为我们生成一个测试模块和一个测试函数。这有助于我们开始编写测试,因为这样每次开始新项目时不必去查找测试函数的具体结构和语法了。同时可以额外增加任意多的测试函数以及测试模块!

我们将先通过对自动生成的测试模板做一些试验来探索测试如何工作的一些方面内容,而不实际测试任何代码。接着会写一些真实的测试来调用我们编写的代码并断言他们的行为是正确的。

让我们创建一个新的库项目adder

$ cargo new adder
     Created library `adder` project
$ cd adder

adder 库中src/lib.rs的内容应该看起来像这样:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
    }
}

Listing 11-1: The test module and function generated automatically for us by cargo new

现在让我们暂时忽略tests模块和#[cfg(test)]注解并只关注函数。注意fn行之前的#[test]:这个属性表明这是一个测试函数,这样测试执行者就知道将其作为测试处理。也可以在tests模块中拥有非测试的函数来帮助我们建立通用场景或进行常见操作,所以需要使用#[test]属性标明哪些函数是测试。

这个函数目前没有任何内容,这意味着没有代码会使测试失败;一个空的测试是可以通过的!让我们运行一下看看它是否通过了。

cargo test命令会运行项目中所有的测试,如列表 11-2 所示:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished debug [unoptimized + debuginfo] target(s) in 0.22 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

Listing 11-2: The output from running the one automatically generated test

Cargo 编译并运行了测试。在CompilingFinishedRunning这几行之后,可以看到running 1 test这一行。下一行显示了生成的测试函数的名称,它是it_works,以及测试的运行结果,ok。接着可以看到全体测试运行结果的总结:test result: ok.意味着所有测试都通过了。1 passed; 0 failed表示通过或失败的测试数量。

这里并没有任何被标记为忽略的测试,所以总结表明0 ignored。在下一部分关于运行测试的不同方式中会讨论忽略测试。0 measured统计是针对测试性能的性能测试的。性能测试(benchmark tests)在编写本书时,仍只属于开发版 Rust(nightly Rust)。请查看附录 D 来了解更多开发版 Rust 的信息。

测试输出中以Doc-tests adder开头的下一部分是所有文档测试的结果。现在并没有任何文档测试,不过 Rust 会编译任何出现在 API 文档中的代码示例。这个功能帮助我们使文档和代码保持同步!在第十四章的“文档注释”部分会讲到如何编写文档测试。现在我们将忽略Doc-tests部分的输出。

让我们改变测试的名称并看看这如何改变测试的输出。给it_works函数起个不同的名字,比如exploration,像这样:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
    }
}

并再次运行cargo test。现在输出中将出现exploration而不是it_works

running 1 test
test tests::exploration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

让我们增加另一个测试,不过这一次是一个会失败的测试!当测试函数中出现 panic 时测试就失败了。第九章讲到了最简单的造成 panic 的方法:调用panic!宏!写入新函数后 src/lib.rs 现在看起来如列表 11-3 所示:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn exploration() {
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

Listing 11-3: Adding a second test; one that will fail since we call the panic! macro

再次cargo test运行测试。输出应该看起来像列表 11-4,它表明exploration测试通过了而another失败了:

running 2 tests
test tests::exploration ... ok
test tests::another ... FAILED

failures:

---- tests::another stdout ----
    thread 'tests::another' panicked at 'Make this test fail', src/lib.rs:9
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::another

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured

error: test failed

Listing 11-4: Test results when one test passes and one test fails

test tests::another这一行是FAILED而不是ok了。在单独测试结果和总结之间多了两个新的部分:第一个部分显示了测试失败的详细原因。在这个例子中,another因为panicked at 'Make this test fail'而失败,这位于 src/lib.rs 的第 9 行。下一部分仅仅列出了所有失败的测试,这在很有多测试和很多失败测试的详细输出时很有帮助。可以使用失败测试的名称来只运行这个测试,这样比较方便调试;下一部分会讲到更多运行测试的方法。

最后是总结行:总体上讲,一个测试结果是FAILED的。有一个测试通过和一个测试失败。

现在我们见过不同场景中测试结果是什么样子的了,再来看看除了panic!之外一些在测试中有帮助的宏吧。

使用assert!宏来检查结果

assert!宏由标准库提供,在希望确保测试中一些条件为true时非常有用。需要向assert!宏提供一个计算为布尔值的参数。如果值是trueassert!什么也不做同时测试会通过。如果值为falseassert!调用panic!宏,这会导致测试失败。这是一个帮助我们检查代码是否以期望的方式运行的宏。

回忆一下第五章中,列表 5-9 中有一个Rectangle结构体和一个can_hold方法,在列表 11-5 中再次使用他们。将他们放进 src/lib.rs 而不是 src/main.rs 并使用assert!宏编写一些测试。

Filename: src/lib.rs

#[derive(Debug)]
pub struct Rectangle {
    length: u32,
    width: u32,
}

impl Rectangle {
    pub fn can_hold(&self, other: &Rectangle) -> bool {
        self.length > other.length && self.width > other.width
    }
}

Listing 11-5: The Rectangle struct and its can_hold method from Chapter 5

can_hold方法返回一个布尔值,这意味着它完美符合assert!宏的使用场景。在列表 11-6 中,让我们编写一个can_hold方法的测试来作为练习,这里创建一个长为 8 宽为 7 的Rectangle实例,并假设它可以放得下另一个长为5 宽为 1 的Rectangle实例:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };

        assert!(larger.can_hold(&smaller));
    }
}

Listing 11-6: A test for can_hold that checks that a larger rectangle indeed holds a smaller rectangle

注意在tests模块中新增加了一行:use super::*;tests是一个普通的模块,它遵循第七章介绍的通常的可见性规则。因为这是一个内部模块,需要将外部模块中被测试的代码引入到内部模块的作用域中。这里选择使用全局导入使得外部模块定义的所有内容在tests模块中都是可用的。

我们将测试命名为larger_can_hold_smaller,并创建所需的两个Rectangle实例。接着调用assert!宏并传递larger.can_hold(&smaller)调用的结果作为参数。这个表达式预期会返回true,所以测试应该通过。让我们拭目以待!

running 1 test
test tests::larger_can_hold_smaller ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

它确实通过了!再来增加另一个测试,这一回断言一个更小的矩形不能放下一个更大的矩形:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_can_hold_larger() {
        let larger = Rectangle { length: 8, width: 7 };
        let smaller = Rectangle { length: 5, width: 1 };

        assert!(!smaller.can_hold(&larger));
    }
}

因为这里can_hold函数的正确结果是false,我们需要将这个结果取反后传递给assert!宏。这样的话,测试就会通过而can_hold将返回false

running 2 tests
test tests::smaller_can_hold_larger ... ok
test tests::larger_can_hold_smaller ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured

这个通过的测试!现在让我们看看如果引入一个 bug 的话测试结果会发生什么。将can_hold方法中比较长度时本应使用大于号的地方改成小于号:

#[derive(Debug)]
pub struct Rectangle {
    length: u32,
    width: u32,
}

impl Rectangle {
    pub fn can_hold(&self, other: &Rectangle) -> bool {
        self.length < other.length && self.width > other.width
    }
}

现在运行测试会产生:

running 2 tests
test tests::smaller_can_hold_larger ... ok
test tests::larger_can_hold_smaller ... FAILED

failures:

---- tests::larger_can_hold_smaller stdout ----
    thread 'tests::larger_can_hold_smaller' panicked at 'assertion failed:
    larger.can_hold(&smaller)', src/lib.rs:22
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::larger_can_hold_smaller

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured

我们的测试捕获了 bug!因为larger.length是 8 而smaller.length 是 5,can_hold中的长度比较现在返回false因为 8 不小于 5。

使用assert_eq!assert_ne!宏来测试相等

测试功能的一个常用方法是将需要测试代码的值与期望值做比较,并检查是否相等。可以通过向assert!宏传递一个使用==宏的表达式来做到。不过这个操作实在是太常见了,以至于标注库提供了一对宏来方便处理这些操作:assert_eq!assert_ne!。这两个宏分别比较两个值是相等还是不相等。当断言失败时他们也会打印出这两个值具体是什么,以便于观察测试为什么失败,而assert!只会打印出它从==表达式中得到了false值,而不是导致false值的原因。

列表 11-7 中,让我们编写一个对其参数加二并返回结果的函数add_two。接着使用assert_eq!宏测试这个函数:

Filename: src/lib.rs

pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

Listing 11-7: Testing the function add_two using the assert_eq! macro

测试通过了!

running 1 test
test tests::it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

传递给assert_eq!宏的第一个参数,4,等于调用add_two(2)的结果。我们将会看到这个测试的那一行说test tests::it_adds_two ... okok表明测试通过了!

在代码中引入一个 bug 来看看使用assert_eq!的测试失败是什么样的。修改add_two函数的实现使其加 3:

pub fn add_two(a: i32) -> i32 {
    a + 3
}

再次运行测试:

running 1 test
test tests::it_adds_two ... FAILED

failures:

---- tests::it_adds_two stdout ----
    thread 'tests::it_adds_two' panicked at 'assertion failed: `(left ==
    right)` (left: `4`, right: `5`)', src/lib.rs:11
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::it_adds_two

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured

测试捕获到了 bug!it_adds_two测试失败并显示信息assertion failed: `(left == right)` (left: `4`, right: `5`)。这个信息有助于我们开始调试:它说assert_eq!left参数是 4,而right参数,也就是add_two(2)的结果,是 5。

注意在一些语言和测试框架中,断言两个值相等的函数的参数叫做expectedactual,而且指定参数的顺序是需要注意的。然而在 Rust 中,他们则叫做leftright,同时指定期望的值和被测试代码产生的值的顺序并不重要。这个测试中的断言也可以写成assert_eq!(add_two(2), 4),这时错误信息会变成assertion failed: `(left == right)` (left: `5`, right: `4`)

assert_ne!宏在传递给它的两个值不相等时通过而在相等时失败。这个宏在代码按照我们期望运行时不确定值应该是什么,不过知道他们绝对不应该是什么的时候最有用处。例如,如果一个函数确定会以某种方式改变其输出,不过这种方式由运行测试是星期几来决定,这时最好的断言可能就是函数的输出不等于其输入。

assert_eq!assert_ne!宏在底层分别使用了==!=。当断言失败时,这些宏会使用调试格式打印出其参数,这意味着被比较的值必需实现了PartialEqDebug trait。所有的基本类型和大部分标准库类型都实现了这些 trait。对于自定义的结构体和枚举,需要实现 PartialEq才能断言他们的值是否相等。需要实现 Debug才能在断言失败时打印他们的值。因为这两个 trait 都是可推导 trait,如第五章所提到的,通常可以直接在结构体或枚举上添加#[derive(PartialEq, Debug)]注解。附录 C 中有更多关于这些和其他可推导 trait 的详细信息。

自定义错误信息

也可以向assert!assert_eq!assert_ne!宏传递一个可选的参数来增加用于打印的自定义错误信息。任何在assert!必需的一个参数和assert_eq!assert_ne!必需的两个参数之后指定的参数都会传递给第八章讲到的format!宏,所以可以传递一个包含{}占位符的格式字符串和放入占位符的值。自定义信息有助于记录断言的意义,这样到测试失败时,就能更好的例子代码出了什么问题。

例如,比如说有一个根据人名进行问候的函数,而我们希望测试将传递给函数的人名显示在输出中:

Filename: src/lib.rs

pub fn greeting(name: &str) -> String {
    format!("Hello {}!", name)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn greeting_contains_name() {
        let result = greeting("Carol");
        assert!(result.contains("Carol"));
    }
}

这个程序的需求还没有被确定,而我们非常确定问候开始的Hello文本不会改变。我们决定并不想在人名改变时 不得不更新测试,所以相比检查greeting函数返回的确切的值,我们将仅仅断言输出的文本中包含输入参数。

让我们通过改变greeting不包含name来在代码中引入一个 bug 来测试失败时是怎样的,

pub fn greeting(name: &str) -> String {
    String::from("Hello!")
}

运行测试会产生:

running 1 test
test tests::greeting_contains_name ... FAILED

failures:

---- tests::greeting_contains_name stdout ----
    thread 'tests::greeting_contains_name' panicked at 'assertion failed:
    result.contains("Carol")', src/lib.rs:12
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::greeting_contains_name

这仅仅告诉了我们断言失败了和失败的行号。一个更有用的错误信息应该打印出从greeting函数得到的值。让我们改变测试函数来使用一个由包含占位符的格式字符串和从greeting函数取得的值组成的自定义错误信息:

#[test]
fn greeting_contains_name() {
    let result = greeting("Carol");
    assert!(
        result.contains("Carol"),
        "Greeting did not contain name, value was `{}`", result
    );
}

现在如果再次运行测试,将会看到更有价值的错误信息:

---- tests::greeting_contains_name stdout ----
    thread 'tests::greeting_contains_name' panicked at 'Greeting did not contain
    name, value was `Hello`', src/lib.rs:12
note: Run with `RUST_BACKTRACE=1` for a backtrace.

可以在测试输出中看到所取得的确切的值,这会帮助我们理解发生了什么而不是期望发生什么。

使用should_panic检查 panic

除了检查代码是否返回期望的正确的值之外,检查代码是否按照期望处理错误情况也是很重要的。例如,考虑第九章列表 9-8 创建的Guess类型。其他使用Guess的代码依赖于Guess实例只会包含 1 到 100 的值的保证。可以编写一个测试来确保创建一个超出范围的值的Guess实例会 panic。

可以通过对函数增加另一个属性should_panic来实现这些。这个属性在函数中的代码 panic 时会通过,而在其中的代码没有 panic 时失败。

列表 11-8 展示了如何编写一个测试来检查Guess::new按照我们的期望出现的错误情况:

Filename: src/lib.rs

struct Guess {
    value: u32,
}

impl Guess {
    pub fn new(value: u32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess {
            value: value,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Listing 11-8: Testing that a condition will cause a panic!

#[should_panic]属性位于#[test]之后和对应的测试函数之前。让我们看看测试通过时它时什么样子:

running 1 test
test tests::greater_than_100 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

看起来不错!现在在代码中引入 bug,通过移除new函数在值大于 100 时会 panic 的条件:

# struct Guess {
#     value: u32,
# }
#
impl Guess {
    pub fn new(value: u32) -> Guess {
        if value < 1  {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }

        Guess {
            value: value,
        }
    }
}

如果运行列表 11-8 的测试,它会失败:

running 1 test
test tests::greater_than_100 ... FAILED

failures:

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured

这回并没有得到非常有用的信息,不过一旦我们观察测试函数,会发现它标注了#[should_panic]。这个错误意味着代码中函数Guess::new(200)并没有产生 panic。

然而should_panic测试可能是非常含糊不清的,因为他们只是告诉我们代码并没有产生 panic。should_panic甚至在测试因为其他不同的原因而不是我们期望发生的那个而 panic 时也会通过。为了使should_panic测试更精确,可以给should_panic属性增加一个可选的expected参数。测试工具会确保错误信息中包含其提供的文本。例如,考虑列表 11-9 中修改过的Guess,这里new函数更具其值是过大还或者过小而提供不同的 panic 信息:

Filename: src/lib.rs

struct Guess {
    value: u32,
}

impl Guess {
    pub fn new(value: u32) -> Guess {
        if value < 1 {
            panic!("Guess value must be greater than or equal to 1, got {}.",
                   value);
        } else if value > 100 {
            panic!("Guess value must be less than or equal to 100, got {}.",
                   value);
        }

        Guess {
            value: value,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "Guess value must be less than or equal to 100")]
    fn greater_than_100() {
        Guess::new(200);
    }
}

Listing 11-9: Testing that a condition will cause a panic! with a particular panic message

这个测试会通过,因为should_panic属性中expected参数提供的值是Guess::new函数 panic 信息的子字符串。我们可以指定期望的整个 panic 信息,在这个例子中是Guess value must be less than or equal to 100, got 200.。这依赖于 panic 有多独特或动态和你希望测试有多准确。在这个例子中,错误信息的子字符串足以确保函数在else if value > 100的情况下运行。

为了观察带有expected信息的should_panic测试失败时会发生什么,让我们再次引入一个 bug 来将if value < 1else if value > 100的代码块对换:

if value < 1 {
    panic!("Guess value must be less than or equal to 100, got {}.", value);
} else if value > 100 {
    panic!("Guess value must be greater than or equal to 1, got {}.", value);
}

这一次运行should_panic测试,它会失败:

running 1 test
test tests::greater_than_100 ... FAILED

failures:

---- tests::greater_than_100 stdout ----
    thread 'tests::greater_than_100' panicked at 'Guess value must be greater
    than or equal to 1, got 200.', src/lib.rs:10
note: Run with `RUST_BACKTRACE=1` for a backtrace.
note: Panic did not include expected string 'Guess value must be less than or
equal to 100'

failures:
    tests::greater_than_100

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured

错误信息表明测试确实如期望 panic 了,不过 panic 信息did not include expected string 'Guess value must be less than or equal to 100'。可以看到我们的到的 panic 信息,在这个例子中是Guess value must be greater than or equal to 1, got 200.。这样就可以开始寻找 bug 在哪了!

现在我们讲完了编写测试的方法,让我们看看运行测试时会发生什么并讨论可以用于cargo test的不同选项。

运行测试

ch11-02-running-tests.md
commit 55b294f20fc846a13a9be623bf322d8b364cee77

就像cargo run会编译代码并运行生成的二进制文件,cargo test在测试模式下编译代码并运行生成的测试二进制文件。这里有一些选项可以用来改变cargo test的默认行为。例如,cargo test生成的二进制文件的默认行为是并行的运行所有测试,并捕获测试运行过程中产生的输出避免他们被显示出来使得阅读测试结果相关的内容变得更容易。可以指定命令行参数来改变这些默认行为。

这些选项的一部分可以传递给cargo test,而另一些则需要传递给生成的测试二进制文件。为了分隔两种类型的参数,首先列出传递给cargo test的参数,接着是分隔符--,再之后是传递给测试二进制文件的参数。运行cargo test --help会告诉你cargo test的相关参数,而运行cargo test -- --help则会告诉你位于分隔符--之后的相关参数。

并行或连续的运行测试

当运行多个测试时,他们默认使用线程来并行的运行。这意味着测试会更快的运行完毕,所以可以更快的得到代码能否工作的反馈。因为测试是在同时运行的,你应该小心测试不能相互依赖或任何共享状态,包括类似于当前工作目录或者环境变量这样的共享环境。

例如,每一个测试都运行一些代码在硬盘上创建一个test-output.txt文件并写入一些数据。接着每一个测试都读取文件中的数据并断言这个文件包含特定的值,而这个值在每个测试中都是不同的。因为所有测试都是同时运行的,一个测试可能会在另一个测试读写文件过程中覆盖了文件。那么第二个测试就会失败,并不是因为代码不正确,而是因为测试并行运行时相互干涉。一个解决方案是使每一个测试读写不同的文件;另一个是一次运行一个测试。

如果你不希望测试并行运行,或者想要更加精确的控制使用线程的数量,可以传递--test-threads参数和希望使用线程的数量给测试二进制文件。例如:

$ cargo test -- --test-threads=1

这里将测试线程设置为 1,告诉程序不要使用任何并行机制。这也会比并行运行花费更多时间,不过测试就不会在存在共享状态时潜在的相互干涉了。

显示测试输出

如果测试通过了,Rust 的测试库默认会捕获打印到标准输出的任何内容。例如,如果在测试中调用println!而测试通过了,我们将不会在终端看到println!的输出:只会看到说明测试通过的行。如果测试失败了,就会看到任何标准输出和其他错误信息。

例如,列表 11-20 有一个无意义的函数它打印出其参数的值并接着返回 10。接着还有一个会通过的测试和一个会失败的测试:

Filename: src/lib.rs

fn prints_and_returns_10(a: i32) -> i32 {
    println!("I got the value {}", a);
    10
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn this_test_will_pass() {
        let value = prints_and_returns_10(4);
        assert_eq!(10, value);
    }

    #[test]
    fn this_test_will_fail() {
        let value = prints_and_returns_10(8);
        assert_eq!(5, value);
    }
}

Listing 11-10: Tests for a function that calls println!

运行cargo test将会看到这些测试的输出:

running 2 tests
test tests::this_test_will_pass ... ok
test tests::this_test_will_fail ... FAILED

failures:

---- tests::this_test_will_fail stdout ----
    I got the value 8
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left ==
right)` (left: `5`, right: `10`)', src/lib.rs:19
note: Run with `RUST_BACKTRACE=1` for a backtrace.

failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured

注意输出中哪里也不会出现I got the value 4,这是当测试通过时打印的内容。这些输出被捕获。失败测试的输出,I got the value 8,则出现在输出的测试总结部分,它也显示了测试失败的原因。

如果你希望也能看到通过的测试中打印的值,捕获输出的行为可以通过--nocapture参数来禁用:

$ cargo test -- --nocapture

使用--nocapture参数再次运行列表 11-10 中的测试会显示:

running 2 tests
I got the value 4
I got the value 8
test tests::this_test_will_pass ... ok
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left ==
right)` (left: `5`, right: `10`)', src/lib.rs:19
note: Run with `RUST_BACKTRACE=1` for a backtrace.
test tests::this_test_will_fail ... FAILED

failures:

failures:
    tests::this_test_will_fail

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured

注意测试的输出和测试结果的输出是相互交叉的;这是由于上一部分讲到的测试是并行运行的。尝试一同使用--test-threads=1--nocapture功能来看看输出是什么样子!

通过名称来运行测试的子集

有时运行整个测试集会耗费很多时间。如果你负责特定位置的代码,你可能会希望只与这些代码相关的测试。可以向cargo test传递希望运行的测试的(部分)名称作为参数来选择运行哪些测试。

为了展示如何运行测试的子集,列表 11-11 使用add_two函数创建了三个测试来供我们选择运行哪一个:

Filename: src/lib.rs

pub fn add_two(a: i32) -> i32 {
    a + 2
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn add_two_and_two() {
        assert_eq!(4, add_two(2));
    }

    #[test]
    fn add_three_and_two() {
        assert_eq!(5, add_two(3));
    }

    #[test]
    fn one_hundred() {
        assert_eq!(102, add_two(100));
    }
}

Listing 11-11: Three tests with a variety of names

如果没有传递任何参数就运行测试,如你所见,所有测试都会并行运行:

running 3 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok
test tests::one_hundred ... ok

test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured

运行单个测试

可以向cargo test传递任意测试的名称来只运行这个测试:

$ cargo test one_hundred
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-06a75b4a1f2515e9

running 1 test
test tests::one_hundred ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

不能像这样指定多个测试名称,只有传递给cargo test的第一个值才会被使用。

过滤运行多个测试

然而,可以指定测试的部分名称,这样任何名称匹配这个值的测试会被运行。例如,因为头两个测试的名称包含add,可以通过cargo test add来运行这两个测试:

$ cargo test add
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-06a75b4a1f2515e9

running 2 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured

这运行了所有名字中带有add的测试。同时注意测试所在的模块作为测试名称的一部分,所以可以通过模块名来过滤运行一个模块中的所有测试。

除非指定否则忽略某些测试

有时一些特定的测试执行起来是非常耗费时间的,所以在运行大多数cargo test的时候希望能排除他们。与其通过参数列举出所有希望运行的测试,也可以使用ignore属性来标记耗时的测试来排除他们:

Filename: src/lib.rs

#[test]
fn it_works() {
    assert!(true);
}

#[test]
#[ignore]
fn expensive_test() {
    // code that takes an hour to run
}

我们对想要排除的测试的#[test]之后增加了#[ignore]行。现在如果运行测试,就会发现it_works运行了,而expensive_test没有运行:

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished debug [unoptimized + debuginfo] target(s) in 0.24 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 2 tests
test expensive_test ... ignored
test it_works ... ok

test result: ok. 1 passed; 0 failed; 1 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

expensive_test被列为ignored,如果只希望运行被忽略的测试,可以使用cargo test -- --ignored来请求运行他们:

$ cargo test -- --ignored
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/adder-ce99bcc2479f4607

running 1 test
test expensive_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

通过控制运行哪些测试,可以确保运行cargo test的结果是快速的。当某个时刻需要检查ignored测试的结果而且你也有时间等待这个结果的话,可以选择执行cargo test -- --ignored

测试的组织结构

ch11-03-test-organization.md
commit 55b294f20fc846a13a9be623bf322d8b364cee77

正如之前提到的,测试是一个很广泛的学科,而且不同的开发者也采用不同的技术和组织。Rust 社区倾向于根据测试的两个主要分类来考虑问题:单元测试unit tests)与集成测试integration tests)。单元测试倾向于更小而更专注,在隔离的环境中一次测试一个模块。他们也可以测试私有接口。集成测试对于你的库来说则完全是外部的。他们与其他用户使用相同的方式使用你的代码,他们只针对公有接口而且每个测试都会测试多个模块。

这两类测试对于从独立和整体的角度保证你的库符合期望是非常重要的。

单元测试

单元测试的目的是在与其他部分隔离的环境中测试每一个单元的代码,以便于快速而准确的定位何处的代码是否符合预期。单元测试位于 src 目录中,与他们要测试的代码存在于相同的文件中。传统做法是在每个文件中创建包含测试函数的tests模块,并使用cfg(test)标注模块。

测试模块和cfg(test)

测试模块的#[cfg(test)]注解告诉 Rust 只在执行cargo test时才编译和运行测试代码,而在运行cargo build时不这么做。这在只希望构建库的时候可以节省编译时间,并能节省编译产物的空间因为他们并没有包含测试。我们将会看到因为集成测试位于另一个文件夹,他们并不需要#[cfg(test)]注解。但是因为单元测试位于与源码相同的文件中,所以使用#[cfg(test)]来指定他们不应该被包含进编译产物中。

还记得本章第一部分新建的adder项目吗?Cargo 为我们生成了如下代码:

Filename: src/lib.rs

#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
    }
}

这里自动生成了测试模块。cfg属性代表 configuration ,它告诉 Rust 其之后的项只被包含进特定配置中。在这个例子中,配置是test,Rust 所提供的用于编译和运行测试的配置。通过使用这个属性,Cargo 只会在我们主动使用cargo test运行测试时才编译测试代码。除了标注为#[test]的函数之外,这还包括测试模块中可能存在的帮助函数。

测试私有函数

测试社区中一直存在关于是否应该对私有函数进行单元测试的论战,而其他语言中难以甚至不可能测试私有函数。不过无论你坚持哪种测试意识形态,Rust 的私有性规则确实允许你测试私有函数,由于私有性规则。考虑列表 11-12 中带有私有函数internal_adder的代码:

Filename: src/lib.rs

pub fn add_two(a: i32) -> i32 {
    internal_adder(a, 2)
}

fn internal_adder(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn internal() {
        assert_eq!(4, internal_adder(2, 2));
    }
}

Listing 11-12: Testing a private function

注意internal_adder函数并没有标记为pub,不过因为测试也不过是 Rust 代码而tests也仅仅是另一个模块,我们完全可以在测试中导入和调用internal_adder。如果你并不认为私有函数应该被测试,Rust 也不会强迫你这么做。

集成测试

在 Rust 中,集成测试对于需要测试的库来说是完全独立。他们同其他代码一样使用库文件,这意味着他们只能调用作为库公有 API 的一部分的函数。他们的目的是测试库的多个部分能否一起正常工作。每个能单独正确运行的代码单元集成在一起也可能会出现问题,所以集成测试的覆盖率也是很重要的。为了创建集成测试,首先需要一个 tests 目录。

tests 目录

为了编写集成测试,需要在项目根目录创建一个 tests 目录,与 src 同级。Cargo 知道如何去寻找这个目录中的集成测试文件。接着可以随意在这个文件夹中创建任意多的测试文件,Cargo 会将每一个文件当作单独的 crate 来编译。

让我们试一试吧!保留列表 11-12 中 src/lib.rs 的代码。创建一个 tests 目录,新建一个文件 tests/integration_test.rs,并输入列表 11-13 中的代码。

Filename: tests/integration_test.rs

extern crate adder;

#[test]
fn it_adds_two() {
    assert_eq!(4, adder::add_two(2));
}

Listing 11-13: An integration test of a function in the adder crate

我们在顶部增加了extern crate adder,这在单元测试中是不需要的。这是因为每一个tests目录中的测试文件都是完全独立的 crate,所以需要在每一个文件中导入库。集成测试就像其他使用者那样通过导入 crate 并只使用公有 API 来使用库文件。

并不需要将 tests/integration_test.rs 中的任何代码标注为#[cfg(test)]。Cargo 对tests文件夹特殊处理并只会在运行cargo test时编译这个目录中的文件。现在就试试运行cargo test

cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished debug [unoptimized + debuginfo] target(s) in 0.31 secs
     Running target/debug/deps/adder-abcabcabc

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

     Running target/debug/deps/integration_test-ce99bcc2479f4607

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

现在有了三个部分的输出:单元测试、集成测试和文档测试。第一部分单元测试与我们之前见过的一样:每一个单元测试一行(列表 11-12 中有一个叫做internal的测试),接着是一个单元测试的总结行。

集成测试部分以行Running target/debug/deps/integration-test-ce99bcc2479f4607(输出最后的哈希值可能不同)开头。接着是每一个集成测试中的测试函数一行,以及一个就在Doc-tests adder部分开始之前的集成测试的总结行。

注意在任意 src 文件中增加更多单元测试函数会增加更多单元测试部分的测试结果行。在我们创建的集成测试文件中增加更多测试函数会增加更多集成测试部分的行。每一个集成测试文件有其自己的部分,所以如果在 tests 目录中增加更多文件,这里就会有更多集成测试部分。

我们仍然可以通过指定测试函数的名称作为cargo test的参数来运行特定集成测试。为了运行某个特定集成测试文件中的所有测试,使用cargo test--test后跟文件的名称:

$ cargo test --test integration_test
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/integration_test-952a27e0126bb565

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

这些只是 tests 目录中我们指定的文件中的测试。

集成测试中的子模块

随着集成测试的增加,你可能希望在 tests 目录增加更多文件,例如根据测试的功能来将测试分组。正如我们之前提到的,每一个 tests 目录中的文件都被编译为单独的 crate。

将每个集成测试文件当作其自己的 crate 来对待有助于创建更类似与终端用户使用 crate 那样的单独的作用域。然而,这意味着考虑到像第七章学习的如何将代码分隔进模块和文件那样,tests 目录中的文件不能像 src 中的文件那样共享相同的行为。

对于 tests 目录中文件的不同行为,通常在如果有一系列有助于多个集成测试文件的帮助函数,而你尝试遵循第七章的步骤将他们提取到一个通用的模块中时显得很明显。例如,如果我们创建了 tests/common.rs 并将setup函数放入其中,这里将放入一些希望能够在多个测试文件的多个测试函数中调用的代码:

Filename: tests/common.rs

pub fn setup() {
    // setup code specific to your library's tests would go here
}

如果再次运行测试,将会在测试结果中看到一个对应 common.rs 文件的新部分,即便这个文件并没有包含任何测试函数,或者没有任何地方调用了setup函数:

running 1 test
test tests::internal ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

     Running target/debug/deps/common-b8b07b6f1be2db70

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

     Running target/debug/deps/integration_test-d993c68b431d39df

running 1 test
test it_adds_two ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests adder

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

common出现在测试结果中并显示running 0 tests,这不是我们想要的;我们只是希望能够在其他集成测试文件中分享一些代码罢了。

为了使common不出现在测试输出中,需要使用第七章学习到的另一个将代码提取到文件的方式:不再创建tests/common.rs,而是创建 tests/common/mod.rs。当将setup代码移动到 tests/common/mod.rs 并去掉 tests/common.rs 文件之后,测试输出中将不会出现这一部分。tests 目录中的子目录不会被作为单独的 crate 编译或作为一部分出现在测试输出中。

一旦拥有了 tests/common/mod.rs,就可以将其作为模块来在任何集成测试文件中使用。这里是一个 tests/integration_test.rs 中调用setup函数的it_adds_two测试的例子:

Filename: tests/integration_test.rs

extern crate adder;

mod common;

#[test]
fn it_adds_two() {
    common::setup();
    assert_eq!(4, adder::add_two(2));
}

注意mod common;声明与第七章中的模块声明相同。接着在测试函数中就可以调用common::setup()了。

二进制 crate 的集成测试

如果项目是二进制 crate 并且只包含 src/main.rs 而没有 src/lib.rs,这样就不可能在 tests 创建集成测试并使用 extern crate 导入 src/main.rs 中的函数了。只有库 crate 向其他 crate 暴露了可以调用和使用的函数;二进制 crate 只意在单独运行。

这也是 Rust 二进制项目明确采用 src/main.rs 调用 src/lib.rs 中逻辑这样的结构的原因之一。通过这种结构,集成测试就可以使用extern crate测试库 crate 中的主要功能,而如果这些重要的功能没有问题的话,src/main.rs 中的少量代码也就会正常工作且不需要测试。

总结

Rust 的测试功能提供了一个确保即使做出改变函数也能继续以指定方式运行的途径。单元测试独立的验证库的不同部分并能够测试私有实现细节。集成测试则涉及多个部分结合起来工作时的用例,并像其他代码那样测试库的公有 API。即使 Rust 的类型系统和所有权规则可以帮助避免一些 bug,不过测试对于减少代码是否符合期望相关的逻辑 bug 是很重要的。

接下来让我们结合本章所学和其他之前章节的知识,在下一章一起编写一个项目!

一个 I/O 项目:构建一个小巧的 grep

ch12-00-an-io-project.md
commit 1f432fc231cfbc310433ab2a354d77058444288c

本章既是一个目前所学的很多技能的概括,也是一个更多标准库功能的探索。我们将构建一个与文件和命令行输入/输出交互的命令行工具来练习现在一些你已经掌握的 Rust 技能。

Rust 的运行速度、安全性、“单二进制文件”输出和跨平台支持使其成为创建命令行程序的绝佳选择,所以我们的项目将创建一个我们自己版本的经典命令行工具:grep。grep 是“Globally search a Regular Expression and Print.”的首字母缩写。grep最简单的使用场景是使用如下步骤在特定文件中搜索指定字符串:

  • 获取一个文件和一个字符串作为参数。
  • 读取文件
  • 寻找文件中包含字符串参数的行
  • 打印出这些行

我们还会展示如何使用环境变量和打印到标准错误而不是标准输出;这些功能在命令行工具中是很常用的。

一位 Rust 社区的成员,Andrew Gallant,已经创建了一个功能完整且非常快速的grep版本,叫做ripgrep。相比之下,我们的grep将非常简单,本章将交给你一些帮助你理解像ripgrep这样真实项目的背景知识。

这个项目将会结合之前所学的一些内容:

  • 代码组织(使用第七章学习的模块)
  • vector 和字符串(第八章,集合)
  • 错误处理(第九章)
  • 合理的使用 trait 和生命周期(第十章)
  • 测试(第十一章)

另外,我还会简要的讲到闭包、迭代器和 trait 对象,他们分别会在第十三章和第十七章中详细介绍。

让我们一如既往的使用cargo new创建一个新项目。我们称之为greprs以便与可能已经安装在系统上的grep工具相区别:

$ cargo new --bin greprs
     Created binary (application) `greprs` project
$ cd greprs

接受命令行参数

ch12-01-accepting-command-line-arguments.md
commit b8e4fcbf289b82c12121b282747ce05180afb1fb

第一个任务是让greprs能够接受两个命令行参数:文件名和要搜索的字符串。也就是说希望能够使用cargo run,要搜索的字符串和被搜索的文件的路径来运行程序,像这样:

$ cargo run searchstring example-filename.txt

现在cargo new生成的程序忽略任何传递给它的参数。crates.io 上有一些现存的可以帮助我们接受命令行参数的库,不过因为我们正在学习,让我们实现一个。

读取参数值

为了能够获取传递给程序的命令行参数的值,我们需要调用一个 Rust 标准库提供的函数:std::env::args。这个函数返回一个传递给程序的命令行参数的迭代器iterator)。我们还未讨论到迭代器,第十三章会全面的介绍他们。但是对于我们现在的目的来说只需要明白两点:

  1. 迭代器生成一系列的值。
  2. 在迭代器上调用collect方法可以将其生成的元素转换为一个 vector。

让我们尝试一下:使用列表 12-1 中的代码来读取任何传递给greprs的命令行参数并将其收集到一个 vector 中。

Filename: src/main.rs

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

Listing 12-1: Collect the command line arguments into a vector and print them out

首先使用use语句来将std::env模块引入作用域以便可以使用它的args函数。注意std::env::args函数嵌套进了两层模块中。如第七章讲到的,当所需函数嵌套了多于一层模块时,通常将父模块引入作用域,而不是其自身。这便于我们利用std::env中的其他函数。这比增加了use std::env::args;后仅仅使用args调用函数要更明确一些;这样看起来好像一个定义于当前模块的函数。

注意:std::env::args在其任何参数包含无效 Unicode 字符时会 panic。如果你需要接受包含无效 Unicode 字符的参数,使用std::env::args_os代替。这个函数返回OsString值而不是String值。出于简单考虑这里使用std::env::args,因为OsString值每个平台都不一样而且比String值处理起来更复杂。

main函数的第一行,我们调用了env::args,并立即使用collect来创建了一个包含迭代器所有值的 vector。collect可以被用来创建很多类型的集合,所以这里显式注明的args类型来指定我们需要一个字符串 vector。虽然在 Rust 中我们很少会需要注明类型,collect就是一个经常需要注明类型的函数,因为 Rust 不能推断出你想要什么类型的集合。

最后,我们使用调试格式:?打印出 vector。让我们尝试不用参数运行代码,接着用两个参数:

$ cargo run
["target/debug/greprs"]

$ cargo run needle haystack
...snip...
["target/debug/greprs", "needle", "haystack"]

你可能注意到了 vector 的第一个值是"target/debug/greprs",它是我们二进制文件的名称。其原因超出了本章介绍的范围,不过需要记住的是我们保存了所需的两个参数。

将参数值保存进变量

打印出参数 vector 中的值仅仅展示了可以访问程序中指定为命令行参数的值。但是这并不是我们想要做的,我们希望将这两个参数的值保存进变量这样就可以在程序使用这些值。让我们如列表 12-2 这样做:

Filename: src/main.rs

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let filename = &args[2];

    println!("Searching for {}", query);
    println!("In file {}", filename);
}

Listing 12-2: Create variables to hold the query argument and filename argument

正如我们在打印出 vector 时所看到的,程序的名称占据了 vector 的第一个值args[0],所以我们从索引1开始。第一个参数greprs是需要搜索的字符串,所以将其将第一个参数的引用存放在变量query中。第二个参数将是文件名,所以将第二个参数的引用放入变量filename中。

我们将临时打印出出这些变量的值,再一次证明代码如我们期望的那样工作。让我们使用参数testsample.txt再次运行这个程序:

$ cargo run test sample.txt
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs test sample.txt`
Searching for test
In file sample.txt

好的,它可以工作!我们将所需的参数值保存进了对应的变量中。之后会增加一些错误处理来应对类似用户没有提供参数的情况,不过现在我们将忽略他们并开始增加读取文件功能。

读取文件

ch12-02-reading-a-file.md
commit b8e4fcbf289b82c12121b282747ce05180afb1fb

接下来我们将读取由命令行文件名参数指定的文件。首先,需要一个用来测试的示例文件——用来确保greprs正常工作的最好的文件是拥有少量文本和多个行且有一些重复单词的文件。列表 12-3 是一首艾米莉·狄金森(Emily Dickinson)的诗,它正适合这个工作!在项目根目录创建一个文件poem.txt,并输入诗 "I'm nobody! Who are you?":

Filename: poem.txt

I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us — don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

Listing 12-3: The poem "I'm nobody! Who are you?" by Emily Dickinson that will make a good test case

创建完这个文件之后,修改 src/main.rs 并增加如列表 12-4 所示的打开文件的代码:

Filename: src/main.rs

use std::env;
use std::fs::File;
use std::io::prelude::*;

fn main() {
    let args: Vec<String> = env::args().collect();

    let query = &args[1];
    let filename = &args[2];

    println!("Searching for {}", query);
    println!("In file {}", filename);

    let mut f = File::open(filename).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    println!("With text:\n{}", contents);
}

Listing 12-4: Reading the contents of the file specified by the second argument

首先,增加了更多的use语句来引入标准库中的相关部分:需要std::fs::File来处理文件,而std::io::prelude::*则包含许多对于 I/O 包括文件 I/O 有帮助的 trait。类似于 Rust 有一个通用的 prelude 来自动引入特定内容,std::io也有其自己的 prelude 来引入处理 I/O 时所需的通用内容。不同于默认的 prelude,必须显式use位于std::io中的 prelude。

main中,我们增加了三点内容:第一,通过传递变量filename的值调用File::open函数的值来获取文件的可变句柄。创建了叫做contents的变量并将其设置为一个可变的,空的String。它将会存放之后读取的文件的内容。第三,对文件句柄调用read_to_string并传递contents的可变引用作为参数。

在这些代码之后,我们再次增加了临时的println!打印出读取文件后contents的值,这样就可以检查目前为止的程序能否工作。

尝试运行这些代码,随意指定一个字符串作为第一个命令行参数(因为还未实现搜索功能的部分)而将 poem.txt 文件将作为第二个参数:

$ cargo run the poem.txt
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us — don't tell!
They'd banish us, you know.

How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!

好的!代码读取并打印出了文件的内容。虽然它还有一些瑕疵:main函数有着多个功能,同时也没有处理可能出现的错误。虽然我们的程序还很小,这些瑕疵并不是什么大问题。不过随着程序功能的丰富,将会越来越难以用简单的方法修复他们。在开发程序时,及早开始重构是一个最佳实践,因为重构少量代码时要容易的多,所以让我们现在就开始吧。

重构改进模块性和错误处理

ch12-03-improving-error-handling-and-modularity.md
commit b8e4fcbf289b82c12121b282747ce05180afb1fb

为了改善我们的程序这里有四个问题需要修复,而且他们都与程序的组织方式和如何处理潜在错误有关。

第一,main现在进行了两个任务:它解析了参数并打开了文件。对于一个这样的小函数,这并不是一个大问题。然而如果main中的功能持续增加,main函数处理的单独的任务也会增加。当函数承担了更多责任,它就更难以推导,更难以测试,并且更难以在不破坏其他部分的情况下做出修改。最好能分离出功能这样每个函数就负责一个任务。

这同时也关系到第二个问题:searchfilename是程序中的配置变量,而像fcontents则用来执行程序逻辑。随着main函数的增长,就需要引入更多的变量到作用域中,而当作用域中有更多的变量时,将更难以追踪每个变量的目的。最好能将将配置变量组织进一个结构这样就能使他们的目的更明确了。

第三个问题是如果打开文件失败我们使用expect来打印出错误信息,不过这个错误信息只是说file not found。除了缺少文件之外还有很多打开文件可能失败的方式:例如,文件可能存在,不过可能没有打开它的权限。如果我们现在就出于这种情况,打印出的file not found错误信息就给了用户一个不符合事实的建议!

第四,我们不停的使用expect来处理不同的错误,如果用户没有指定足够的参数来运行程序,他们会从 Rust 得到 "index out of bounds" 错误而这并不能明确的解释问题。如果所有的错误处理都位于一处这样将来的维护者在需要修改错误处理逻辑时就只需要咨询一处代码。将所有的错误处理都放在一处也有助于确保我们打印的错误信息对终端用户来说是有意义的。

让我们通过重构项目来解决这些问题。

二进制项目的关注分离

main函数负责多个任务的组织问题在许多二进制项目中很常见。所以 Rust 社区开发出一个类在main函数开始变得庞大时进行二进制程序的关注分离的指导性过程。这些过程有如下步骤:

  1. 将程序拆分成 main.rslib.rs 并将程序的逻辑放入 lib.rs 中。
  2. 当命令行解析逻辑比较小时,可以保留在 main.rs 中。
  3. 当命令行解析开始变得复杂时,也同样将其从 main.rs 提取到 lib.rs中。
  4. 经过这些过程之后保留在main函数中的责任是:
    • 使用参数值调用命令行解析逻辑
    • 设置任何其他的配置
    • 调用 lib.rs 中的run函数
    • 如果run返回错误,则处理这个错误

这个模式的一切就是为了关注分离:main.rs 处理程序运行,而 lib.rs 处理所有的真正的任务逻辑。因为不能直接测试main函数,这个结构通过将所有的程序逻辑移动到 lib.rs 的函数中使得我们可以测试他们。仅仅保留在 main.rs 中的代码将足够小以便阅读就可以验证其正确性。

提取参数解析器

首先,我们将提取解析参数的功能。列表 12-5 中展示了新main函数的开头,它调用了新函数parse_config。目前它仍将定义在 src/main.rs 中:

Filename: src/main.rs

fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, filename) = parse_config(&args);

    // ...snip...
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
}

Listing 12-5: Extract a parse_config function from main

我们仍然将命令行参数收集进一个 vector,不过不同于在main函数中将索引 1 的参数值赋值给变量query和将索引 2 的值赋值给变量filename,我们将整个 vector 传递给parse_config函数。接着parse_config函数将包含知道哪个参数该放入哪个变量的逻辑,并将这些值返回到main。仍然在main中创建变量queryfilename,不过main不再负责处理命令行参数与变量如何对应。

这对我们这小程序可能有点大材小用,不过我们将采用小的、增量的步骤进行重构。在做出这些改变之后,再次运行程序并验证参数解析是否仍然正常。经常验证你的进展是一个好习惯,这样在遇到问题时能帮助你定位问题的成因。

组合配置值

我们可以采取另一个小的步骤来进一步改善这个函数。现在函数返回一个元组,不过立刻又就将元组拆成了独立的部分。这是一个我们可能没有进行正确抽象的信号。

另一个表明还有改进空间的迹象是parse_configconfig部分,它暗示了我们返回的两个值是相关的并都是一个配置值的一部分。目前除了将这两个值组合进元组之外并没有表达这个数据结构的意义:我们可以将这两个值放入一个结构体并给每个字段一个有意义的名字。这会让未来的维护者更容易理解不同的值如何相互关联以及他们的目的。

注意:一些同学将这种拒绝使用相对而言更为合适的复合类型而使用基本类型的模式称为基本类型偏执primitive obsession)。

列表 12-6 展示了新定义的结构体Config,它有字段queryfilename。我们也改变了parse_config函数来返回一个Config结构体的实例,并更新main来使用结构体字段而不是单独的变量:

Filename: src/main.rs

# use std::env;
# use std::fs::File;
#
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let mut f = File::open(config.filename).expect("file not found");

    // ...snip...
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let filename = args[2].clone();

    Config {
        query: query,
        filename: filename,
    }
}

Listing 12-6: Refactoring parse_config to return an instance of a Config struct

parse_config的签名现在表明它返回一个Config值。在parse_config的函数体中,之前返回了argsString值引用的字符串 slice,现在我们选择定义Config来使用拥有所有权的String值。main中的args变量是参数值的所有者并只允许parse_config函数借用他们,这意味着如果Config尝试获取args中值的所有权将违反 Rust 的借用规则。

还有许多不同的方式可以处理String的数据,而最简单但有些不太高效的方式是调用这些值的clone方法。这会生成Config实例可以拥有的数据的完整拷贝,不过会比储存字符串数据的引用消耗更多的时间和内存。不过拷贝数据使得代码显得更加直白因为无需管理引用的生命周期,所以在这种情况下牺牲一小部分性能来换取简洁性的取舍是值得的。

使用clone权衡取舍

由于其运行时消耗,许多 Rustacean 之间有一个趋势是倾向于避免使用clone来解决所有权问题。在关于迭代器的第十三章中,我们将会学习如何更有效率的处理这种情况,不过现在,复制一些字符串来取得进展是没有问题的,因为只会进行一次这样的拷贝,而且文件名和要搜索的字符串都比较短。在第一轮编写时拥有一个可以工作但有点低效的程序要比尝试过度优化代码更好一些。随着你对 Rust 更加熟练,将能更轻松的直奔合适的方法,不过现在调用clone是完全可以接受的。

我们更新mainparse_config返回的Config实例放入变量config中,并更新之前分别使用searchfilename变量的代码为现在的使用Config结构体的字段。

现在代码更明确的表现了我们的意图,queryfilename是相关联的并且他们的目的是配置程序如何工作的。任何使用这些值的代码就知道在config实例中对应目的的字段名中寻找他们。

创建一个Config构造函数

目前为止,我们将负责解析命令行参数的逻辑从main提取到了parse_config函数中,这帮助我们看清值queryfilename是相互关联的并应该在代码中表现这种关系。接着我们增加了Config结构体来命名queryfilename的相关目的,并能够从parse_config函数中将这些值的名称作为结构体字段名称返回。

所以现在parse_config函数的目的是创建一个Config实例,我们可以将parse_config从一个普通函数变为一个叫做new的与结构体关联的函数。做出这个改变使得代码更符合习惯:可以像标准库中的String调用String::new来创建一个该类型的实例那样,将parse_config变为一个与Config关联的new函数。列表 12-7 展示了需要做出的修改:

Filename: src/main.rs

# use std::env;
#
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    // ...snip...
}

# struct Config {
#     query: String,
#     filename: String,
# }
#
// ...snip...

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let filename = args[2].clone();

        Config {
            query: query,
            filename: filename,
        }
    }
}

Listing 12-7: Changing parse_config into Config::new

这里将main中调用parse_config的地方更新为调用Config::new。我们将parse_config的名字改为new并将其移动到impl块中,这使得new函数与Config相关联。再次尝试编译并确保它可以工作。

修复错误处理

现在我们开始修复错误处理。回忆一下之前提到过如果args vector 包含少于 3 个项并尝试访问 vector 中索引 1 或 索引 2 的值会造成程序 panic。尝试不带任何参数运行程序;这将看起来像这样:

$ cargo run
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs`
thread 'main' panicked at 'index out of bounds: the len is 1
but the index is 1',  /stable-dist-rustc/build/src/libcollections/vec.rs:1307
note: Run with `RUST_BACKTRACE=1` for a backtrace.

index out of bounds: the len is 1 but the index is 1是一个针对程序员的错误信息,然而这并不能真正帮助终端用户理解发生了什么和他们应该做什么。现在就让我们修复它吧。

改善错误信息

在列表 12-8 中,在new函数中增加了一个检查在访问索引 1 和 2 之前检查 slice 是否足够长。如果 slice 不够长,我们使用一个更好的错误信息 panic 而不是index out of bounds信息:

Filename: src/main.rs

// ...snip...
fn new(args: &[String]) -> Config {
    if args.len() < 3 {
        panic!("not enough arguments");
    }
    // ...snip...

Listing 12-8: Adding a check for the number of arguments

这类似于列表 9-8 中的Guess::new函数,那里如果value参数超出了有效值的范围就调用panic!。不同于检查值的范围,这里检查args的长度至少是 3,而函数的剩余部分则可以假设这个条件成立的基础上运行。如果 args少于 3 个项,这个条件将为真,并调用panic!立即终止程序。

有了new中这几行额外的代码,再次不带任何参数运行程序并看看现在错误看起来像什么:

$ cargo run
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs`
thread 'main' panicked at 'not enough arguments', src/main.rs:29
note: Run with `RUST_BACKTRACE=1` for a backtrace.

这个输出就好多了,现在有了一个合理的错误信息。然而,我们还有一堆额外的信息不希望提供给用户。所以在这里使用列表 9-8 中的技术可能不是最好的;无论如何panic!调用更适合程序问题而不是使用问题,正如第九章所讲到的。相反我们可以使用那一章学习的另一个技术:返回一个可以表明成功或错误的Result

new中返回Result而不是调用panic!

我们可以选择返回一个Result值,它在成功时会包含一个Config的实例,而在错误时会描述问题。当Config::newmain交流时,在使用Result类型存在问题时可以使用 Rust 的信号方式。接着修改mainErr成员转换为对用户更友好的错误,而不是panic!调用产生的关于thread 'main'RUST_BACKTRACE的文本。

列表 12-9 展示了Config::new返回值和函数体中返回Result所需的改变:

Filename: src/main.rs

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config {
            query: query,
            filename: filename,
        })
    }
}

Listing 12-9: Return a Result from Config::new

现在new函数返回一个Result,在成功时带有一个Config实例而在出现错误时带有一个&'static str。回忆一下第十章“静态声明周期”中讲到&'static str是一个字符串字面值,也是目前的错误信息。

new函数体中有两处修改:当没有足够参数时不再调用panic!,而是返回Err值。同时我们将Config返回值包装进Ok成员中。这些修改使得函数符合其新的类型签名。

通过让Config::new返回一个Err值,这就允许main函数处理new函数返回的Result值并在出现错误的情况更明确的结束进程。

Config::new调用并处理错误

为了处理错误情况并打印一个对用户友好的信息,我们需要像列表 12-10 那样更新main函数来处理现在Config::new返回的Result。另外还需要实现一些panic!替我们处理的问题:使用错误码 1 退出命令行工具。非零的退出状态是一个告诉调用程序的进程我们的程序以错误状态退出的惯例信号。

Filename: src/main.rs

use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // ...snip...

Listing 12-10: Exiting with an error code if creating a new Config fails

在上面的列表中,使用了一个之前没有涉及到的方法:unwrap_or_else,它定义于标准库的Result<T, E>上。使用unwrap_or_else可以进行一些自定义的非panic!的错误处理。当ResultOk时,这个方法的行为类似于unwrap:它返回Ok内部封装的值。然而,当ResultErr时,它调用一个闭包closure),也就是一个我们定义的作为参数传递给unwrap_or_else的匿名函数。第十三章会更详细的介绍闭包。现在你需要理解的是unwrap_or_else会将Err的内部值,也就是列表 12-9 中增加的not enough arguments静态字符串的情况,传递给闭包中位于两道竖线间的参数err。闭包中的代码在其运行时可以使用这个err值。

我们新增了一个use行来从标准库中导入process。在错误的情况闭包中将被运行的代码只有两行:我们打印出了err值,接着调用了std::process::exit(在开头增加了新的use行从标准库中导入了process)。process::exit会立即停止程序并将传递给它的数字作为返回状态码。这类似于列表 12-8 中使用的基于panic!的错误处理,除了不会在得到所有的额外输出了。让我们试试:

$ cargo run
   Compiling greprs v0.1.0 (file:///projects/greprs)
    Finished debug [unoptimized + debuginfo] target(s) in 0.48 secs
     Running `target/debug/greprs`
Problem parsing arguments: not enough arguments

非常好!现在输出对于用户来说就友好多了。

提取run函数

现在我们完成了配置解析的重构:让我们转向程序的逻辑。正如“二进制项目的关注分离”部分的讨论所留下的过程,我们将提取一个叫做run的函数来存放目前main函数中不属于设置配置或处理错误的所有逻辑。一旦完成这些,main函数将简明的足以通过观察来验证,而我们将能够为所有其他逻辑编写测试。

列表 12-11 展示了提取出来的run函数。目前我们只进行小的增量式的提取函数的改进并仍将在 src/main.rs 中定义这个函数:

Filename: src/main.rs

fn main() {
    // ...snip...

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    run(config);
}

fn run(config: Config) {
    let mut f = File::open(config.filename).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents).expect("something went wrong reading the file");

    println!("With text:\n{}", contents);
}

// ...snip...

Listing 12-11: Extracting a run function containing the rest of the program logic

现在run函数包含了main中从读取文件开始的剩余的所有逻辑。run函数获取一个Config实例作为参数。

run函数中返回错误

通过将剩余的逻辑分离进run函数而不是留在main中,就可以像列表 12-9 中的Config::new那样改进错误处理。不再通过通过expect允许程序 panic,run函数将会在出错时返回一个Result<T, E>。这让我们进一步以一种对用户友好的方式统一main中的错误处理。列表 12-12 展示了run签名和函数体中的变化:

Filename: src/main.rs

use std::error::Error;

// ...snip...

fn run(config: Config) -> Result<(), Box<Error>> {
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    println!("With text:\n{}", contents);

    Ok(())
}

Listing 12-12: Changing the run function to return Result

这里做出了三个大的改变。第一,改变了run函数的返回值为Result<(), Box<Error>>。之前这个函数返回 unit 类型(),现在它仍然保持作为Ok时的返回值。

对于错误类型,使用了trait 对象Box<Error>(在开头使用了use语句将std::error::Error引入作用域)。第十七章会涉及 trait 对象。目前只需知道Box<Error>意味着函数会返回实现了Error trait 的类型,不过无需指定具体将会返回的值的类型。这提供了在不同的错误场景可能有不同类型的错误返回值的灵活性。

第二个改变是去掉了expect调用并替换为第九章讲到的?。不同于遇到错误就panic!,这会从函数中返回错误值并让调用者来处理它。

第三个修改是现在成功时这个函数会返回一个Ok值。因为run函数签名中声明成功类型返回值是(),这意味着需要将 unit 类型值包装进Ok值中。Ok(())一开始看起来有点奇怪,不过这样使用()是表明我们调用run只是为了它的副作用的惯用方式;它并没有返回什么有意义的值。

上述代码能够编译,不过会有一个警告:

warning: unused result which must be used, #[warn(unused_must_use)] on by default
  --> src/main.rs:39:5
   |
39 |     run(config);
   |     ^^^^^^^^^^^^

Rust 提示我们的代码忽略了Result值,它可能表明这里存在一个错误。虽然我们没有检查这里是否有一个错误,而编译器提醒我们这里应该有一些错误处理代码!现在就让我们修正他们。

处理mainrun返回的错误

我们将检查错误并使用与列表 12-10 中处理错误类似的技术来优雅的处理他们,不过有一些细微的不同:

Filename: src/main.rs

fn main() {
    // ...snip...

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = run(config) {
        println!("Application error: {}", e);

        process::exit(1);
    }
}

我们使用if let来检查run是否返回一个Err值,不同于unwrap_or_else,并在出错时调用process::exit(1)run并不返回像Config::new返回的Config实例那样需要unwrap的值。因为run在成功时返回(),而我们只关心发现一个错误,所以并不需要unwrap_or_else来返回未封装的值,因为它只会是()

不过两个例子中if letunwrap_or_else的函数体都一样:打印出错误并退出。

将代码拆分到库 crate

现在项目看起来好多了!现在我们将要拆分 src/main.rs 并将一些代码放入 src/lib.rs,这样就能测试他们并拥有一个小的main函数。

让我们将如下代码片段从 src/main.rs 移动到新文件 src/lib.rs 中:

  • run函数定义
  • 相关的use语句
  • Config的定义
  • Config::new函数定义

现在 src/lib.rs 的内容应该看起来像列表 12-13:

Filename: src/lib.rs

use std::error::Error;
use std::fs::File;
use std::io::prelude::*;

pub struct Config {
    pub query: String,
    pub filename: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config {
            query: query,
            filename: filename,
        })
    }
}

pub fn run(config: Config) -> Result<(), Box<Error>>{
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    println!("With text:\n{}", contents);

    Ok(())
}

Listing 12-13: Moving Config and run into src/lib.rs

这里使用了公有的pub:在Config、其字段和其new方法,以及run函数上。现在我们有了一个拥有可以测试的公有 API 的库 crate 了。

从二进制 crate 中调用库 crate

现在需要在 src/main.rs 中使用extern crate greprs将移动到 src/lib.rs 的代码引入二进制 crate 的作用域。接着我们将增加一个use greprs::Config行将Config类型引入作用域,并使用库 crate 的名称作为run函数的前缀,如列表 12-14 所示:

Filename: src/main.rs

extern crate greprs;

use std::env;
use std::process;

use greprs::Config;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = greprs::run(config) {
        println!("Application error: {}", e);

        process::exit(1);
    }
}

Listing 12-14: Bringing the greprs crate into the scope of src/main.rs

通过这些重构,所有功能应该抖联系在一起并可以运行了。运行cargo run来确保一切都正确的衔接在一起。

哇哦!这可有很多的工作,不过我们为将来成功打下了基础。现在处理错误将更容易,同时代码也更模块化。从现在开始几乎所有的工作都将在 src/lib.rs 中进行。

让我们利用这些新创建的模块的优势来进行一些在旧代码中难以展开的工作,他们在新代码中却很简单:编写测试!

测试库的功能

ch12-04-testing-the-librarys-functionality.md
commit b8e4fcbf289b82c12121b282747ce05180afb1fb

现在我们将逻辑提取到了 src/lib.rs 并将所有的参数解析和错误处理留在了 src/main.rs 中,为代码的核心功能编写测试将更加容易。我们可以直接使用多种参数调用函数并检查返回值而无需从命令行运行二进制文件了。

在这一部分,我们将遵循测试驱动开发(Test Driven Development, TTD)的模式。这是一个软件开发技术,它遵循如下步骤:

  1. 编写一个会失败的测试,并运行它以确保其因为你期望的原因失败。
  2. 编写或修改刚好足够的代码来使得新的测试通过。
  3. 重构刚刚增加或修改的代码,并确保测试仍然能通过。
  4. 重复上述步骤!

这只是众多编写软件的方法之一,不过 TDD 有助于驱动代码的设计。在编写能使测试通过的代码之前编写测测试有助于在开发过程中保持高测试覆盖率。

我们将测试驱动实现greprs实际在文件内容中搜索查询字符串并返回匹配的行列表的部分。我们将在一个叫做search的函数中增加这些功能。

编写失败测试

首先,去掉 src/lib.rssrc/main.rs 中的println!语句,因为不再真的需要他们了。接着我们会像第十一章那样增加一个test模块和一个测试函数。测试函数指定了我们希望search函数拥有的行为:它会获取一个需要查询的字符串和用来查询的文本。列表 12-15 展示了这个测试:

Filename: src/lib.rs

# fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
#      vec![]
# }
#
#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn one_result() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.";

        assert_eq!(
            vec!["safe, fast, productive."],
            search(query, contents)
        );
    }
}

Listing 12-15: Creating a failing test for the search function we wish we had

这里选择使用 "duct" 作为这个测试中需要搜索的字符串。用来搜索的文本有三行,其中只有一行包含 "duct"。我们断言search函数的返回值只包含期望的那一行。

我们还不能运行这个测试并看到它失败,因为它甚至都还不能编译!我们将增加足够的代码来使其能够编译:一个总是会返回空 vector 的search函数定义,如列表 12-16 所示。一旦有了它,这个测试应该能够编译并因为空 vector 并不匹配一个包含一行"safe, fast, productive."的 vector 而失败。

Filename: src/lib.rs

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
     vec![]
}

Listing 12-16: Defining just enough of the search function that our test will compile

注意需要在search的签名中显式定义一个显式生命周期'a并用于contents参数和返回值。回忆一下第十章中生命周期参数指定哪个参数的生命周期与返回值的生命周期相关联。在这个例子中,我们表明返回的 vector 中应该包含引用参数contents(而不是参数query) slice 的字符串 slice。

换句话说,我们告诉 Rust 函数search返回的数据将与search函数中的参数contents的数据存在的一样久。这是非常重要的!为了使这个引用有效那么slice 引用的数据也需要保持有效;如果编译器认为我们是在创建query而不是contents的字符串 slice,那么安全检查将是不正确的。

如果尝试不用生命周期编译的话,我们将得到如下错误:

error[E0106]: missing lifetime specifier
 --> src/lib.rs:5:47
  |
5 | fn search(query: &str, contents: &str) -> Vec<&str> {
  |                                               ^ expected lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the
  signature does not say whether it is borrowed from `query` or `contents`

Rust 不可能知道我们需要的是哪一个参数,所以需要告诉它。因为参数contents包含了所有的文本而且我们希望返回匹配的那部分文本,而我们知道contents是应该要使用生命周期语法来与返回值相关联的参数。

其他语言中并不需要你在函数签名中将参数与返回值相关联,所以这么做可能仍然感觉有些陌生,随着时间的推移会越来越容易。你可能想要将这个例子与第十章中生命周期语法部分做对比。

现在试尝试运行测试:

$ cargo test
...warnings...
    Finished debug [unoptimized + debuginfo] target(s) in 0.43 secs
     Running target/debug/deps/greprs-abcabcabc

running 1 test
test test::one_result ... FAILED

failures:

---- test::one_result stdout ----
    thread 'test::one_result' panicked at 'assertion failed: `(left == right)`
(left: `["safe, fast, productive."]`, right: `[]`)', src/lib.rs:16
note: Run with `RUST_BACKTRACE=1` for a backtrace.


failures:
    test::one_result

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured

error: test failed

好的,测试失败了,这正是我们所期望的。修改代码来让测试通过吧!

编写使测试通过的代码

目前测试之所以会失败是因为我们总是返回一个空的 vector。为了修复并实现search,我们的程序需要遵循如下步骤:

  1. 遍历每一行文本。
  2. 查看这一行是否包含要搜索的字符串。
    • 如果有,将这一行加入返回列表中。
    • 如果没有,什么也不做。
  3. 返回匹配到的列表

让我们一步一步的来,从遍历每行开始。

使用lines方法遍历每一行

Rust 有一个有助于一行一行遍历字符串的方法,出于方便它被命名为lines,它如列表 12-17 这样工作:

Filename: src/lib.rs

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        // do something with line
    }
}

Listing 12-17: Iterating through each line in contents

lines方法返回一个迭代器。第十三章会深入了解迭代器,不过我们已经在列表 3-6 中见过使用迭代器的方法,在那里使用了一个for循环和迭代器在一个集合的每一项上运行一些代码。

用查询字符串搜索每一行

接下来将会增加检查当前行是否包含查询字符串的功能。幸运的是,字符串类型为此也有一个有用的方法叫做contains!如列表 12-18 所示在search函数中加入contains方法:

Filename: src/lib.rs

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    for line in contents.lines() {
        if line.contains(query) {
            // do something with line
        }
    }
}

Listing 12-18: Adding functionality to see if the line contains the string in query

存储匹配的行

最后我们需要一个方法来存储包含查询字符串的行。为此可以在for循环之前创建一个可变的 vector 并调用push方法在 vector 中存放一个line。在for循环之后,返回这个 vector,如列表 12-19 所示:

Filename: src/lib.rs

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

Listing 12-19: Storing the lines that match so that we can return them

现在search函数应该返回只包含query的那些行,而测试应该会通过。让我们运行测试:

$ cargo test
running 1 test
test test::one_result ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

     Running target/debug/greprs-2f55ee8cd1721808

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests greprs

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

测试通过了,很好,它可以工作了!

现在测试通过了,我们可以考虑一下重构search的实现并时刻保持测试通过来保持其功能不变的机会了。这些代码并不坏,不过并没有利用迭代器的一些实用功能。第十三章将回到这个例子并深入探索迭代器并看看如何改进代码。

run函数中使用search函数

现在search函数是可以工作并测试通过了的,我们需要实际在run函数中调用search。需要将config.query值和run从文件中读取的contents传递给search函数。接着run会打印出search返回的每一行:

Filename: src/lib.rs

pub fn run(config: Config) -> Result<(), Box<Error>> {
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    for line in search(&config.query, &contents) {
        println!("{}", line);
    }

    Ok(())
}

这里再一次使用了for循环获取了search返回的每一行,而对每一行运行的代码将他们打印了出来。

现在整个程序应该可以工作了!让我们试一试,首先使用一个只会在艾米莉·狄金森的诗中返回一行的单词 "frog":

$ cargo run frog poem.txt
   Compiling greprs v0.1.0 (file:///projects/greprs)
    Finished debug [unoptimized + debuginfo] target(s) in 0.38 secs
     Running `target/debug/greprs frog poem.txt`
How public, like a frog

好的!接下来,像 "the" 这样会匹配多行的单词会怎么样呢:

$ cargo run the poem.txt
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs the poem.txt`
Then there's a pair of us — don't tell!
To tell your name the livelong day

最后,让我们确保搜索一个在诗中哪里都没有的单词时不会得到任何行,比如 "monomorphization":

$ cargo run monomorphization poem.txt
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs monomorphization poem.txt`

非常好!我们创建了一个属于自己的经典工具,并学习了很多如何组织程序的知识。我们还学习了一些文件输入输出、生命周期、测试和命令行解析的内容。

现在如果你希望的话请随意移动到第十三章。为了使这个项目章节更丰满,我们将简要的展示如何处理环境变量和打印到标准错误,这两者在编写命令行程序时都很有用。

处理环境变量

ch12-05-working-with-environment-variables.md
commit 0db6a0a34886bf02feabcab8b430b5d332a8bdf5

我们将用一个额外的功能来改进我们的工具:一个通过环境变量启用的大小写不敏感搜索的选项。我们将其设计为一个命令行参数并要求用户每次需要时都加上它,不过相反我们将使用环境变量。这允许用户设置环境变量一次之后在整个终端会话中所有的搜索都将是大小写不敏感的了。

编写一个大小写不敏感search函数的失败测试

首先,增加一个新函数,当设置了环境变量时会调用它。

这里将继续遵循上一部分开始使用的 TDD 过程,其第一步是再次编写一个失败测试。我们将为新的大小写不敏感搜索函数新增一个测试函数,并将老的测试函数从one_result改名为case_sensitive来更清除的表明这两个测试的区别,如列表 12-20 所示:

Filename: src/lib.rs

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";

        assert_eq!(
            vec!["safe, fast, productive."],
            search(query, contents)
        );
    }

    #[test]
    fn case_insensitive() {
        let query = "rUsT";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Trust me.";

        assert_eq!(
            vec!["Rust:", "Trust me."],
            search_case_insensitive(query, contents)
        );
    }
}

Listing 12-20: Adding a new failing test for the case insensitive function we're about to add

注意我们也改变了老测试中querycontents的值:将查询字符串改变为 "duct",它将会匹配带有单词 productive" 的行。还新增了一个含有文本 "Duct tape" 的行,它有一个大写的 D,这在大小写敏感搜索时不应该匹配 "duct"。我们修改这个测试以确保不会意外破坏已经实现的大小写敏感搜索功能;这个测试现在应该能通过并在处理大小写不敏感搜索时应该能一直通过。

大小写不敏感搜索的新测试使用带有一些大写字母的 "rUsT" 作为其查询字符串。我们将要增加的search_case_insensitive的期望返回值是包含查询字符串 "rust" 的两行,"Rust:" 包含一个大写的 R 还有"Trust me."包含一个小写的 r。这个测试现在会编译失败因为还没有定义search_case_insensitive函数;请随意增加一个总是返回空 vector 的骨架实现,正如列表 12-16 中search函数那样为了使测试编译并失败时所做的那样。

实现search_case_insensitive函数

search_case_insensitive函数,如列表 12-21 所示,将与search函数基本相同。区别是它会将query变量和每一line都变为小写,这样不管输入参数是大写还是小写,在检查该行是否包含查询字符串时都会是小写。

Filename: src/lib.rs

fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.to_lowercase().contains(&query) {
            results.push(line);
        }
    }

    results
}

Listing 12-21: Defining the search_case_insensitive function to lowercase both the query and the line before comparing them

首先我们将query字符串转换为小写,并将其储存(覆盖)到同名的变量中。对查询字符串调用to_lowercase是必需的这样不管用户的查询是"rust"、"RUST"、"Rust"或者"rUsT",我们都将其当作"rust"处理并对大小写不敏感。

注意query现在是一个String而不是字符串 slice,因为调用to_lowercase是在创建新数据,而不是引用现有数据。如果查询字符串是"rUsT",这个字符串 slice 并不包含可供我们使用的小写的 u,所以必需分配一个包含"rust"的新String。因为query现在是一个String,当我们将query作为一个参数传递给contains方法时,需要增加一个 & 因为contains的签名被定义为获取一个字符串 slice。

接下来在检查每个line是否包含search之前增加了一个to_lowercase调用。这会将"Rust:"变为"rust:"并将"Trust me."变为"trust me."。现在我们将linequery都转换成了小写,这样就可以不管大小写的匹配文件中的文本和用户输入的查询了。

让我们看看这个实现能否通过测试:

    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/greprs-e58e9b12d35dc861

running 2 tests
test test::case_insensitive ... ok
test test::case_sensitive ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured

     Running target/debug/greprs-8a7faa2662b5030a

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests greprs

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

好的!现在,让我们在run函数中调用真正的新search_case_insensitive函数。首先,我们将在Config结构体中增加一个配置项来切换大小写敏感和大小写不敏感搜索。

Filename: src/lib.rs

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

这里增加了case_sensitive字符来存放一个布尔值。接着我们需要run函数检查case_sensitive字段的值并使用它来决定是否调用search函数或search_case_insensitive函数,如列表 12-22所示:

Filename: src/lib.rs

# use std::error::Error;
# use std::fs::File;
# use std::io::prelude::*;
#
# fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
#      vec![]
# }
#
# fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
#      vec![]
# }
#
# struct Config {
#     query: String,
#     filename: String,
#     case_sensitive: bool,
# }
#
pub fn run(config: Config) -> Result<(), Box<Error>>{
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    let results = if config.case_sensitive {
        search(&config.query, &contents)
    } else {
        search_case_insensitive(&config.query, &contents)
    };

    for line in results {
        println!("{}", line);
    }

    Ok(())
}

Listing 12-22: Calling either search or search_case_insensitive based on the value in config.case_sensitive

最后需要实际检查环境变量。处理环境变量的函数位于标准库的env模块中,所以我们需要在 src/lib.rs 的开头增加一个use std::env;行将这个模块引入作用域中。接着在Config::new中使用env模块的var方法检查一个叫做CASE_INSENSITIVE的环境变量,如列表 12-23 所示:

Filename: src/lib.rs

use std::env;
# struct Config {
#     query: String,
#     filename: String,
#     case_sensitive: bool,
# }

// ...snip...

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        let case_sensitive = env::var("CASE_INSENSITIVE").is_err();

        Ok(Config {
            query: query,
            filename: filename,
            case_sensitive: case_sensitive,
        })
    }
}

Listing 12-23: Checking for an environment variable named CASE_INSENSITIVE

这里创建了一个新变量case_sensitive。为了设置它的值,需要调用env::var函数并传递我们需要寻找的环境变量名称,CASE_INSENSITIVEenv::var返回一个Result,它在环境变量被设置时返回包含其值的Ok成员,并在环境变量未被设置时返回Err成员。我们使用Resultis_err方法来检查其是否是一个 error(也就是环境变量未被设置的情况),这也就意味着我们需要进行一个大小写敏感搜索。如果CASE_INSENSITIVE环境变量被设置为任何值,is_err会返回 false 并将进行大小写不敏感搜索。我们并不关心环境变量所设置的值,只关心它是否被设置了,所以检查is_err而不是unwrapexpect或任何我们已经见过的Result的方法。我们将变量case_sensitive的值传递给Config实例这样run函数可以读取其值并决定是否调用search或者列表 12-22 中实现的search_case_insensitive

让我们试一试吧!首先不设置环境变量并使用查询"to"运行程序,这应该会匹配任何全小写的单词"to"的行:

$ cargo run to poem.txt
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs to poem.txt`
Are you nobody, too?
How dreary to be somebody!

看起来程序仍然能够工作!现在将CASE_INSENSITIVE设置为 1 并仍使用相同的查询"to",这回应该得到包含可能有大写字母的"to"的行:

$ CASE_INSENSITIVE=1 cargo run to poem.txt
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running `target/debug/greprs to poem.txt`
Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!

好极了,我们也得到了包含"To"的行!现在greprs程序可以通过环境变量控制进行大小写不敏感搜索了。现在你知道了如何管理由命令行参数或环境变量设置的选项了!

一些程序允许对相同配置同时使用参数环境变量。在这种情况下,程序来决定参数和环境变量的优先级。作为一个留给你的测试,尝试同时通过一个命令行参数来控制大小写不敏感搜索,并在程序遇到矛盾值时决定其优先级。

std::env模块还包含了更多处理环境变量的实用功能;请查看官方文档来了解其可用的功能。

输出到stderr而不是stdout

ch12-06-writing-to-stderr-instead-of-stdout.md
commit d09cfb51a239c0ebfc056a64df48fe5f1f96b207

目前为止,我们将所有的输出都println!到了终端。大部分终端都提供了两种输出:“标准输出”对应大部分信息,而“标准错误”则用于错误信息。这种区别是命令行程序所期望拥有的行为:例如它允许用户选择将程序正常输出定向到一个文件中并仍将错误信息打印到屏幕上。但是println!只能够打印到标准输出,所以我们必需使用其他方法来打印到标准错误。

我们可以验证,目前所编写的greprs,所有内容都被打印到了标准输出,包括应该被写入标准错误的错误信息。可以通过故意造成错误来做到这一点,一个发生这种情况的方法是不使用任何参数运行程序。我们准备将标准输出重定向到一个文件中,不过不是标准错误。命令行程序期望以这种方式工作,因为如果输出是错误信息,它应该显示在屏幕上而不是被重定向到文件中。可以看出我们的程序目前并没有满足这个期望,通过使用>并指定一个文件名,output.txt,这是期望将标注输出重定向的文件:

$ cargo run > output.txt

>语法告诉 shell 将标准输出的内容写入到 output.txt 文件中而不是打印到屏幕上。我们并没有看到期望的错误信息打印到屏幕上,所以这意味着它一定被写入了文件中。让我们看看 output.txt 包含什么:

Application error: No search string or filename found

是的,这就是错误信息,这意味着它被打印到了标准输出。这并不是命令行程序所期望拥有的。像这样的错误信息被打印到标准错误,并当以这种方式重定向标注输出时只将运行成功时的数据打印到文件中。让我们像列表 12-23 所示改变错误信息如何被打印的。因为本章早些时候的进行的重构,所有打印错误信息的代码都在一个位置,在main中:

Filename: src/main.rs

extern crate greprs;

use std::env;
use std::process;
use std::io::prelude::*;

use greprs::Config;

fn main() {
    let args: Vec<String> = env::args().collect();
    let mut stderr = std::io::stderr();

    let config = Config::new(&args).unwrap_or_else(|err| {
        writeln!(
            &mut stderr,
            "Problem parsing arguments: {}",
            err
        ).expect("Could not write to stderr");
        process::exit(1);
    });

    if let Err(e) = greprs::run(config) {
        writeln!(
            &mut stderr,
            "Application error: {}",
            e
        ).expect("Could not write to stderr");

        process::exit(1);
    }
}

Listing 12-23: Writing error messages to stderr instead of stdout using writeln!

Rust 并没有类似println!这样的方便写入标准错误的函数。相反,我们使用writeln!宏,它有点像println!,不过它获取一个额外的参数。第一个参数是希望写入内容的位置。可以通过std::io::stderr函数获取一个标准错误的句柄。我们将一个stderr的可变引用传递给writeln!;它需要是可变的因为这样才能写入信息!第二个和第三个参数就像println!的第一个和第二参数:一个格式化字符串和任何需要插入的变量。

再次用相同方式运行程序,不带任何参数并用>重定向stdout

$ cargo run > output.txt
Application error: No search string or filename found

现在我们看到了屏幕上的错误信息,不过output.txt里什么也没有,这也就是命令行程序所期望的行为。

如果使用不会造成错误的参数再次运行程序,不过仍然将标准输出重定向到一个文件:

$ cargo run to poem.txt > output.txt

我们并不会在终端看到任何输出,同时output.txt将会包含其结果:

Filename: output.txt

Are you nobody, too?
How dreary to be somebody!

这一部分展示了现在我们使用的成功时产生的标准输出和错误时产生的标准错误是恰当的。

总结

在这一章中,我们回顾了目前为止的一些主要章节并涉及了如何在 Rust 中进行常规的 I/O 操作。通过使用命令行参数、文件、环境变量和writeln!宏与stderr,现在你已经准备好编写命令行程序了。结合前几章的知识,你的代码将会是组织良好的,并能有效的将数据存储到合适的数据结构中、更好的处理错误,并且还是经过良好测试的。

接下来,让我们探索如何利用一些 Rust 中受函数式编程语言影响的功能:闭包和迭代器。

Rust 中的函数式语言功能 —— 迭代器和闭包

ch13-00-functional-features.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

Rust 的设计灵感来源于很多前人的成果。影响 Rust 的其中之一就是函数式编程,在这里函数也是值并可以被用作参数或其他函数的返回值、赋值给变量等等。我们将回避解释函数式编程的具体是什么以及其优缺点,而是突出展示 Rust 中那些类似被认为是函数式的编程语言中的功能。

更具体的,我们将要涉及:

  • 闭包Closures),一个可以储存在变量里的类似函数的结构
  • 迭代器Iterators),一种处理元素序列的方式。。
  • 如何使用这些功能来改进上一章的项目
  • 这些功能的性能。**剧透高能:**他们的速度超乎想象!

这并不是一个 Rust 受函数式风格影响的完整功能列表:还有模式匹配、枚举和很多其他功能。不过掌握闭包和迭代器则是编写符合语言风格的快速的 Rust 代码的重要一环。

闭包

ch13-01-closures.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

Rust 提供了定义闭包的能力,它类似于函数。让我们先不从技术上的定义开始,而是看看闭包语句结构,然后再返回他们的定义。列表 13-1 展示了一个被赋值给变量add_one的小的闭包定义,之后可以用这个变量来调用闭包:

Filename: src/main.rs

fn main() {
    let add_one = |x| x + 1;

    let five = add_one(4);

    assert_eq!(5, five);
}

Listing 13-1: A closure that takes one parameter and adds one to it, assigned to the variable add_one

闭包的定义位于第一行,展示了闭包获取了一个叫做x的参数。闭包的参数位于竖线之间(|)。

这是一个很小的闭包,它只包含一个表达式。列表 13-2 展示了一个稍微复杂一点的闭包:

Filename: src/main.rs

fn main() {
    let calculate = |a, b| {
        let mut result = a * 2;

        result += b;

        result
    };

    assert_eq!(7, calculate(2, 3)); // 2 * 2 + 3 == 7
    assert_eq!(13, calculate(4, 5)); // 4 * 2 + 5 == 13
}

Listing 13-2: A closure with two parameters and multiple expressions in its body

可以通过大括号来定义多于一个表达式的闭包体。

你会注意到一些闭包不同于fn关键字定义的函数的地方。第一个不同是并不需要声明闭包的参数和返回值的类型。也可以选择加上类型注解;列表 13-3 展示了列表 13-1 中闭包带有参数和返回值类型注解的版本:

Filename: src/main.rs

fn main() {
    let add_one = |x: i32| -> i32 { x + 1 };

    assert_eq!(2, add_one(1));
}

Listing 13-3: A closure definition with optional parameter and return value type annotations

在带有类型注解的情况下闭包的语法于函数就更接近了。让我们来更直接的比较一下不同闭包的语法与函数的语法。这里增加了一些空格来对齐相关的部分:

fn  add_one_v1   (x: i32) -> i32 { x + 1 }  // a function
let add_one_v2 = |x: i32| -> i32 { x + 1 }; // the full syntax for a closure
let add_one_v3 = |x|             { x + 1 }; // a closure eliding types
let add_one_v4 = |x|               x + 1  ; // without braces

定义闭包时不要求类型注解而在定义函数时要求的原因在于函数是显式暴露给用户的接口的一部分,所以为了严格的定义接口确保所有人都同意函数使用和返回的值类型是很重要的。但是闭包并不像函数那样用于暴露接口:他们存在于绑定中并直接被调用。强制标注类型就等于为了很小的优点而显著的降低了工程性(本末倒置)。

不过闭包的定义确实会推断每一个参数和返回值的类型。例如,如果用i8调用列表 13-1 中没有类型注解的闭包,如果接着用i32调用同一闭包则会得到一个错误:

Filename: src/main.rs

let add_one = |x| x + 1;

let five = add_one(4i8);
assert_eq!(5i8, five);

let three = add_one(2i32);

编译器给出如下错误:

error[E0308]: mismatched types
 -->
  |
7 | let three = add_one(2i32);
  |                     ^^^^ expected i8, found i32

因为闭包是直接被调用的所以能可靠的推断出其类型,再强制要求标注类型就显得有些冗余了。

闭包与函数语法不同还有另一个原因是,它与函数有着不同的行为:闭包拥有其环境(上下文)

闭包可以引用其环境

我们知道函数只能使用其作用域内的变量,或者要么是const的要么是被声明为参数的。闭包则可以做的更多:闭包允许使用包含他们的作用域的变量。列表 13-4 是一个在equal_to_x变量中并使用其周围环境中变量x的闭包的例子:

Filename: src/main.rs

fn main() {
    let x = 4;

    let equal_to_x = |z| z == x;

    let y = 4;

    assert!(equal_to_x(y));
}

Listing 13-4: Example of a closure that refers to a variable in its enclosing scope

这里。即便x并不是equal_to_x的一个参数,equal_to_x闭包也被允许使用它,因为变量x定义于同样定义equal_to_x的作用域中。并不允许在函数中进行与列表 13-4 相同的操作;尝试这么做看看会发生什么:

Filename: src/main.rs

fn main() {
    let x = 4;

    fn equal_to_x(z: i32) -> bool { z == x }

    let y = 4;

    assert!(equal_to_x(y));
}

我们会得到一个错误:

error[E0434]: can't capture dynamic environment in a fn item; use the || { ... }
closure form instead
 -->
  |
4 |     fn equal_to_x(z: i32) -> bool { z == x }
  |                                          ^

编译器甚至提醒我们这只能用于闭包!

获取他们环境中值的闭包主要用于开始新线程的场景。我们也可以定义以闭包作为参数的函数,通过使用Fn trait。这里是一个函数call_with_one的例子,它的签名有一个闭包参数:

fn call_with_one<F>(some_closure: F) -> i32
    where F: Fn(i32) -> i32 {

    some_closure(1)
}

let answer = call_with_one(|x| x + 2);

assert_eq!(3, answer);

我们将|x| x + 2传递给了call_with_one,而call_with_one1作为参数调用了这个闭包。some_closure调用的返回值接着被call_with_one返回。

call_with_one的签名使用了第十章 trait 部分讨论到的where语法。some_closure参数有一个泛型类型F,它在where从句中被定义为拥有Fn(i32) -> i32 trait bound。Fn trait 代表了一个闭包,而且可以给Fn trait 增加类型来代表一个特定类型的闭包。在这种情况下,闭包拥有一个i32的参数并返回一个i32,所以泛型的 trait bound 被指定为Fn(i32) -> i32

在函数签名中指定闭包要求使用泛型和 trait bound。每一个闭包都有一个独特的类型,所以不能写出闭包的类型而必须使用泛型。

Fn并不是唯一可以指定闭包的 trait bound,事实上有三个:FnFnMutFnOnce。这是在 Rust 中经常见到的三种模式的延续:借用、可变借用和获取所有权。用Fn来指定可能只会借用其环境中值的闭包。用FnMut来指定会修改环境中值的闭包,而如果闭包会获取环境值的所有权则使用FnOnce。大部分情况可以从Fn开始,而编译器会根据调用闭包时会发生什么来告诉你是否需要FnMutFnOnce

为了展示拥有闭包作为参数的函数的应用场景,让我们继续下一主题:迭代器。

迭代器

ch13-02-iterators.md
commit 431116f5c696000b9fd6780e5fde90392cef6812

迭代器是 Rust 中的一个模式,它允许你对一个项的序列进行某些处理。例如。列表 13-5 中对 vecctor 中的每一个数加一:

let v1 = vec![1, 2, 3];

let v2: Vec<i32> = v1.iter().map(|x| x + 1).collect();

assert_eq!(v2, [2, 3, 4]);

Listing 13-5: Using an iterator, map, and collect to add one to each number in a vector

vector 的iter方法允许从 vector 创建一个迭代器iterator)。接着迭代器上的map方法调用允许我们处理每一个元素:在这里,我们向map传递了一个对每一个元素x加一的闭包。map是最基本的与比较交互的方法之一,因为依次处理每一个元素是非常有用的!最后collect方法消费了迭代器并将其元素存放到一个新的数据结构中。在这个例子中,因为我们指定v2的类型是Vec<i32>collect将会创建一个i32的 vector。

map这样的迭代器方法有时被称为迭代器适配器iterator adaptors),因为他们获取一个迭代器并产生一个新的迭代器。也就是说,map在之前迭代器的基础上通过调用传递给它的闭包来创建了一个新的值序列的迭代器。

概括一下,这行代码进行了如下工作:

  1. 从 vector 中创建了一个迭代器。
  2. 使用map适配器和一个闭包参数对每一个元素加一。
  3. 使用collect适配器来消费迭代器并生成了一个新的 vector。

这就是如何产生结果[2, 3, 4]的。如你所见,闭包是使用迭代器的很重要的一部分:他们提供了一个自定义类似map这样的迭代器适配器的行为的方法。

迭代器是惰性的

在上一部分,你可能已经注意到了一个微妙的用词区别:我们说map适配adapts)了一个迭代器,而collect消费consumes)了一个迭代器。这是有意为之的。单独的迭代器并不会做任何工作;他们是惰性的。也就是说,像列表 13-5 的代码但是不调用collect的话:

let v1: Vec<i32> = vec![1, 2, 3];

v1.iter().map(|x| x + 1); // without collect

这可以编译,不过会给出一个警告:

warning: unused result which must be used: iterator adaptors are lazy and do
nothing unless consumed, #[warn(unused_must_use)] on by default
 --> src/main.rs:4:1
  |
4 | v1.iter().map(|x| x + 1); // without collect
  | ^^^^^^^^^^^^^^^^^^^^^^^^^

这个警告是因为迭代器适配器实际上并不自己进行处理。他们需要一些其他方法来触发迭代器链的计算。我们称之为消费适配器consuming adaptors),而collect就是其中之一。

那么如何知道迭代器方法是否消费了迭代器呢?还有哪些适配器是可用的呢?为此,让我们看看Iterator trait。

Iterator trait

迭代器都实现了一个标准库中叫做Iterator的 trait。其定义看起来像这样:

trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

这里有一些还未讲到的新语法:type ItemSelf::Item定义了这个 trait 的关联类型associated type),第十九章会讲到关联类型。现在所有你需要知道就是这些代码表示Iterator trait 要求你也定义一个Item类型,而这个Item类型用作next方法的返回值。换句话说,Item类型将是迭代器返回的元素的类型。

让我们使用Iterator trait 来创建一个从一数到五的迭代器Counter。首先,需要创建一个结构体来存放迭代器的当前状态,它有一个u32的字段count。我们也定义了一个new方法,当然这并不是必须的。因为我们希望Counter能从一数到五,所以它总是从零开始:

struct Counter {
    count: u32,
}

impl Counter {
    fn new() -> Counter {
        Counter { count: 0 }
    }
}

接下来,我们将通过定义next方法来为Counter类型实现Iterator trait。我们希望迭代器的工作方式是对当前状态加一(这就是为什么将count初始化为零,这样迭代器首先就会返回一)。如果count仍然小于六,将返回当前状态,不过如果count大于等于六,迭代器将返回None,如列表 13-6 所示:

# struct Counter {
#     count: u32,
# }
#
impl Iterator for Counter {
    // Our iterator will produce u32s
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        // increment our count. This is why we started at zero.
        self.count += 1;

        // check to see if we've finished counting or not.
        if self.count < 6 {
            Some(self.count)
        } else {
            None
        }
    }
}

Listing 13-6: Implementing the Iterator trait on our Counter struct

type Item = u32这一行表明迭代器中Item的关联类型将是u32。同样无需担心关联类型,因为第XX章会涉及他们。

next方法是迭代器的主要接口,它返回一个Option。如果它是Some(value),相当于可以迭代器中获取另一个值。如果它是None,迭代器就结束了。在next方法中可以进行任何迭代器需要的计算。在这个例子中,我们对当前状态加一,接着检查其是否仍然小于六。如果是,返回Some(self.count)来产生下一个值。如果大于等于六,迭代结束并返回None

迭代器 trait 指定当其返回None,就代表迭代结束。该 trait 并不强制任何在next方法返回None后再次调用时必须有的行为。在这个情况下,在第一次返回None后每一次调用next仍然返回None,不过其内部count字段会依次增长到u32的最大值,接着count会溢出(在调试模式会panic!而在发布模式则会折叠从最小值开始)。有些其他的迭代器则选择再次从头开始迭代。如果需要确保迭代器在返回第一个None之后所有的next方法调用都返回None,可以使用fuse方法来创建不同于任何其他的迭代器。

一旦实现了Iterator trait,我们就有了一个迭代器!可以通过不停的调用Counter结构体的next方法来使用迭代器的功能:

let mut counter = Counter::new();

let x = counter.next();
println!("{:?}", x);

let x = counter.next();
println!("{:?}", x);

let x = counter.next();
println!("{:?}", x);

let x = counter.next();
println!("{:?}", x);

let x = counter.next();
println!("{:?}", x);

let x = counter.next();
println!("{:?}", x);

这会一次一行的打印出从Some(1)Some(5),之后就全是None

各种Iterator适配器

在列表 13-5 中有一个迭代器并调用了其像mapcollect这样的方法。然而在列表 13-6 中,只实现了Counternext方法。Counter如何才能得到像mapcollect这样的方法呢?

好吧,当讲到Iterator的定义时,我们故意省略一个小的细节。Iterator定义了一系列默认实现,他们会调用next方法。因为next是唯一一个Iterator trait 没有默认实现的方法,一旦实现之后,Iterator的所有其他的适配器就都可用了。这些适配器可不少!

例如,处于某种原因我们希望获取一个Counter实例产生的值,与另一个Counter实例忽略第一个值之后的值相组合,将每组数相乘,并只保留能被三整除的相乘结果,最后将所有保留的结果相加,我们可以这么做:

# struct Counter {
#     count: u32,
# }
#
# impl Counter {
#     fn new() -> Counter {
#         Counter { count: 0 }
#     }
# }
#
# impl Iterator for Counter {
#     // Our iterator will produce u32s
#     type Item = u32;
#
#     fn next(&mut self) -> Option<Self::Item> {
#         // increment our count. This is why we started at zero.
#         self.count += 1;
#
#         // check to see if we've finished counting or not.
#         if self.count < 6 {
#             Some(self.count)
#         } else {
#             None
#         }
#     }
# }
let sum: u32 = Counter::new().zip(Counter::new().skip(1))
                             .map(|(a, b)| a * b)
                             .filter(|x| x % 3 == 0)
                             .sum();
assert_eq!(18, sum);

注意zip只生成四对值;理论上的第五对值并不会产生,因为zip在任一输入返回None时也会返回None(这个迭代器最多就生成 5)。

因为实现了Iteratornext方法,所有这些方法调用都是可能的。请查看标准库文档来寻找迭代器可能会用得上的方法。

改进 I/O 项目

ch13-03-improving-our-io-project.md
commit 0608e2d0743951d8e628b6e130c6b5744775a783

在我们上一章实现的grep I/O 项目中,其中有一些地方的代码可以使用迭代器来变得更清楚简洁一些。让我们看看迭代器如何能够改进Config::new函数和search函数的实现。

使用迭代器并去掉clone

回到列表 12-8 中,这些代码获取一个String slice 并创建一个Config结构体的实例,它检查参数的数量、索引 slice 中的值、并克隆这些值以便Config可以拥有他们的所有权:

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config {
            query: query,
            filename: filename,
        })
    }
}

当时我们说不必担心这里的clone调用,因为将来会移除他们。好吧,就是现在了!所以,为什么这里需要clone呢?这里的问题是参数args中有一个String元素的 slice,而new函数并不拥有args。为了能够返回Config实例的所有权,我们需要克隆Config中字段queryfilename的值,这样Config就能拥有这些值了。

现在在认识了迭代器之后,我们可以将new函数改为获取一个有所有权的迭代器作为参数。可以使用迭代器来代替之前必要的 slice 长度检查和特定位置的索引。因为我们获取了迭代器的所有权,就不再需要借用所有权的索引操作了,我们可以直接将迭代器中的String值移动到Config中,而不用调用clone来创建一个新的实例。

首先,让我们看看列表 12-6 中的main函数,将env::args的返回值改为传递给Config::new,而不是调用collect并传递一个 slice:

fn main() {
    let config = Config::new(env::args());
    // ...snip...

如果参看标准库中env::args函数的文档,我们会发现它的返回值类型是std::env::Args。所以下一步就是更新Config::new的签名使得参数args拥有std::env::Args类型而不是&[String]

impl Config {
    fn new(args: std::env::Args) -> Result<Config, &'static str> {
        // ...snip...

之后我们将修复Config::new的函数体。因为标准库文档也表明,std::env::Args实现了Iterator trait,所以我们知道可以调用其next方法!如下就是新的代码:

# struct Config {
#     query: String,
#     filename: String,
# }
#
impl Config {
    fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
        args.next();

        let query = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a query string"),
        };

        let filename = match args.next() {
            Some(arg) => arg,
            None => return Err("Didn't get a file name"),
        };

        Ok(Config {
            query: query,
            filename: filename,
        })
    }
}

还记得env::args返回值的第一个值是程序的名称吗。我们希望忽略它,所以首先调用next并不处理其返回值。第二次调用next的返回值应该是希望放入Configquery字段的值。使用match来在next返回Some时提取值,而在因为没有足够的参数(这会造成next调用返回None)而提早返回Err值。

filename值也进行相同处理。稍微有些可惜的是queryfilenamematch表达式是如此的相似。如果可以对next返回的Option使用?就好了,不过目前?只能用于Result值。即便我们可以像Result一样对Option使用?,得到的值也是借用的,而我们希望能够将迭代器中的String移动到Config中。

使用迭代器适配器来使代码更简明

另一部分可以利用迭代器的代码位于列表 12-15 中实现的search函数中:

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let mut results = Vec::new();

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line);
        }
    }

    results
}

我们可以用一种更简短的方式来编写这些代码,并避免使用了一个作为可变中间值的results vector,像这样使用迭代器适配器方法来实现:

fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    contents.lines()
        .filter(|line| line.contains(query))
        .collect()
}

这里使用了filter适配器来只保留line.contains(query)为真的那些行。接着使用collect将他们放入另一个 vector 中。这就简单多了!

也可以对列表 12-16 中定义的search_case_insensitive函数使用如下同样的技术:

fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
    let query = query.to_lowercase();

    contents.lines()
        .filter(|line| {
            line.to_lowercase().contains(&query)
        }).collect()
}

看起来还不坏!那么到底该用哪种风格呢?大部分 Rust 程序员倾向于使用迭代器风格。开始这有点难以理解,不过一旦你对不同迭代器的工作方式有了直觉上的理解之后,他们将更加容易理解。相比使用很多看起来大同小异的循环并创建一个 vector,抽象出这些老生常谈的代码将使得我们更容易看清代码所特有的概念,比如迭代器中用于过滤每个元素的条件。

不过他们真的完全等同吗?当然更底层的循环会更快一些。让我们聊聊性能吧。

性能

ch13-04-performance.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

哪一个版本的grep函数会更快一些呢:是直接使用for循环的版本还是使用迭代器的版本呢?我们将运行一个性能测试,通过将阿瑟·柯南·道尔的“福尔摩斯探案集”的全部内容加载进String并寻找其中的单词 "the"。如下是for循环版本和迭代器版本的 grep 函数的性能测试结果:

test bench_grep_for  ... bench:  19,620,300 ns/iter (+/- 915,700)
test bench_grep_iter ... bench:  19,234,900 ns/iter (+/- 657,200)

结果迭代器版本还要稍微快一点!这里我们将不会查看性能测试的代码,我们的目的并不是为了证明他们是完全等同的,而是得出一个怎样比较这两种实现方式的基本思路。对于真正的性能测试,将会检查不同长度的文本、不同的搜索单词、不同长度的单词和所有其他的可变情况。这里所要表达的是:迭代器,作为一个高级的抽象,被编译成了与手写的底层代码大体一致性能代码。迭代器是 Rust 的零成本抽象zero-cost abstractions)之一,它意味着抽象并不会强加运行时开销,它与本贾尼·斯特劳斯特卢普,C++ 的设计和实现者所定义的零开销zero-overhead)如出一辙:

In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.

  • Bjarne Stroustrup "Foundations of C++"

从整体来说,C++ 的实现遵循了零开销原则:你不需要的,无需为他们买单。更有甚者的是:你需要的时候,也不可能找到其他更好的代码了。

  • 本贾尼·斯特劳斯特卢普 "Foundations of C++"

作为另一个例子,这里有一些来自于音频解码器的代码。这些代码使用迭代器链来对作用域中的三个变量进行了某种数学计算:一个叫buffer的数据 slice、一个有12个元素的数组coefficients、和一个代表移位位数的qlp_shift。例子中声明了这些变量但并没有提供任何值;虽然这些代码在其上下文之外没有什么意义,不过仍是一个简洁的现实中的例子,来展示 Rust 如何将高级概念转换为底层代码:

let buffer: &mut [i32];
let coefficients: [i64; 12];
let qlp_shift: i16;

for i in 12..buffer.len() {
    let prediction = coefficients.iter()
                                 .zip(&buffer[i - 12..i])
                                 .map(|(&c, &s)| c * s as i64)
                                 .sum::<i64>() >> qlp_shift;
    let delta = buffer[i];
    buffer[i] = prediction as i32 + delta;
}

为了计算prediction的值,这些代码遍历了coefficients中的 12 个值,使用zip方法将系数与buffer的前 12 个值组合在一起。接着将每一对值相乘,再将所有结果相加,然后将总和右移qlp_shift位。

像音频解码器这样的程序通常非常看重计算的性能。这里,我们创建了一个迭代器,使用了两个适配器,接着消费了其值。Rust 代码将会被编译为什么样的汇编代码呢?好吧,在编写本书的这个时候,它被编译成与手写的相同的汇编代码。遍历coefficients的值完全用不到循环:Rust 知道这里会迭代 12 次,所以它“展开”了循环。所有的系数都被储存在了寄存器中(这意味着访问他们非常快)。也没有数组访问边界检查。这是极端有效率的。

现在知道这些了,请放心大胆的使用迭代器和闭包吧!他们使得代码看起来更高级,但并不为此引入运行时性能损失。

总结

闭包和迭代器是 Rust 受函数式编程语言观念所启发的功能。他们对 Rust 直白的表达高级概念的能力有很大贡献。闭包和迭代器的实现,以及 Rust 的零成本抽象,也使得运行时性能不受影响。

现在我们改进了我们 I/O 项目的(代码)表现力,让我们看一看更多cargo的功能,他们是如何将项目准备好分享给世界的。

更多关于 Cargo 和 Crates.io

ch14-00-more-about-cargo.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

目前为止本书已经使用过一些 Cargo 的功能了,不过这是最基本的那些。我们使用 Cargo 构建、运行和测试代码,不过它还可以做更多。现在就让我们来了解这些其他功能。Cargo 所能做的比本章所涉及的内容还要多;作为一个完整的参考,请查看文档。

我们将要涉及到:

  • 使用发布配置来自定义构建
  • 将库发布到 crates.io
  • 使用工作空间来组织更大的项目
  • 从 crates.io 安装二进制文件
  • 使用自定义的命令来扩展 Cargo

发布配置

ch14-01-release-profiles.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

Cargo 支持一个叫做发布配置release profiles)的概念。这些配置控制各种代码编译参数而且彼此相互独立。在构建的输出中你已经见过了这个功能的影子:

$ cargo build
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
$ cargo build --release
    Finished release [optimized] target(s) in 0.0 secs

这里的 "debug" 和 "release" 提示表明编译器在使用不同的配置。Cargo 支持四种配置:

  • dev:用于cargo build
  • release:用于cargo build --release
  • test:用于cargo test
  • doccargo doc

可以通过自定义Cargo.toml文件中的[profile.*]部分来调整这些配置的编译器参数。例如,这里是devrelease配置的默认参数:

[profile.dev]
opt-level = 0

[profile.release]
opt-level = 3

opt-level设置控制 Rust 会进行何种程度的优化。这个配置的值从 0 到 3。越高的优化级别需要更多的时间。当开发时经常需要编译,你通常希望能在牺牲一些代码性能的情况下编译得快一些。当准备发布时,一次花费更多时间编译来换取每次都要运行的更快的编译结果将更好一些。

可以在Cargo.toml中覆盖这些默认设置。例如,如果你想在开发时开启一级优化:

[profile.dev]
opt-level = 1

这将覆盖默认的设置0,而现在开发构建将获得更多的优化。虽然不如发布构建,但也多少有一些。

对于每个配置的设置和其默认值的完整列表,请查看Cargo 的 文档

将 crate 发布到 Crates.io

ch14-02-publishing-to-crates-io.md
commit f2eef19b3a39ee68dd363db2fcba173491ba9dc4

我们曾经在项目中增加 crates.io 上的 crate 作为依赖。也可以选择将代码分享给其他人。Crates.io 用来分发包的源代码,所以它主要用于分发开源代码。

Rust 和 Cargo 有一些帮助人们找到和使用你发布的包的功能。我们将介绍这些功能,接着讲到如何发布一个包。

文档注释

在第三章中,我们见到了以//开头的注释,Rust 还有第二种注释:文档注释documentation comment)。注释固然对阅读代码的人有帮助,也可以生成 HTML 代码来显式公有 API 的文档注释,这有助于那些对如何使用 crate 有兴趣而不关心如何实现的人。注意只有库 crate 能生成文档,因为二进制 crate 并没有人们需要知道如何使用的公有 API。

文档注释使用///而不是//并支持 Markdown 注解。他们就位于需要文档的项的之前。如下是一个add_one函数的文档注释:

Filename: src/lib.rs

/// Adds one to the number given.
///
/// # Examples
///
/// ```
/// let five = 5;
///
/// assert_eq!(6, add_one(five));
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

Listing 14-1: A documentation comment for a function

cargo doc运行一个由 Rust 分发的工具,rustdoc,来为这些注释生成 HTML 文档。可以运行cargo doc --open在本地尝试一下,这会构建当前状态的文档(以及 crate 的依赖)并在浏览器中打开。导航到add_one函数将会发现文档注释是如何渲染的。

在文档注释中增加示例代码块是一个清楚的表明如何使用库的方法。这么做还有一个额外的好处:cargo test也会像测试那样运行文档中的示例代码!没有什么比有例子的文档更好的了!也没有什么比不能正常工作的例子更糟的了,因为代码在编写文档时已经改变。尝试cargo test运行列表 14-1 中add_one函数的文档;将会看到如下测试结果:

   Doc-tests add-one

running 1 test
test add_one_0 ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

尝试改变示例或函数并观察cargo test会捕获不再能运行的例子!

还有另一种风格的文档注释,//!,用于注释包含项的结构(例如:crate、模块或函数),而不是其之后的项。这通常用在 crate 的根(lib.rs)或模块的根(mod.rs)来分别编写 crate 或模块整体的文档。如下是包含整个标准库的libstd模块的文档:

//! # The Rust Standard Library
//!
//! The Rust Standard Library provides the essential runtime
//! functionality for building portable Rust software.

使用pub use来导出合适的公有 API

第七章介绍了如何使用mod关键字来将代码组织进模块中,如何使用pub关键字将项变为公有,和如何使用use关键字将项引入作用域。当发布 crate 给并不熟悉其使用的库的实现的人时,就值得花时间考虑 crate 的结构对于开发和对于依赖 crate 的人来说是否同样有用。如果结构对于供其他库使用来说并不方便,也无需重新安排内部组织:可以选择使用pub use来重新导出一个不同的公有结构。

例如列表 14-2 中,我们创建了一个库art,其包含一个kinds模块,模块中包含枚举Color和包含函数mix的模块utils

Filename: src/lib.rs

//! # Art
//!
//! A library for modeling artistic concepts.

pub mod kinds {
    /// The primary colors according to the RYB color model.
    pub enum PrimaryColor {
        Red,
        Yellow,
        Blue,
    }

    /// The secondary colors according to the RYB color model.
    pub enum SecondaryColor {
        Orange,
        Green,
        Purple,
    }
}

pub mod utils {
    use kinds::*;

    /// Combines two primary colors in equal amounts to create
    /// a secondary color.
    pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor {
        // ...snip...
#         SecondaryColor::Green
    }
}

Listing 14-2: An art library with items organized into kinds and utils modules

为了使用这个库,列表 14-3 中另一个 crate 中使用了use语句:

Filename: src/main.rs

extern crate art;

use art::kinds::PrimaryColor;
use art::utils::mix;

fn main() {
    let red = PrimaryColor::Red;
    let yellow = PrimaryColor::Yellow;
    mix(red, yellow);
}

Listing 14-3: A program using the art crate's items with its internal structure exported

库的用户并不需要知道PrimaryColorSecondaryColor位于kinds模块中和mix位于utils模块中;这些结构对于内部组织是有帮助的,不过对于外部的观点来说没有什么意义。

为此,可以选择在列表 14-2 中增加如下pub use语句来将这些类型重新导出到顶级结构,如列表 14-4 所示:

Filename: src/lib.rs

//! # Art
//!
//! A library for modeling artistic concepts.

pub use kinds::PrimaryColor;
pub use kinds::SecondaryColor;
pub use utils::mix;

pub mod kinds {
    // ...snip...

Listing 14-4: Adding pub use statements to re-export items

重导出的项将会被连接和排列在 crate API 文档的头版。art crate 的用户仍然可以像列表 14-3 那样使用内部结构,或者使用列表 14-4 中更方便的结构,如列表 14-5 所示:

Filename: src/main.rs

extern crate art;

use art::PrimaryColor;
use art::mix;

fn main() {
    // ...snip...
}

Listing 14-5: Using the re-exported items from the art crate

创建一个有用的公有 API 结构更像一种艺术而不是科学。选择pub use提供了如何向用户暴露 crate 内部结构的灵活性。观察一些你所安装的 crate 的代码来看看其内部结构是否不同于公有 API。

在第一次发布之前

在能够发布任何 crate 之前,你需要在crates.io上注册一个账号并获取一个 API token。为此,访问其官网并使用 GitHub 账号登陆。目前 GitHub 账号是必须的,不过将来网站可能会支持其他创建账号的方法。一旦登陆之后,查看Account Settings页面并使用其中指定的 API key 运行cargo login命令,这看起来像这样:

$ cargo login abcdefghijklmnopqrstuvwxyz012345

这个命令会通知 Cargo 你的 API token 并将其储存在本地的 ~/.cargo/config 文件中。注意这个 token 是一个秘密secret)并不应该与其他人共享。如果因为任何原因与他人共享了这个信息,应该立即重新生成这个 token。

在发布新 crate 之前

首先,crate 必须有一个位移的名称。虽然在本地开发 crate 时,可以使用任何你喜欢的名字,不过crates.io上的 crate 名称遵守先到先得的原则分配。一旦一个 crate 名被使用,就不能被另一个 crate 所使用,所以请确认你喜欢的名字在网站上是可用的。

如果尝试发布由cargo new生成的 crate,会出现一个警告接着是一个错误:

$ cargo publish
    Updating registry `https://github.com/rust-lang/crates.io-index`
warning: manifest has no description, license, license-file, documentation,
homepage or repository.
...snip...
error: api errors: missing or empty metadata fields: description, license.
Please see http://doc.crates.io/manifest.html#package-metadata for how to
upload metadata

我们可以在包的 Cargo.toml 文件中包含更多的信息。其中一些字段是可选的,不过描述和 license 是发布所必须的,因为这样人们才能知道 crate 是干什么的已经在什么样的条款下可以使用他们。

描述连同 crate 一起出现在搜索结果和 crate 页面中。描述通常是一两句话。license字段获取一个 license 标识符值,其可能的值由 Linux 基金会的Software Package Data Exchange (SPDX)指定。如果你想要使用一个不存在于SPDX的 license,则不使用license值,使用license-file来指定项目中包含你想要使用的 license 的文本的文件名。

关于项目所适用的 license 的指导超出了本书的范畴。很多 Rust 社区成员选择与 Rust 自身相同的 license,它是一个双许可的MIT/Apache-2.0,这表明可以通过斜杠来分隔指定多个 license。所以一个准备好发布的项目的 Cargo.toml 文件看起来像这样:

[package]
name = "guessing_game"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
description = "A fun game where you guess what number the computer has chosen."
license = "MIT/Apache-2.0"

[dependencies]

请查看crates.io 的文档中关于其他可以指定元数据的内容,他们可以帮助你的 crate 更容易被发现和使用!

发布到 Crates.io

现在我们创建了一个账号,保存了 API token,为 crate 选择了一个名字,并指定了所需的元数据,我们已经准备好发布了!发布 crate 是一个特定版本的 crate 被上传并托管在 crates.io 的过程。

发布 crate 请多加小心,因为发布是永久性的。对应版本不能被覆盖,其代码也不可能被删除。然而,可以被发布的版本号却没有限制。

让我们运行cargo publish命令,这次它应该会成功因为已经指定了必须的元数据:

$ cargo publish
 Updating registry `https://github.com/rust-lang/crates.io-index`
Packaging guessing_game v0.1.0 (file:///projects/guessing_game)
Verifying guessing_game v0.1.0 (file:///projects/guessing_game)
Compiling guessing_game v0.1.0
(file:///projects/guessing_game/target/package/guessing_game-0.1.0)
 Finished debug [unoptimized + debuginfo] target(s) in 0.19 secs
Uploading guessing_game v0.1.0 (file:///projects/guessing_game)

恭喜!你现在向 Rust 社区分享了代码,而且任何人都可以轻松的将你的 crate 加入他们项目的依赖。

发布已有 crate 的新版本

当你修改了 crate 并准备好发布新版本时,改变 Cargo.tomlversion所指定的值。请使用语义化版本规则来根据修改的类型决定下一个版本呢号。接着运行cargo publish来上传新版本。

使用cargo yank从 Crates.io 删除版本

发布版本时可能会出现意外,因为这样那样的原因导致功能被破坏,比如语法错误或忘记引入某些文件。对于这种情况,Cargo 支持 yanking 一个版本。

标记一个版本的 crate 为 yank 意味着没有项目能够再开始依赖这个版本,不过现存的已经依赖这个版本的项目仍然能够下载和依赖这个版本的内容。crates.io 的一个主要目的是作为一个代码的永久档案库,这样能够保证所有的项目都能继续构建,而允许删除一个版本违反了这个目标。本质上来说,yank 意味着所有带有 Cargo.lock 的项目并不会被破坏,同时任何未来生成的 Cargo.lock 将不能使用被撤回的版本。

yank 并意味着删除了任何代码。例如 yank 功能不打算删除意外上传的 secret。如果这发生了,请立刻重置这些 secret。

为了 yank 一个版本的 crate,运行cargo yank并指定需要 yank 的版本:

$ cargo yank --vers 1.0.1

也可以撤销 yank,并允许项目开始依赖这个版本,通过在命令中加上--undo

$ cargo yank --vers 1.0.1 --undo

Cargo 工作空间

ch14-03-cargo-workspaces.md
commit d945f6d4046f4fc3c09326213100492790aebb45

第十二章中,我们构建一个包含二进制 crate 和库 crate 的包。不过如果库 crate 继续变得更大而我们想要进一步将包拆分为多个库 crate 呢?随着包增长,拆分出其主要组件将是非常有帮助的。对于这种情况,Cargo 提供了一个叫工作空间workspaces)的功能,它可以帮助我们管理多个相关的并行开发的包。

工作空间是一系列的包都共享同样的 Cargo.lock 和输出目录。让我们使用工作空间创建一个项目,这是我们熟悉的所以就可以关注工作空间的结构了。这里有一个二进制项目它使用了两个库:一个会提供add_one方法而第二个会提供add_two方法。让我们为这个二进制项目创建一个新 crate 作为开始:

$ cargo new --bin adder
     Created binary (application) `adder` project
$ cd adder

需要修改二进制包的 Cargo.toml 来告诉 Cargo 包adder是一个工作空间。再文件末尾增加如下:

[workspace]

类似于很多 Cargo 的功能,工作空间支持配置惯例:只要遵循这些惯例就无需再增加任何配置了。这个惯例是任何作为子目录依赖的 crate 将是工作空间的一部分。让我们像这样在 Cargo.toml 中的[dependencies]增加一个adder crate 的路径依赖:

[dependencies]
add-one = { path = "add-one" }

如果增加依赖但没有指定path,这将是一个不位于工作空间的正常的依赖。

接下来,在adder目录中生成add-one crate:

$ cargo new add-one
     Created library `add-one` project

现在adder目录应该有如下目录和文件:

├── Cargo.toml
├── add-one
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── src
    └── main.rs

add-one/src/lib.rs 中增加add_one函数的实现:

Filename: add-one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

打开addersrc/main.rs 并增加一行extern crate将新的add-one库引入作用域,并修改main函数来使用add_one函数:

extern crate add_one;

fn main() {
    let num = 10;
    println!("Hello, world! {} plus one is {}!", num, add_one::add_one(num));
}

让我们构建一下!

$ cargo build
   Compiling add-one v0.1.0 (file:///projects/adder/add-one)
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished debug [unoptimized + debuginfo] target(s) in 0.68 secs

注意在 adder 目录运行cargo build会构建这个 crate 和 adder/add-one 中的add-one crate,不过只创建一个 Cargo.lock 和一个 target 目录,他们都位于 adder 目录。试试你能否用相同的方式增加add-two crate。

假如我们想要在add-one crate 中使用rand crate。一如既往在Cargo.toml[dependencies]部分增加这个 crate:

Filename: add-one/Cargo.toml

[dependencies]

rand = "0.3.14"

如果在 add-one/src/lib.rs 中加上extern crate rand;后再运行cargo build,则会编译成功:

$ cargo build
    Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading rand v0.3.14
   ...snip...
   Compiling rand v0.3.14
   Compiling add-one v0.1.0 (file:///projects/adder/add-one)
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished debug [unoptimized + debuginfo] target(s) in 10.18 secs

现在 Cargo.lock 的顶部反映了add-one依赖rand这一事实。然而即使在工作空间的某处使用了rand,也不能在工作空间的其他 crate 使用它,除非在对应的 Cargo.toml 也增加rand的依赖。例如,如果在顶层的adder crate 的 src/main.rs 中增加extern crate rand;,将会出现一个错误:

$ cargo build
   Compiling adder v0.1.0 (file:///projects/adder)
error[E0463]: can't find crate for `rand`
 --> src/main.rs:1:1
  |
1 | extern crate rand;
  | ^^^^^^^^^^^^^^^^^^^ can't find crate

为了修复这个错误,修改顶层的 Cargo.toml 并表明randadder crate 的一个依赖。

作为另一个提高,为 crate 中的add_one::add_one函数增加一个测试:

Filename: add-one/src/lib.rs

pub fn add_one(x: i32) -> i32 {
    x + 1
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        assert_eq!(3, add_one(2));
    }
}

现在在顶层的 adder 目录运行cargo test

$ cargo test
   Compiling adder v0.1.0 (file:///projects/adder)
    Finished debug [unoptimized + debuginfo] target(s) in 0.27 secs
     Running target/debug/adder-f0253159197f7841

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

等等,零个测试?我们不是刚增加了一个吗?如果我们观察输出,就不难发现在工作空间中的cargo test只运行顶层 crate 的测试。为了运行其他 crate 的测试,需要使用-p参数来表明我们希望运行指定包的测试:

$ cargo test -p add-one
    Finished debug [unoptimized + debuginfo] target(s) in 0.0 secs
     Running target/debug/deps/add_one-abcabcabc

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured

   Doc-tests add-one

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured

类似的,如果选择将工作空间发布到 crates.io,其中的每一个包都需要单独发布。

随着项目增长,考虑使用工作空间:每一个更小的组件比一大块代码要容易理解。将 crate 保持在工作空间中易于协调他们的改变,如果他们一起运行并经常需要同时被修改的话。

使用cargo install从 Crates.io 安装文件

ch14-04-installing-binaries.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

cargo install命令用于在本地安装和使用二进制 crate。它并不打算替换系统中的包;它意在作为一个方便 Rust 开发者们安装其他人已经在 crates.io 上共享的工具的手段。只有有二进制目标文件的包能够安装,而且所有二进制文件都被安装到 Rust 安装根目录的 bin 文件夹中。如果你使用 rustup.rs 安装的 Rust 且没有自定义任何配置,这将是$HOME/.cargo/bin。将这个目录添加到$PATH环境变量中就能够运行通过cargo install安装的程序了。

例如,第十二章提到的叫做ripgrep的用于搜索文件的grep的 Rust 实现。如果想要安装ripgrep,可以运行如下:

$ cargo install ripgrep
Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading ripgrep v0.3.2
 ...snip...
   Compiling ripgrep v0.3.2
    Finished release [optimized + debuginfo] target(s) in 97.91 secs
  Installing ~/.cargo/bin/rg

最后一行输出展示了安装的二进制文件的位置和名称,在这里ripgrep被命名为rg。只要你像上面提到的那样将安装目录假如$PATH,就可以运行rg --help并开始使用一个更快更 Rust 的工具来搜索文件了!

Cargo 自定义扩展命令

ch14-05-extending-cargo.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

Cargo 被设计为可扩展的,通过新的子命令而无须修改 Cargo 自身。如果$PATH中有类似cargo-something的二进制文件,就可以通过cargo something来像 Cargo 子命令一样运行它。像这样的自定义命令也可以运行cargo --list来展示出来,通过cargo install向 Cargo 安装扩展并可以如内建 Cargo 工具那样运行他们是很方便的!

总结

通过 Cargo 和 crates.io 来分享代码是使得 Rust 生态环境可以用于许多不同的任务的重要组成部分。Rust 的标准库是小而稳定的,不过 crate 易于分享和使用,并采用一个不同语言自身的时间线来提供改进。不要羞于在 crates.io 上共享对你有用的代码;因为它很有可能对别人也很有用!

智能指针

ch15-00-smart-pointers.md
commit 4f2dc564851dc04b271a2260c834643dfd86c724

指针是一个常见的编程概念,它代表一个指向储存其他数据的位置。第四章学习了 Rust 的引用;他们是一类很平常的指针,以&符号为标志并借用了他们所指向的值。智能指针Smart pointers)是一类数据结构,他们的表现类似指针,但是也拥有额外的元数据和能力,比如说引用计数。智能指针模式起源于 C++。在 Rust 中,普通引用和智能指针的一个额外的区别是引用是一类只借用数据的指针;相反大部分情况,智能指针拥有他们指向的数据。

本书中已经出现过一些智能指针,虽然当时我们并不这么称呼他们。例如在某种意义上说,第八章的StringVec<T>都是智能指针。他们拥有一些数据并允许你修改他们,并带有元数据(比如他们的容量)和额外的功能或保证(String的数据总是有效的 UTF-8 编码)。智能指针区别于常规结构体的特性在于他们实现了DerefDrop trait,而本章会讨论这些 trait 以及为什么对于智能指针来说他们很重要。

考虑到智能指针是一个在 Rust 经常被使用的通用设计模式,本章并不会覆盖所有现存的智能指针。很多库都有自己的智能指针而你也可以编写属于你自己的。这里将会讲到的是来自标准库中最常用的一些:

  • Box<T>,用于在堆上分配值
  • Rc<T>,一个引用计数类型,其数据可以有多个所有者
  • RefCell<T>,其本身并不是智能指针,不过它管理智能指针RefRefMut的访问,在运行时而不是在编译时执行借用规则。

同时我们还将涉及:

  • 内部可变性interior mutability)模式,当一个不可变类型暴露出改变其内部值的 API,这时借用规则适用于运行时而不是编译时。
  • 引用循环,它如何会泄露内存,以及如何避免他们

让我们开始吧!

Box<T>用于已知大小的堆上数据

ch15-01-box.md
commit 85b2c9ac704c9dc4bbedb97209d336afb9809dc1

最简单直接的智能指针是 box,它的类型是Box<T>。 box 允许你将一个值放在堆上(第四章介绍过栈与堆)。列表 15-1 展示了如何使用 box 在堆上储存一个i32

Filename: src/main.rs

fn main() {
    let b = Box::new(5);
    println!("b = {}", b);
}

Listing 15-1: Storing an i32 value on the heap using a box

这会打印出b = 5。在这个例子中,我们可以像数据是储存在栈上的那样访问 box 中的数据。正如任何拥有数据所有权的值那样,当像b这样的 box 在main的末尾离开作用域时,它将被释放。这个释放过程作用于 box 本身(位于栈上)和它所指向的数据(位于堆上)。

将一个单独的值存放在堆上并不是很有意义,所以像列表 15-1 这样单独使用 box 并不常见。一个 box 的实用场景是当你希望确保类型有一个已知大小的时候。例如,考虑一下列表 15-2,它是一个用于 cons list 的枚举定义,这是一个来源于函数式编程的数据结构类型。注意它还不能编译:

Filename: src/main.rs

enum List {
    Cons(i32, List),
    Nil,
}

Listing 15-2: The first attempt of defining an enum to represent a cons list data structure of i32 values

我们实现了一个只存放i32值的 cons list。也可以选择使用第十章介绍的泛型来实现一个类型无关的 cons list。

cons list 的更多内容

cons list 是一个来源于 Lisp 编程语言及其方言的数据结构。在 Lisp 中,cons函数("construct function"的缩写)利用两个参数来构造一个新的列表,他们通常是一个单独的值和另一个列表。

cons 函数的概念涉及到更通用的函数式编程术语;“将 x 与 y 连接”通常意味着构建一个新的容器而将 x 的元素放在新容器的开头,其后则是容器 y 的元素。

cons list 通过递归调用cons函数产生。代表递归的 base case 的规范名称是Nil,它宣布列表的终止。注意这不同于第六章中的"null"或"nil"的概念,他们代表无效或缺失的值。

cons list 是一个每个元素和之后的其余部分都只包含一个值的列表。列表的其余部分由嵌套的 cons list 定义。其结尾由值Nil表示。cons list 在 Rust 中并不常见;通常Vec<T>是一个更好的选择。实现这个数据结构是Box<T>实用性的一个好的例子。让我们看看为什么!

使用 cons list 来储存列表1, 2, 3将看起来像这样:

use List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

第一个Cons储存了1和另一个List值。这个List是另一个包含2Cons值和下一个List值。这又是另一个存放了3Cons值和最后一个值为NilList,非递归成员代表了列表的结尾。

如果尝试编译上面的代码,会得到如列表 15-3 所示的错误:

error[E0072]: recursive type `List` has infinite size
 -->
  |
1 |   enum List {
  |  _^ starting here...
2 | |     Cons(i32, List),
3 | |     Nil,
4 | | }
  | |_^ ...ending here: recursive type has infinite size
  |
  = help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
  make `List` representable

Listing 15-3: The error we get when attempting to define a recursive enum

错误表明这个类型“有无限的大小”。为什么呢?因为List的一个成员被定义为递归的:它存放了另一个相同类型的值。这意味着 Rust 无法计算为了存放List值到底需要多少空间。让我们一点一点的看:首先了解一下 Rust 如何决定需要多少空间来存放一个非递归类型。回忆一下第六章讨论枚举定义时的列表 6-2 中定义的Message枚举:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

当 Rust 需要知道需要为Message值分配多少空间时,它可以检查每一个成员并发现Message::Quit并不需要任何空间,Message::Move需要足够储存两个i32值的空间,依此类推。因此,Message值所需的最大空间等于储存其最大成员的空间大小。

与此相对当 Rust 编译器检查像列表 15-2 中的List这样的递归类型时会发生什么呢。编译器尝试计算出储存一个List枚举需要多少内存,并开始检查Cons成员,那么Cons需要的空间等于i32的大小加上List的大小。为了计算List需要多少内存,它检查其成员,从Cons成员开始。Cons成员储存了一个i32值和一个List值,这样的计算将无限进行下去,如图 15-4 所示:

An infinite Cons list

Figure 15-4: An infinite List consisting of infinite Cons variants

Rust 无法计算出要为定义为递归的类型分配多少空间,所以编译器给出了列表 15-3 中的错误。这个错误也包括了有用的建议:

= help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
        make `List` representable

因为Box<T>是一个指针,我们总是知道它需要多少空间:指针需要一个usize大小的空间。这个usize的值将是堆数据的地址。而堆数据可以是任意大小,不过开始这个堆数据的地址总是能放进一个usize中。所以如果将列表 15-2 的定义修改为像这里列表 15-5 中的定义,并修改main函数为Cons成员中的值使用Box::new

Filename: src/main.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let list = Cons(1,
        Box::new(Cons(2,
            Box::new(Cons(3,
                Box::new(Nil))))));
}

Listing 15-5: Definition of List that uses Box<T> in order to have a known size

这样编译器就能够计算出储存一个List值需要的大小了。Rust 将会检查List,同样的从Cons成员开始检查。Cons成员需要i32的大小加上一个usize的大小,因为 box 总是usize大小的,不管它指向的是什么。接着 Rust 检查Nil成员,它并不储存一个值,所以Nil并不需要任何空间。我们通过 box 打破了这无限递归的连锁。图 15-6 展示了现在Cons成员看起来像什么:

A finite Cons list

Figure 15-6: A List that is not infinitely sized since Cons holds a Box

这就是 box 主要应用场景:打破无限循环的数据结构以便编译器可以知道其大小。第十七章讨论 trait 对象时我们将了解另一个 Rust 中会出现未知大小数据的情况。

虽然我们并不经常使用 box,他们也是一个了解智能指针模式的好的方式。Box<T>作为智能指针经常被使用的两个方面是他们DerefDrop trait 的实现。让我们研究这些 trait 如何工作以及智能指针如何利用他们。

Deref Trait 允许通过引用访问数据

ch15-02-deref.md
commit ecc3adfe0cfa0a4a15a178dc002702fd0ea74b3f

第一个智能指针相关的重要 trait 是Deref,它允许我们重载*,解引用运算符(不同于乘法运算符和全局引用运算符)。重载智能指针的*能使访问其持有的数据更为方便,在本章结束前谈到解引用强制多态时我们会说明方便的意义。

第八章的哈希 map 的“根据旧值更新一个值”部分简要的提到了解引用运算符。当时有一个可变引用,而我们希望改变这个引用所指向的值。为此,首先我们必须解引用。这是另一个使用i32值引用的例子:

let mut x = 5;
{
    let y = &mut x;

    *y += 1
}

assert_eq!(6, x);

我们使用*y来访问可变引用y所指向的数据,而不是可变引用本身。接着可以修改它的数据,在这里对其加一。

引用并不是智能指针,他们只是引用指向的一个值,所以这个解引用操作是很直接的。智能指针还会储存指针或数据的元数据。当解引用一个智能指针时,我们只想要数据,而不需要元数据。我们希望能在使用常规引用的地方也能使用智能指针。为此,可以通过实现Deref trait 来重载*运算符的行为。

列表 15-7 展示了一个定义为储存 mp3 数据和元数据的结构体通过Deref trait 来重载*的例子。Mp3,在某种意义上是一个智能指针:它拥有包含音频的Vec<u8>数据。另外,它储存了一些可选的元数据,在这个例子中是音频数据中艺术家和歌曲的名称。我们希望能够方便的访问音频数据而不是元数据,所以需要实现Deref trait 来返回音频数据。实现Deref trait 需要一个叫做deref的方法,它借用self并返回其内部数据:

Filename: src/main.rs

use std::ops::Deref;

struct Mp3 {
    audio: Vec<u8>,
    artist: Option<String>,
    title: Option<String>,
}

impl Deref for Mp3 {
    type Target = Vec<u8>;

    fn deref(&self) -> &Vec<u8> {
        &self.audio
    }
}

fn main() {
    let my_favorite_song = Mp3 {
        // we would read the actual audio data from an mp3 file
        audio: vec![1, 2, 3],
        artist: Some(String::from("Nirvana")),
        title: Some(String::from("Smells Like Teen Spirit")),
    };

    assert_eq!(vec![1, 2, 3], *my_favorite_song);
}

Listing 15-7: An implementation of the Deref trait on a struct that holds mp3 file data and metadata

大部分代码看起来都比较熟悉:一个结构体、一个 trait 实现、和一个创建了结构体示例的 main 函数。其中有一部分我们还未全面的讲解:类似于第十三章学习迭代器 trait 时出现的type Itemtype Target = T;语法用于定义关联类型,第十九章会更详细的介绍。不必过分担心例子中的这一部分;它只是一个稍显不同的定义泛型参数的方式。

assert_eq!中,我们验证vec![1, 2, 3]是否为Mp3实例*my_favorite_song解引用的值,结果正是如此因为我们实现了deref方法来返回音频数据。如果没有为Mp3实现Deref trait,Rust 将不会编译*my_favorite_song:会出现错误说Mp3类型不能被解引用。

没有Deref trait 的话,编译器只能解引用&引用,而my_favorite_song并不是(它是一个Mp3结构体)。通过Deref trait,编译器知道实现了Deref trait 的类型有一个返回引用的deref方法(在这个例子中,是&self.audio因为列表 15-7 中的deref的定义)。所以为了得到一个*可以解引用的&引用,编译器将*my_favorite_song展开为如下:

*(my_favorite_song.deref())

这个就是self.audio中的结果值。deref返回一个引用并接下来必需解引用而不是直接返回值的原因是所有权:如果deref方法直接返回值而不是引用,其值将被移动出self。和大部分使用解引用运算符的地方相同,这里并不想获取my_favorite_song.audio的所有权。

注意将*替换为deref调用和*调用的过程在每次使用*的时候都会发生一次。*的替换并不会无限递归进行。最终的数据类型是Vec<u8>,它与列表 15-7 中assert_eq!vec![1, 2, 3]相匹配。

函数和方法的隐式解引用强制多态

Rust 倾向于偏爱明确而不是隐晦,不过一个情况下这并不成立,就是函数和方法的参数的解引用强制多态deref coercions)。解引用强制多态会自动的将指针或智能指针的引用转换为指针内容的引用。解引用强制多态发生于当传递给函数的参数类型不同于函数签名中定义参数类型的时候。解引用强制多态的加入使得 Rust 调用函数或方法时无需很多使用&*的引用和解引用。

使用列表 15-7 中的Mp3结构体,如下是一个获取u8 slice 并压缩 mp3 音频数据的函数签名:

fn compress_mp3(audio: &[u8]) -> Vec<u8> {
    // the actual implementation would go here
}

如果 Rust 没有解引用强制多态,为了使用my_favorite_song中的音频数据调用此函数,必须写成:

compress_mp3(my_favorite_song.audio.as_slice())

也就是说,必须明确表用需要my_favorite_song中的audio字段而且我们希望有一个 slice 来引用这整个Vec<u8>。如果有很多地方需要用相同的方式处理audio数据,那么.audio.as_slice()就显得冗长重复了。

然而,因为解引用强制多态和Mp3Deref trait 实现,我们可以使用如下代码使用my_favorite_song中的数据调用这个函数:

let result = compress_mp3(&my_favorite_song);

只有&和实例,好的!我们可以把智能指针当成普通的引用。也就是说解引用强制多态意味着 Rust 利用了Deref实现的优势:Rust 知道Mp3实现了Deref trait 并从deref方法返回&Vec<u8>。它也知道标准库实现了Vec<T>Deref trait,其deref方法返回&[T](我们也可以通过查阅Vec<T>的 API 文档来发现这一点)。所以,在编译时,Rust 会发现它可以调用两次Deref::deref来将&Mp3变成&Vec<u8>再变成&[T]来满足compress_mp3的签名。这意味着我们可以少写一些代码!Rust 会多次分析Deref::deref的返回值类型直到它满足参数的类型,只要相关类型实现了Deref trait。这些间接转换在编译时进行,所以利用解引用强制多态并没有运行时惩罚!

类似于如何使用Deref trait 重载&T*运算符,DerefMut trait用于重载&mut T*运算符。

Rust 在发现类型和 trait 实现满足三种情况时会进行解引用强制多态:

  • &T&UT: Deref<Target=U>
  • &mut T&mut UT: DerefMut<Target=U>
  • &mut T&UT: Deref<Target=U>

头两个情况除了可变性之外是相同的:如果有一个&T,而T实现了返回U类型的Deref,可以直接得到&U。对于可变引用也是一样。最后一个有些微妙:如果有一个可变引用,它也可以强转为一个不可变引用。反之则是_不可能_的:不可变引用永远也不能强转为可变引用。

Deref trait 对于智能指针模式十分重要的原因在于智能指针可以被看作普通引用并被用于期望使用普通引用的地方。例如,无需重新编写方法和函数来直接获取智能指针。

Drop Trait 运行清理代码

ch15-03-drop.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

对于智能指针模式来说另一个重要的 trait 是DropDrop运行我们在值要离开作用域时执行一些代码。智能指针在被丢弃时会执行一些重要的清理工作,比如释放内存或减少引用计数。更一般的来讲,数据类型可以管理多于内存的资源,比如文件或网络连接,而使用Drop在代码处理完他们之后释放这些资源。我们在智能指针上下文中讨论Drop是因为其功能几乎总是用于实现智能指针。

在其他一些语言中,我们不得不记住在每次使用完智能指针实例后调用清理内存或资源的代码。如果忘记的话,运行代码的系统可能会因为负荷过重而崩溃。在 Rust 中,可以指定一些代码应该在值离开作用域时被执行,而编译器会自动插入这些代码。这意味着无需记住在所有处理完这些类型实例后调用清理代码,而仍然不会泄露资源!

指定在值离开作用域时应该执行的代码的方式是实现Drop trait。Drop trait 要求我们实现一个叫做drop的方法,它获取一个self的可变引用。

列表 15-8 展示了并没有实际功能的结构体CustomSmartPointer,不过我们会在创建实例之后打印出CustomSmartPointer created.,而在实例离开作用域时打印出Dropping CustomSmartPointer!,这样就能看出每一段代码是何时被执行的。实际的项目中,我们应该在drop中清理任何智能指针运行所需要的资源,而不是这个例子中的println!语句:

Filename: src/main.rs

struct CustomSmartPointer {
    data: String,
}

impl Drop for CustomSmartPointer {
    fn drop(&mut self) {
        println!("Dropping CustomSmartPointer!");
    }
}

fn main() {
    let c = CustomSmartPointer { data: String::from("some data") };
    println!("CustomSmartPointer created.");
    println!("Wait for it...");
}

Listing 15-8: A CustomSmartPointer struct that implements the Drop trait, where we could put code that would clean up after the CustomSmartPointer.

Drop trait 位于 prelude 中,所以无需导入它。drop方法的实现调用了println!;这里是你需要放入实际关闭套接字代码的地方。在main函数中,我们创建一个CustomSmartPointer的新实例并打印出CustomSmartPointer created.以便在运行时知道代码运行到此处。在main的结尾,CustomSmartPointer的实例会离开作用域。注意我们没有显式调用drop方法:

当运行这个程序,我们会看到:

CustomSmartPointer created.
Wait for it...
Dropping CustomSmartPointer!

被打印到屏幕上,它展示了 Rust 在实例离开作用域时自动调用了drop

可以使用std::mem::drop函数来在值离开作用域之前丢弃它。这通常是不必要的;整个Drop trait 的要点在于它自动的帮我们处理清理工作。在第十六章讲到并发时我们会看到一个需要在离开作用域之前丢弃值的例子。现在知道这是可能的即可,std::mem::drop位于 prelude 中所以可以如列表 15-9 所示直接调用drop

Filename: src/main.rs

fn main() {
    let c = CustomSmartPointer { data: String::from("some data") };
    println!("CustomSmartPointer created.");
    drop(c);
    println!("Wait for it...");
}

Listing 15-9: Calling std::mem::drop to explicitly drop a value before it goes out of scope

运行这段代码会打印出如下内容,因为Dropping CustomSmartPointer!CustomSmartPointer created.Wait for it...之间被打印出来,表明析构代码被执行了:

CustomSmartPointer created.
Dropping CustomSmartPointer!
Wait for it...

注意不允许直接调用我们定义的drop方法:如果将列表 15-9 中的drop(c)替换为c.drop(),会得到一个编译错误表明explicit destructor calls not allowed。不允许直接调用Drop::drop的原因是 Rust 在值离开作用域时会自动插入Drop::drop,这样就会丢弃值两次。丢弃一个值两次可能会造成错误或破坏内存,所以 Rust 就不允许这么做。相应的可以调用std::mem::drop,它的定义是:

pub mod std {
    pub mod mem {
        pub fn drop<T>(x: T) { }
    }
}

这个函数对于T是泛型的,所以可以传递任何值。这个函数的函数体并没有任何实际内容,所以它也不会利用其参数。这个空函数的作用在于drop获取其参数的所有权,它意味着在这个函数结尾x离开作用域时x会被丢弃。

使用Drop trait 实现指定的代码在很多方面都使得清理值变得方便和安全:比如可以使用它来创建我们自己的内存分配器!通过Drop trait 和 Rust 所有权系统,就无需担心之后清理代码,因为 Rust 会自动考虑这些问题。如果代码在值仍被使用时就清理它会出现编译错误,因为所有权系统确保了引用总是有效的,这也就保证了drop只会在值不再被使用时被调用一次。

现在我们学习了Box<T>和一些智能指针的特性,让我们聊聊一些其他标准库中定义的拥有各种实用功能的智能指针。

Rc<T> 引用计数智能指针

ch15-04-rc.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

大部分情况下所有权是非常明确的:可以准确的知道哪个变量拥有某个值。然而并不总是如此;有时确实可能需要多个所有者。为此,Rust 有一个叫做Rc<T>的类型。它的名字是引用计数reference counting)的缩写。引用计数意味着它记录一个值引用的数量来知晓这个值是否仍在被使用。如果这个值有零个引用,就知道可以在没有有效引用的前提下清理这个值。

根据现实生活场景来想象的话,它就像一个客厅的电视。当一个人进来看电视时,他打开电视。其他人也会进来看电视。当最后一个人离开房间时,他关掉电视因为它不再被使用了。如果某人在其他人还在看的时候关掉了电视,正在看电视人肯定会抓狂的!

Rc<T>用于当我们希望在堆上分配一些内存供程序的多个部分读取,而且无法在编译时确定程序的那一部分会最后结束使用它。如果我们知道的话那么常规的所有权规则会在编译时强制起作用。

注意Rc<T>只能用于单线程场景;下一章并发会涉及到如何在多线程程序中进行引用计数。如果尝试在多线程中使用Rc<T>则会得到一个编译错误。

使用Rc<T>分享数据

让我们回到列表 15-5 中的 cons list 例子。在列表 15-11 中尝试使用Box<T>定义的List。首先创建了一个包含 5 接着是 10 的列表实例。之后我们想要创建另外两个列表:一个以 3 开始并后接第一个包含 5 和 10 的列表,另一个以 4 开始其后是第一个列表。换句话说,我们希望这两个列表共享第三个列表的所有权,概念上类似于图 15-10:

Two lists that share ownership of a third list

Figure 15-10: Two lists, b and c, sharing ownership of a third list, a

尝试使用Box<T>定义的List并不能工作,如列表 15-11 所示:

Filename: src/main.rs

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use List::{Cons, Nil};

fn main() {
    let a = Cons(5,
        Box::new(Cons(10,
            Box::new(Nil))));
    let b = Cons(3, Box::new(a));
    let c = Cons(4, Box::new(a));
}

Listing 15-11: Having two lists using Box<T> that try to share ownership of a third list won't work

编译会得出如下错误:

error[E0382]: use of moved value: `a`
  --> src/main.rs:13:30
   |
12 |     let b = Cons(3, Box::new(a));
   |                              - value moved here
13 |     let c = Cons(4, Box::new(a));
   |                              ^ value used here after move
   |
   = note: move occurs because `a` has type `List`, which does not
   implement the `Copy` trait

Cons成员拥有其储存的数据,所以当创建b列表时将a的所有权移动到了b。接着当再次尝使用a创建c时,这不被允许因为a的所有权已经被移动。

相反可以改变Cons的定义来存放一个引用,不过接着必须指定生命周期参数,而且在构造列表时,也必须使列表中的每一个元素都至少与列表本身存在的一样久。否则借用检查器甚至都不会允许我们编译代码。

如列表 15-12 所示,可以将List的定义从Box<T>改为Rc<T>

Filename: src/main.rs

enum List {
    Cons(i32, Rc<List>),
    Nil,
}

use List::{Cons, Nil};
use std::rc::Rc;

fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    let b = Cons(3, a.clone());
    let c = Cons(4, a.clone());
}

Listing 15-12: A definition of List that uses Rc<T>

注意必须为Rc增加use语句因为它不在 prelude 中。在main中创建了存放 5 和 10 的列表并将其存放在一个叫做a的新的Rc中。接着当创建bc时,我们对a调用了clone方法。

克隆Rc<T>会增加引用计数

之前我们见过clone方法,当时使用它来创建某些数据的完整拷贝。但是对于Rc<T>来说,它并不创建一个完整的拷贝。Rc<T>存放了引用计数,也就是说,一个存在多少个克隆的计数器。让我们像列表 15-13 那样在创建c时增加一个内部作用域,并在不同的位置打印出关联函数Rc::strong_count的结果。Rc::strong_count返回传递给它的Rc值的引用计数,而在本章的稍后部分介绍避免引用循环时讲到它为什么叫做strong_count

Filename: src/main.rs

# enum List {
#     Cons(i32, Rc<List>),
#     Nil,
# }
#
# use List::{Cons, Nil};
# use std::rc::Rc;
#
fn main() {
    let a = Rc::new(Cons(5, Rc::new(Cons(10, Rc::new(Nil)))));
    println!("rc = {}", Rc::strong_count(&a));
    let b = Cons(3, a.clone());
    println!("rc after creating b = {}", Rc::strong_count(&a));
    {
        let c = Cons(4, a.clone());
        println!("rc after creating c = {}", Rc::strong_count(&a));
    }
    println!("rc after c goes out of scope = {}", Rc::strong_count(&a));
}

Listing 15-13: Printing out the reference count

这会打印出:

rc = 1
rc after creating b = 2
rc after creating c = 3
rc after c goes out of scope = 2

不难看出a的初始引用计数是一。接着每次调用clone,计数会加一。当c离开作用域时,计数减一,这发生在Rc<T>Drop trait 实现中。这个例子中不能看到的是当b接着是amain函数的结尾离开作用域时,包含 5 和 10 的列表的引用计数会是 0,这时列表将被丢弃。这个策略允许拥有多个所有者,而引用计数会确保任何所有者存在时这个值保持有效。

在本部分的开始,我们说Rc<T>只允许程序的多个部分读取Rc<T>T的不可变引用。如果Rc<T>允许一个可变引用,我们将遇到第四章讨论的借用规则所不允许的问题:两个指向同一位置的可变借用会导致数据竞争和不一致。不过可变数据是非常有用的!在下一部分,我们将讨论内部可变性模式和RefCell<T>类型,它可以与Rc<T>结合使用来处理不可变性的限制。

RefCell<T>和内部可变性模式

ch15-05-interior-mutability.md
commit 3f2a1bd8dbb19cc48b210fc4fb35c305c8d81b56

内部可变性Interior mutability)是 Rust 中的一个设计模式,它允许你即使在有不可变引用时改变数据,这通常是借用规则所不允许。内部可变性模式涉及到在数据结构中使用unsafe代码来模糊 Rust 通常的可变性和借用规则。我们还未讲到不安全代码;第十九章会学习他们。内部可变性模式用于当你可以确保代码在运行时也会遵守借用规则,哪怕编译器也不能保证的情况。引入的unsafe代码将被封装进安全的 API 中,而外部类型仍然是不可变的。

让我们通过遵循内部可变性模式的RefCell<T>类型来开始探索。

RefCell<T>拥有内部可变性

不同于Rc<T>RefCell<T>代表其数据的唯一的所有权。那么是什么让RefCell<T>不同于像Box<T>这样的类型呢?回忆一下第四章所学的借用规则:

  1. 在任意给定时间,只能拥有如下中的一个:
  • 一个可变引用。
  • 任意属性的不可变引用。
  1. 引用必须总是有效的。

对于引用和Box<T>,借用规则的不可变性作用于编译时。对于RefCell<T>,这些不可变性作用于运行时。对于引用,如果违反这些规则,会得到一个编译错误。而对于RefCell<T>,违反这些规则会panic!

Rust 编译器执行的静态分析天生是保守的。代码的一些属性则不可能通过分析代码发现:其中最著名的就是停机问题(停机问题),这超出了本书的范畴,不过如果你感兴趣的话这是一个值得研究的有趣主题。

因为一些分析是不可能的,Rust 编译器在其不确定的时候甚至都不尝试猜测,所以说它是保守的而且有时会拒绝事实上不会违反 Rust 保证的正确的程序。换句话说,如果 Rust 接受不正确的程序,那么人们也就不会相信 Rust 所做的保证了。如果 Rust 拒绝正确的程序,会给程序员带来不便,但不会带来灾难。RefCell<T>正是用于当你知道代码遵守借用规则,而编译器不能理解的时候。

类似于Rc<T>RefCell<T>只能用于单线程场景。在并发章节会介绍如何在多线程程序中使用RefCell<T>的功能。现在所有你需要知道的就是如果尝试在多线程上下文中使用RefCell<T>,会得到一个编译错误。

对于引用,可以使用&&mut语法来分别创建不可变和可变的引用。不过对于RefCell<T>,我们使用borrowborrow_mut方法,它是RefCell<T>拥有的安全 API 的一部分。borrow返回Ref类型的智能指针,而borrow_mut返回RefMut类型的智能指针。这两个类型实现了Deref所以可以被当作常规引用处理。RefRefMut动态的借用所有权,而他们的Drop实现也动态的释放借用。

列表 15-14 展示了如何使用RefCell<T>来使函数不可变的和可变的借用它的参数。注意data变量使用let data而不是let mut data来声明为不可变的,而a_fn_that_mutably_borrows则允许可变的借用数据并修改它!

Filename: src/main.rs

use std::cell::RefCell;

fn a_fn_that_immutably_borrows(a: &i32) {
    println!("a is {}", a);
}

fn a_fn_that_mutably_borrows(b: &mut i32) {
    *b += 1;
}

fn demo(r: &RefCell<i32>) {
    a_fn_that_immutably_borrows(&r.borrow());
    a_fn_that_mutably_borrows(&mut r.borrow_mut());
    a_fn_that_immutably_borrows(&r.borrow());
}

fn main() {
    let data = RefCell::new(5);
    demo(&data);
}

Listing 15-14: Using RefCell<T>, borrow, and borrow_mut

这个例子打印出:

a is 5
a is 6

main函数中,我们新声明了一个包含值 5 的RefCell<T>,并储存在变量data中,声明时并没有使用mut关键字。接着使用data的一个不可变引用来调用demo函数:对于main函数而言data是不可变的!

demo函数中,通过调用borrow方法来获取到RefCell<T>中值的不可变引用,并使用这个不可变引用调用了a_fn_that_immutably_borrows函数。更为有趣的是,可以通过borrow_mut方法来获取RefCell<T>中值的可变引用,而a_fn_that_mutably_borrows函数就允许修改这个值。可以看到下一次调用a_fn_that_immutably_borrows时打印出的值是 6 而不是 5。

RefCell<T>在运行时检查借用规则

回忆一下第四章因为借用规则,尝试使用常规引用在同一作用域中创建两个可变引用的代码无法编译:

let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

这会得到一个编译错误:

error[E0499]: cannot borrow `s` as mutable more than once at a time
 -->
  |
5 |     let r1 = &mut s;
  |                   - first mutable borrow occurs here
6 |     let r2 = &mut s;
  |                   ^ second mutable borrow occurs here
7 | }
  | - first borrow ends here

与此相反,使用RefCell<T>并在同一作用域调用两次borrow_mut的代码是可以编译的,不过它会在运行时 panic。如下代码:

use std::cell::RefCell;

fn main() {
    let s = RefCell::new(String::from("hello"));

    let r1 = s.borrow_mut();
    let r2 = s.borrow_mut();
}

能够编译不过在cargo run运行时会出现如下错误:

    Finished dev [unoptimized + debuginfo] target(s) in 0.83 secs
     Running `target/debug/refcell`
thread 'main' panicked at 'already borrowed: BorrowMutError',
/stable-dist-rustc/build/src/libcore/result.rs:868
note: Run with `RUST_BACKTRACE=1` for a backtrace.

这个运行时BorrowMutError类似于编译错误:它表明我们已经可变得借用过一次s了,所以不允许再次借用它。我们并没有绕过借用规则,只是选择让 Rust 在运行时而不是编译时执行他们。你可以选择在任何时候任何地方使用RefCell<T>,不过除了不得不编写很多RefCell之外,最终还是可能会发现其中的问题(可能是在生产环境而不是开发环境)。另外,在运行时检查借用规则有性能惩罚。

结合Rc<T>RefCell<T>来拥有多个可变数据所有者

那么为什么要权衡考虑选择引入RefCell<T>呢?好吧,还记得我们说过Rc<T>只能拥有一个T的不可变引用吗?考虑到RefCell<T>是不可变的,但是拥有内部可变性,可以将Rc<T>RefCell<T>结合来创造一个既有引用计数又可变的类型。列表 15-15 展示了一个这么做的例子,再次回到列表 15-5 中的 cons list。在这个例子中,不同于在 cons list 中储存i32值,我们储存一个Rc<RefCell<i32>>值。希望储存这个类型是因为其可以拥有不属于列表一部分的这个值的所有者(Rc<T>提供的多个所有者功能),而且还可以改变内部的i32值(RefCell<T>提供的内部可变性功能):

Filename: src/main.rs

#[derive(Debug)]
enum List {
    Cons(Rc<RefCell<i32>>, Rc<List>),
    Nil,
}

use List::{Cons, Nil};
use std::rc::Rc;
use std::cell::RefCell;

fn main() {
    let value = Rc::new(RefCell::new(5));

    let a = Cons(value.clone(), Rc::new(Nil));
    let shared_list = Rc::new(a);

    let b = Cons(Rc::new(RefCell::new(6)), shared_list.clone());
    let c = Cons(Rc::new(RefCell::new(10)), shared_list.clone());

    *value.borrow_mut() += 10;

    println!("shared_list after = {:?}", shared_list);
    println!("b after = {:?}", b);
    println!("c after = {:?}", c);
}

Listing 15-15: Using Rc<RefCell<i32>> to create a List that we can mutate

我们创建了一个值,它是Rc<RefCell<i32>>的实例。将其储存在变量value中因为我们希望之后能直接访问它。接着在a中创建了一个拥有存放了value值的Cons成员的List,而且value需要被克隆因为我们希望除了a之外还拥有value的所有权。接着将a封装进Rc<T>中这样就可以创建都引用a的有着不同开头的列表bc,类似列表 15-12 中所做的那样。

一旦创建了shared_listbc,接下来就可以通过解引用Rc<T>和对RefCell调用borrow_mut来将 10 与 5 相加了。

当打印出shared_listbc时,可以看到他们都拥有被修改的值 15:

shared_list after = Cons(RefCell { value: 15 }, Nil)
b after = Cons(RefCell { value: 6 }, Cons(RefCell { value: 15 }, Nil))
c after = Cons(RefCell { value: 10 }, Cons(RefCell { value: 15 }, Nil))

这是非常巧妙的!通过使用RefCell<T>,我们可以拥有一个表面上不可变的List,不过可以使用RefCell<T>中提供内部可变性的方法来在需要时修改数据。RefCell<T>的运行时借用规则检查也确实保护我们免于出现数据竞争,而且我们也决定牺牲一些速度来换取数据结构的灵活性。

RefCell<T>并不是标准库中唯一提供内部可变性的类型。Cell<T>有点类似,不过不同于RefCell<T>那样提供内部值的引用,其值被拷贝进和拷贝出Cell<T>Mutex<T>提供线程间安全的内部可变性,下一章并发会讨论它的应用。请查看标准库来获取更多细节和不同类型的区别。

引用循环和内存泄漏是安全的

ch15-06-reference-cycles.md
commit 9430a3d28a2121a938d704ce48b15d21062f880e

我们讨论过 Rust 做出的一些保证,例如永远也不会遇到一个空值,而且数据竞争也会在编译时被阻止。Rust 的内存安全保证也使其更难以制造从不被清理的内存,这被称为内存泄露。然而 Rust 并不是不可能出现内存泄漏,避免内存泄露不是 Rust 的保证之一。换句话说,内存泄露是安全的。

在使用Rc<T>RefCell<T>时,有可能创建循环引用,这时各个项相互引用并形成环。这是不好的因为每一项的引用计数将永远也到不了 0,其值也永远也不会被丢弃。让我们看看这是如何发生的以及如何避免它。

在列表 15-16 中,我们将使用列表 15-5 中List定义的另一个变体。我们将回到储存i32值作为Cons成员的第一个元素。现在Cons成员的第二个元素是RefCell<Rc<List>>:这时就不能修改i32值了,但是能够修改Cons成员指向的那个List。还需要增加一个tail方法来方便我们在拥有一个Cons成员时访问第二个项:

Filename: src/main.rs

#[derive(Debug)]
enum List {
    Cons(i32, RefCell<Rc<List>>),
    Nil,
}

impl List {
    fn tail(&self) -> Option<&RefCell<Rc<List>>> {
        match *self {
            Cons(_, ref item) => Some(item),
            Nil => None,
        }
    }
}

Listing 15-16: A cons list definition that holds a RefCell so that we can modify what a Cons variant is referring to

接下来,在列表 15-17 中,我们将在变量a中创建一个List值,其内部是一个5, Nil的列表。接着在变量b创建一个值 10 和指向a中列表的List值。最后修改a指向b而不是Nil,这会创建一个循环:

Filename: src/main.rs

# #[derive(Debug)]
# enum List {
#     Cons(i32, RefCell<Rc<List>>),
#     Nil,
# }
#
# impl List {
#     fn tail(&self) -> Option<&RefCell<Rc<List>>> {
#         match *self {
#             Cons(_, ref item) => Some(item),
#             Nil => None,
#         }
#     }
# }
#
use List::{Cons, Nil};
use std::rc::Rc;
use std::cell::RefCell;

fn main() {

    let a = Rc::new(Cons(5, RefCell::new(Rc::new(Nil))));

    println!("a initial rc count = {}", Rc::strong_count(&a));
    println!("a next item = {:?}", a.tail());

    let b = Rc::new(Cons(10, RefCell::new(a.clone())));

    println!("a rc count after b creation = {}", Rc::strong_count(&a));
    println!("b initial rc count = {}", Rc::strong_count(&b));
    println!("b next item = {:?}", b.tail());

    if let Some(ref link) = a.tail() {
        *link.borrow_mut() = b.clone();
    }

    println!("b rc count after changing a = {}", Rc::strong_count(&b));
    println!("a rc count after changing a = {}", Rc::strong_count(&a));

    // Uncomment the next line to see that we have a cycle; it will
    // overflow the stack
    // println!("a next item = {:?}", a.tail());
}

Listing 15-17: Creating a reference cycle of two List values pointing to each other

使用tail方法来获取aRefCell的引用,并将其放入变量link中。接着对RefCell使用borrow_mut方法将其中的值从存放Nil值的Rc改为b中的Rc。这创建了一个看起来像图 15-18 所示的引用循环:

Reference cycle of lists

Figure 15-18: A reference cycle of lists a and b pointing to each other

如果你注释掉最后的println!,Rust 会尝试打印出a指向b指向a这样的循环直到栈溢出。

观察最后一个println!之前的打印结果,就会发现在将a改变为指向b之后ab的引用计数都是 2。在main的结尾,Rust 首先会尝试丢弃b,这会使Rc的引用计数减一,但是这个计数是 1 而不是 0,所以Rc在堆上的内存不会被丢弃。它只是会永远的停留在 1 上。这个特定例子中,程序立马就结束了,所以并不是一个问题,不过如果是一个更加复杂的程序,它在这个循环中分配了很多内存并占有很长时间,这就是个问题了。这个程序会使用多于它所需要的内存,并有可能压垮系统并造成没有内存可供使用。

现在,如你所见,在 Rust 中创建引用循环是困难和繁琐的。但并不是不可能:避免引用循环这种形式的内存泄漏并不是 Rust 的保证之一。如果你有包含Rc<T>RefCell<T>值或类似的嵌套结合了内部可变性和引用计数的类型,请务必小心确保你没有形成一个引用循环。在列表 15-14 的例子中,可能解决方式就是不要编写像这样可能造成引用循环的代码,因为我们希望Cons成员拥有他们指向的列表。

举例来说,对于像图这样的数据结构,为了创建父节点指向子节点的边和以相反方向从子节点指向父节点的边,有时需要创建这样的引用循环。如果一个方向拥有所有权而另一个方向没有,对于模拟这种数据关系的一种不会创建引用循环和内存泄露的方式是使用Weak<T>。接下来让我们探索一下!

避免引用循环:将Rc<T>变为Weak<T>

Rust 标准库中提供了Weak<T>,一个用于存在引用循环但只有一个方向有所有权的智能指针。我们已经展示过如何克隆Rc<T>来增加引用的strong_countWeak<T>是一种引用Rc<T>但不增加strong_count的方式:相反它增加Rc引用的weak_count。当Rc离开作用域,其内部值会在strong_count为 0 的时候被丢弃,即便weak_count不为 0 。为了能够从Weak<T>中获取值,首先需要使用upgrade方法将其升级为Option<Rc<T>>。升级Weak<T>的结果在Rc还未被丢弃时是Some,而在Rc被丢弃时是None。因为upgrade返回一个Option,我们知道 Rust 会确保SomeNone的情况都被处理并不会尝试使用一个无效的指针。

不同于列表 15-17 中每个项只知道它的下一项,假如我们需要一个树,它的项知道它的子项父项。

让我们从一个叫做Node的存放拥有所有权的i32值和其子Node值的引用的结构体开始:

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: i32,
    children: RefCell<Vec<Rc<Node>>>,
}

我们希望能够Node拥有其子节点,同时也希望变量可以拥有每个节点以便可以直接访问他们。这就是为什么Vec中的项是Rc<Node>值。我们也希望能够修改其他节点的子节点,这就是为什么childrenVec被放进了RefCell的原因。在列表 15-19 中创建了一个叫做leaf的带有值 3 并没有子节点的Node实例,和另一个带有值 5 和以leaf作为子节点的实例branch

Filename: src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        children: RefCell::new(vec![]),
    });

    let branch = Rc::new(Node {
        value: 5,
        children: RefCell::new(vec![leaf.clone()]),
    });
}

Listing 15-19: Creating a leaf node and a branch node where branch has leaf as one of its children but leaf has no reference to branch

leaf中的Node现在有两个所有者:leafbranch,因为我们克隆了leaf中的Rc并储存在了branch中。branch中的Node知道它与leaf相关联因为branchbranch.children中有leaf的引用。然而,leaf并不知道它与branch相关联,而我们希望leaf知道branch是其父节点。

为了做到这一点,需要在Node结构体定义中增加一个parent字段,不过parent的类型应该是什么呢?我们知道它不能包含Rc<T>,因为这样leaf.parent将会指向branchbranch.children会包含leaf的指针,这会形成引用循环。leafbranch不会被丢弃因为他们总是引用对方且引用计数永远也不会是零。

所以在parent的类型中是使用Weak<T>而不是Rc,具体来说是RefCell<Weak<Node>>

Filename: src/main.rs

use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: i32,
    parent: RefCell<Weak<Node>>,
    children: RefCell<Vec<Rc<Node>>>,
}

这样,一个节点就能够在拥有父节点时指向它,而并不拥有其父节点。一个父节点哪怕在拥有指向它的子节点也会被丢弃,只要是其自身也没有一个父节点就行。现在将main函数更新为如列表 15-20 所示:

Filename: src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());

    let branch = Rc::new(Node {
        value: 5,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![leaf.clone()]),
    });

    *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
}

Listing 15-20: A leaf node and a branch node where leaf has a Weak reference to its parent, branch

创建leaf节点是类似的;因为它作为开始并没有父节点,这里创建了一个新的Weak引用实例。当尝试通过upgrade方法获取leaf父节点的引用时,会得到一个None值,如第一个println!输出所示:

leaf parent = None

类似的,branch也有一个新的Weak引用,因为也没有父节点。leaf仍然作为branch的一个子节点。一旦在branch中有了一个新的Node实例,就可以修改leaf将一个branchWeak引用作为其父节点。这里使用了leafparent字段里的RefCellborrow_mut方法,接着使用了Rc::downgrade函数来从branch中的Rc值创建了一个指向branchWeak引用。

当再次打印出leaf的父节点时,这一次将会得到存放了branchSome值。另外需要注意到这里并没有打印出类似列表 15-14 中那样最终导致栈溢出的循环:Weak引用仅仅打印出(Weak)

leaf parent = Some(Node { value: 5, parent: RefCell { value: (Weak) },
children: RefCell { value: [Node { value: 3, parent: RefCell { value: (Weak) },
children: RefCell { value: [] } }] } })

没有无限的输出(或直到栈溢出)的事实表明这里并没有引用循环。另一种证明的方式时观察调用Rc::strong_countRc::weak_count的值。在列表 15-21 中,创建了一个新的内部作用域并将branch的创建放入其中,这样可以观察branch被创建时和离开作用域被丢弃时发生了什么:

Filename: src/main.rs

fn main() {
    let leaf = Rc::new(Node {
        value: 3,
        parent: RefCell::new(Weak::new()),
        children: RefCell::new(vec![]),
    });

    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );

    {
        let branch = Rc::new(Node {
            value: 5,
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(vec![leaf.clone()]),
        });
        *leaf.parent.borrow_mut() = Rc::downgrade(&branch);

        println!(
            "branch strong = {}, weak = {}",
            Rc::strong_count(&branch),
            Rc::weak_count(&branch),
        );

        println!(
            "leaf strong = {}, weak = {}",
            Rc::strong_count(&leaf),
            Rc::weak_count(&leaf),
        );
    }

    println!("leaf parent = {:?}", leaf.parent.borrow().upgrade());
    println!(
        "leaf strong = {}, weak = {}",
        Rc::strong_count(&leaf),
        Rc::weak_count(&leaf),
    );
}

Listing 15-21: Creating branch in an inner scope and examining strong and weak reference counts of leaf and branch

创建leaf之后,强引用计数是 1 (用于leaf自身)而弱引用计数是 0。在内部作用域中,在创建branch和关联leafbranch之后,branch的强引用计数为 1(用于branch自身)而弱引用计数为 1(因为leaf.parent通过一个Weak<T>指向branch)。leaf的强引用计数为 2,因为branch现在有一个leaf克隆的Rc储存在branch.children中。leaf的弱引用计数仍然为 0。

当内部作用域结束,branch离开作用域,其强引用计数减少为 0,所以其Node被丢弃。来自leaf.parent的弱引用计数 1 与Node是否被丢弃无关,所以并没有产生内存泄露!

如果在内部作用域结束后尝试访问leaf的父节点,会像leaf拥有父节点之前一样得到None值。在程序的末尾,leaf的强引用计数为 1 而弱引用计数为 0,因为现在leaf又是唯一指向其自己的值了。

所有这些管理计数和值是否应该被丢弃的逻辑都通过RcWeak和他们的Drop trait 实现来控制。通过在定义中指定从子节点到父节点的关系为一个Weak<T>引用,就能够拥有父节点和子节点之间的双向引用而不会造成引用循环和内存泄露。

总结

现在我们学习了如何选择不同类型的智能指针来选择不同的保证并与 Rust 的常规引用向取舍。Box<T>有一个已知的大小并指向分配在堆上的数据。Rc<T>记录了堆上数据的引用数量这样就可以拥有多个所有者。RefCell<T>和其内部可变性使其可以用于需要不可变类型,但希望在运行时而不是编译时检查借用规则的场景。

我们还介绍了提供了很多智能指针功能的 trait DerefDrop。同时探索了形成引用循环和造成内存泄漏的可能性,以及如何使用Weak<T>避免引用循环。

如果本章内容引起了你的兴趣并希望现在就实现你自己的智能指针的话,请阅读 The Nomicon 来获取更多有用的信息。

接下来,让我们谈谈 Rust 的并发。我们还会学习到一些新的对并发有帮助的智能指针。

无畏并发

ch16-00-concurrency.md
commit da15de39eaabd50100d6fa662c653169254d9175

确保内存安全并不是 Rust 的唯一目标:更好的处理并发和并行编程一直是 Rust 的另一个主要目标。 并发编程(concurrent programming)代表程序的不同部分相互独立的执行,而并行编程代表程序不同部分同时执行,这两个概念在计算机拥有更多处理器可供程序利用时变得更加重要。由于历史的原因,在此类上下文中编程一直是困难且容易出错的:Rust 希望能改变这一点。

最开始,我们认为内存安全和防止并发问题是需要通过两个不同的方法解决的两个相互独立的挑战。然而,随着时间的推移,我们发现所有权和类型系统是一系列解决内存安全并发问题的强用力的工具!通过改进所有权和类型检查,很多并发错误在 Rust 中都是编译时错误,而不是运行时错误。我们给 Rust 的这一部分起了一个绰号无畏并发fearless concurrency)。无畏并发意味着 Rust 不光允许你自信代码不会出现诡异的错误,也让你可以轻易重构这种代码而无需担心会引入新的 bug。

注意:对于 Rust 的口号无畏并发,这里用并发指代很多问题而不是更精确的区分并发和(或)并行,是出于简化问题的原因。如果这是一本专注于并发和/或并行的书,我们肯定会更精确的。对于本章,当我们谈到并发时,请自行替换为并发和(或)并行

很多语言所提供的处理并发问题的解决方法都非常有特色,尤其是对于更高级的语言,这是一个非常合理的策略。然而对于底层语言则没有奢侈的选择。在任何给定的情况下,我们都期望底层语言可以提供最高的性能,并且对硬件有更薄的抽象。因此,Rust 给了我们多种工具,并以适合实际情况和需求的方式来为问题建模。

如下是本章将要涉及到的内容:

  • 如何创建线程来同时运行多段代码。
  • 并发消息传递Message passing),其中通道(channel)被用来在线程间传递消息。
  • 并发共享状态Shared state),其中多个线程可以访问同一片数据。
  • SyncSend trait,他们允许 Rust 的并发保证能被扩展到用户定义的和标准库中提供的类型中。

使用线程同时运行代码

ch16-01-threads.md
commit 55b294f20fc846a13a9be623bf322d8b364cee77

在今天使用的大部分操作系统中,当程序执行时,操作系统运行代码的上下文称为进程process)。操作系统可以运行很多进程,而操作系统也管理这些进程使得多个程序可以在电脑上同时运行。

我们可以将每个进程运行一个程序的概念再往下抽象一层:程序也可以在其上下文中同时运行独立的部分。这个功能叫做线程thread)。

将程序需要执行的计算拆分到多个线程中可以提高性能,因为程序可以在同时进行很多工作。不过使用线程会增加程序复杂性。因为线程是同时运行的,所以无法预先保证不同线程中的代码的执行顺序。这可能会由于线程以不一致的顺序访问数据或资源而导致竞争状态,或由于两个线程相互阻止对方继续运行而造成死锁,以及仅仅出现于特定场景并难以稳定重现的 bug。Rust 减少了这些或那些使用线程的负面影响,不过在多线程上下文中编程,相比只期望在单个线程中运行的程序,仍然要采用不同的思考方式和代码结构。

编程语言有一些不同的方法来实现线程。很多操作系统提供了创建新线程的 API。另外,很多编程语言提供了自己的特殊的线程实现。编程语言提供的线程有时被称作轻量级lightweight)或绿色green)线程。这些语言将一系列绿色线程放入不同数量的操作系统线程中执行。因为这个原因,语言调用操作系统 API 创建线程的模型有时被称为 1:1,一个 OS 线程对应一个语言线程。绿色线程模型被称为 M:N 模型,M个绿色线程对应N个 OS 线程,这里MN不必相同。

每一个模型都有其自己的优势和取舍。对于 Rust 来说最重要的取舍是运行时支持。运行时是一个令人迷惑的概念;在不同上下文中它可能有不同的含义。这里其代表二进制文件中包含的语言自身的代码。对于一些语言,这些代码是庞大的,另一些则很小。通俗的说,“没有运行时”通常被人们用来指代“小运行时”,因为任何非汇编语言都存在一定数量的运行时。更小的运行时拥有更少的功能不过其优势在于更小的二进制输出。更小的二进制文件更容易在更多上下文中与其他语言结合。虽然很多语言觉得增加运行时来换取更多功能没有什么问题,但是 Rust 需要做到几乎没有运行时,同时为了保持高性能必需能够调用 C 语言,这点也是不能妥协的。

绿色线程模型功能要求更大的运行时来管理这些线程。为此,Rust 标准库只提供了 1:1 线程模型实现。因为 Rust 是这么一个底层语言,所以有相应的 crate 实现了 M:N 线程模型,如果你宁愿牺牲性能来换取例如更好的线程运行控制和更低的上下文切换成本。

现在我们明白了 Rust 中的线程是如何定义的,让我们开始探索如何使用标准库提供的线程相关的 API吧。

使用spawn创建新线程

为了创建一个新线程,调用thread::spawn函数并传递一个闭包(第十三章学习了闭包),它包含希望在新线程运行的代码。列表 16-1 中的例子在新线程中打印了一些文本而其余的文本在主线程中打印:

Filename: src/main.rs

use std::thread;

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
    }
}

Listing 16-1: Creating a new thread to print one thing while the main thread is printing something else

注意这个函数编写的方式,当主线程结束时,它也会停止新线程。这个程序的输出每次可能都略微不同,不过它大体上看起来像这样:

hi number 1 from the main thread!
hi number 1 from the spawned thread!
hi number 2 from the main thread!
hi number 2 from the spawned thread!
hi number 3 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the main thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!

这些线程可能会轮流运行,不过并不保证如此。在这里,主线程先行打印,即便新创建线程的打印语句位于程序的开头。甚至即便我们告诉新建的线程打印直到i等于 9 ,它在主线程结束之前也只打印到了 5。如果你只看到了一个线程,或没有出现重叠打印的现象,尝试增加 range 的数值来增加线程暂停并切换到其他线程运行的机会。

使用join等待所有线程结束

由于主线程先于新建线程结束,不仅列表 16-1 中的代码大部分时候不能保证新建线程执行完毕,甚至不能实际保证新建线程会被执行!可以通过保存thread::spawn的返回值来解决这个问题,这是一个JoinHandle。这看起来如列表 16-2 所示:

Filename: src/main.rs

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
    }

    handle.join();
}

Listing 16-2: Saving a JoinHandle from thread::spawn to guarantee the thread is run to completion

JoinHandle是一个拥有所有权的值,它可以等待一个线程结束,这也正是join方法所做的。通过调用这个句柄的join,当前线程会阻塞直到句柄所代表的线程结束。因为我们将join调用放在了主线程的for循环之后,运行这个例子将产生类似这样的输出:

hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 1 from the spawned thread!
hi number 3 from the main thread!
hi number 2 from the spawned thread!
hi number 4 from the main thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!

这两个线程仍然会交替执行,不过主线程会由于handle.join()调用会等待直到新建线程执行完毕。

如果将handle.join()放在主线程的for循环之前,像这样:

Filename: src/main.rs

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
        }
    });

    handle.join();

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
    }
}

主线程会等待直到新建线程执行完毕之后才开始执行for循环,所以输出将不会交替出现:

hi number 1 from the spawned thread!
hi number 2 from the spawned thread!
hi number 3 from the spawned thread!
hi number 4 from the spawned thread!
hi number 5 from the spawned thread!
hi number 6 from the spawned thread!
hi number 7 from the spawned thread!
hi number 8 from the spawned thread!
hi number 9 from the spawned thread!
hi number 1 from the main thread!
hi number 2 from the main thread!
hi number 3 from the main thread!
hi number 4 from the main thread!

稍微考虑一下将join放置与何处会影响线程是否同时运行。

线程和move闭包

第十三章有一个我们没有讲到的闭包功能,它经常用于thread::spawnmove闭包。第十三章中讲到:

获取他们环境中值的闭包主要用于开始新线程的场景

现在我们正在创建新线程,所以让我们讨论一下获取环境值的闭包吧!

注意列表 16-1 中传递给thread::spawn的闭包并没有任何参数:并没有在新建线程代码中使用任何主线程的数据。为了在新建线程中使用来自于主线程的数据,需要新建线程的闭包获取它需要的值。列表 16-3 展示了一个尝试在主线程中创建一个 vector 并用于新建线程的例子,不过这么写还不能工作:

Filename: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    handle.join();
}

Listing 16-3: Attempting to use a vector created by the main thread from another thread

闭包使用了v,所以闭包会获取v并使其成为闭包环境的一部分。因为thread::spawn在一个新线程中运行这个闭包,所以可以在新线程中访问v

然而当编译这个例子时,会得到如下错误:

error[E0373]: closure may outlive the current function, but it borrows `v`,
which is owned by the current function
 -->
  |
6 |     let handle = thread::spawn(|| {
  |                                ^^ may outlive borrowed value `v`
7 |         println!("Here's a vector: {:?}", v);
  |                                           - `v` is borrowed here
  |
help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword, as shown:
  |     let handle = thread::spawn(move || {

当在闭包环境中获取某些值时,Rust 会尝试推断如何获取它。println!只需要v的一个引用,所以闭包尝试借用v。但是这有一个问题:我们并不知道新建线程会运行多久,所以无法知道v是否一直时有效的。

考虑一下列表 16-4 中的代码,它展示了一个v的引用很有可能不再有效的场景:

Filename: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(|| {
        println!("Here's a vector: {:?}", v);
    });

    drop(v); // oh no!

    handle.join();
}

Listing 16-4: A thread with a closure that attempts to capture a reference to v from a main thread that drops v

这些代码可以运行,而新建线程则可能直接就出错了并完全没有机会运行。新建线程内部有一个v的引用,不过主线程仍在执行:它立刻丢弃了v,使用了第十五章提到的显式丢弃其参数的drop函数。接着,新建线程开始执行,现在v是无效的了,所以它的引用也就是无效的。噢,这太糟了!

为了修复这个问题,我们可以听取错误信息的建议:

help: to force the closure to take ownership of `v` (and any other referenced
variables), use the `move` keyword, as shown:
  |     let handle = thread::spawn(move || {

通过在闭包之前增加move关键字,我们强制闭包获取它使用的值的所有权,而不是引用借用。列表 16-5 中展示的对列表 16-3 代码的修改可以按照我们的预期编译并运行:

Filename: src/main.rs

use std::thread;

fn main() {
    let v = vec![1, 2, 3];

    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });

    handle.join();
}

Listing 16-5: Using the move keyword to force a closure to take ownership of the values it uses

那么列表 16-4 中那个主线程调用了drop的代码该怎么办呢?如果在闭包上增加了move,就将v移动到了闭包的环境中,我们将不能对其调用drop了。相反会出现这个编译时错误:

error[E0382]: use of moved value: `v`
  -->
   |
6  |     let handle = thread::spawn(move || {
   |                                ------- value moved (into closure) here
...
10 |     drop(v); // oh no!
   |          ^ value used here after move
   |
   = note: move occurs because `v` has type `std::vec::Vec<i32>`, which does
   not implement the `Copy` trait

Rust 的所有权规则又一次帮助了我们!

现在我们有一个线程和线程 API 的基本了解,让我们讨论一下使用线程实际可以什么吧。

使用消息传递在线程间传送数据

ch16-02-message-passing.md
commit da15de39eaabd50100d6fa662c653169254d9175

最近人气正在上升的一个并发方式是消息传递message passing),这里线程或 actor 通过发送包含数据的消息来沟通。这个思想来源于口号:

Do not communicate by sharing memory; instead, share memory by communicating.

不要共享内存来通讯;而是要通讯来共享内存。

--Effective Go

实现这个目标的主要工具是通道channel)。通道有两部分组成,一个发送者(transmitter)和一个接收者(receiver)。代码的一部分可以调用发送者和想要发送的数据,而另一部分代码可以在接收的那一端收取消息。

我们将编写一个例子使用一个线程生成值并向通道发送他们。主线程会接收这些值并打印出来。

首先,如列表 16-6 所示,先创建一个通道但不做任何事:

Filename: src/main.rs

use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
#     tx.send(()).unwrap();
}

Listing 16-6: Creating a channel and assigning the two halves to tx and rx

mpsc::channel函数创建一个新的通道。mpsc多个生产者,单个消费者multiple producer, single consumer)的缩写。简而言之,可以有多个产生值的发送端,但只能有一个消费这些值的接收端。现在我们以一个单独的生产者开始,不过一旦例子可以工作了就会增加多个生产者。

mpsc::channel返回一个元组:第一个元素是发送端,而第二个元素是接收端。由于历史原因,很多人使用txrx作为发送者接收者的缩写,所以这就是我们将用来绑定这两端变量的名字。这里使用了一个let语句和模式来解构了元组。第十八章会讨论let语句中的模式和解构。

让我们将发送端移动到一个新建线程中并发送一个字符串,如列表 16-7 所示:

Filename: src/main.rs

use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });
}

Listing 16-7: Moving tx to a spawned thread and sending "hi"

正如上一部分那样使用thread::spawn来创建一个新线程。并使用一个move闭包来将tx移动进闭包这样新建线程就是其所有者。

通道的发送端有一个send方法用来获取需要放入通道的值。send方法返回一个Result<T, E>类型,因为如果接收端被丢弃了,将没有发送值的目标,所以发送操作会出错。在这个例子中,我们简单的调用unwrap来忽略错误,不过对于一个真实程序,需要合理的处理它。第九章是你复习正确错误处理策略的好地方。

在列表 16-8 中,让我们在主线程中从通道的接收端获取值:

Filename: src/main.rs

use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

Listing 16-8: Receiving the value "hi" in the main thread and printing it out

通道的接收端有两个有用的方法:recvtry_recv。这里,我们使用了recv,它是 receive 的缩写。这个方法会阻塞执行直到从通道中接收一个值。一旦发送了一个值,recv会在一个Result<T, E>中返回它。当通道发送端关闭,recv会返回一个错误。try_recv不会阻塞;相反它立刻返回一个Result<T, E>

如果运行列表 16-8 中的代码,我们将会看到主线程打印出这个值:

Got: hi

通道与所有权如何交互

现在让我们做一个试验来看看通道与所有权如何在一起工作:我们将尝试在新建线程中的通道中发送完val之后再使用它。尝试编译列表 16-9 中的代码:

Filename: src/main.rs

use std::thread;
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
        println!("val is {}", val);
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

Listing 16-9: Attempting to use val after we have sent it down the channel

这里尝试在通过tx.send发送val到通道中之后将其打印出来。这是一个坏主意:一旦将值发送到另一个线程后,那个线程可能会在我们在此使用它之前就修改或者丢弃它。这会由于不一致或不存在的数据而导致错误或意外的结果。

尝试编译这些代码,Rust 会报错:

error[E0382]: use of moved value: `val`
  --> src/main.rs:10:31
   |
9  |         tx.send(val).unwrap();
   |                 --- value moved here
10 |         println!("val is {}", val);
   |                               ^^^ value used here after move
   |
   = note: move occurs because `val` has type `std::string::String`, which does
   not implement the `Copy` trait

我们的并发错误会造成一个编译时错误!send获取其参数的所有权并移动这个值归接收者所有。这个意味着不可能意外的在发送后再次使用这个值;所有权系统检查一切是否合乎规则。

在这一点上,消息传递非常类似于 Rust 的单所有权系统。消息传递的拥护者出于相似的原因支持消息传递,就像 Rustacean 们欣赏 Rust 的所有权一样:单所有权意味着特定类型问题的消失。如果一次只有一个线程可以使用某些内存,就没有出现数据竞争的机会。

发送多个值并观察接收者的等待

列表 16-8 中的代码可以编译和运行,不过这并不是很有趣:通过它难以看出两个独立的线程在一个通道上相互通讯。列表 16-10 则有一些改进会证明这些代码是并发执行的:新建线程现在会发送多个消息并在每个消息之间暂停一段时间。

Filename: src/main.rs

use std::thread;
use std::sync::mpsc;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let vals = vec![
            String::from("hi"),
            String::from("from"),
            String::from("the"),
            String::from("thread"),
        ];

        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::new(1, 0));
        }
    });

    for received in rx {
        println!("Got: {}", received);
    }
}

Listing 16-10: Sending multiple messages and pausing between each one

这一次,在新建线程中有一个字符串 vector 希望发送到主线程。我们遍历他们,单独的发送每一个字符串并通过一个Duration值调用thread::sleep函数来暂停一秒。

在主线程中,不再显式的调用recv函数:而是将rx当作一个迭代器。对于每一个接收到的值,我们将其打印出来。当通道被关闭时,迭代器也将结束。

当运行列表 16-10 中的代码时,将看到如下输出,每一行都会暂停一秒:

Got: hi
Got: from
Got: the
Got: thread

在主线程中并没有任何暂停或位于for循环中用于等待的代码,所以可以说主线程是在等待从新建线程中接收值。

通过克隆发送者来创建多个生产者

差不多在本部分的开头,我们提到了mpscmultiple producer, single consumer 的缩写。可以扩展列表 16-11 中的代码来创建都向同一接收者发送值的多个线程。这可以通过克隆通道的发送端在来做到,如列表 16-11 所示:

Filename: src/main.rs

# use std::thread;
# use std::sync::mpsc;
# use std::time::Duration;
#
# fn main() {
// ...snip...
let (tx, rx) = mpsc::channel();

let tx1 = tx.clone();
thread::spawn(move || {
    let vals = vec![
        String::from("hi"),
        String::from("from"),
        String::from("the"),
        String::from("thread"),
    ];

    for val in vals {
        tx1.send(val).unwrap();
        thread::sleep(Duration::new(1, 0));
    }
});

thread::spawn(move || {
    let vals = vec![
        String::from("more"),
        String::from("messages"),
        String::from("for"),
        String::from("you"),
    ];

    for val in vals {
        tx.send(val).unwrap();
        thread::sleep(Duration::new(1, 0));
    }
});
// ...snip...
#
#     for received in rx {
#         println!("Got: {}", received);
#     }
# }

Listing 16-11: Sending multiple messages and pausing between each one

这一次,在创建新线程之前,我们对通道的发送端调用了clone方法。这会给我们一个可以传递给第一个新建线程的发送端句柄。我们会将原始的通道发送端传递给第二个新建线程,这样每个线程将向通道的接收端发送不同的消息。

如果运行这些代码,你可能会看到这样的输出:

Got: hi
Got: more
Got: from
Got: messages
Got: for
Got: the
Got: thread
Got: you

虽然你可能会看到这些以不同的顺序出现。这依赖于你的系统!这也就是并发既有趣又困难的原因。如果你拿thread::sleep做实验,在不同的线程中提供不同的值,就会发现他们的运行更加不确定并每次都会产生不同的输出。

现在我们见识过了通道如何工作,再看看共享内存并发吧。

共享状态并发

ch16-03-shared-state.md
commit 9df612e93e038b05fc959db393c15a5402033f47

虽然消息传递是一个很好的处理并发的方式,但并不是唯一的一个。再次考虑一下它的口号:

Do not communicate by sharing memory; instead, share memory by communicating.

不要共享内存来通讯;而是要通讯来共享内存。

那么“共享内存来通讯”是怎样的呢?共享内存并发有点像多所有权:多个线程可以同时访问相同的内存位置。第十五章介绍了智能指针如何使得多所有权成为可能,然而这会增加额外的复杂性,因为需要以某种方式管理这些不同的所有者。

不过 Rust 的类型系统和所有权可以很好的帮助我们,正确的管理它们。以共享内存中更常见的并发原语:互斥器(mutexes)为例,让我们看看具体的情况。

互斥器一次只允许一个线程访问数据

互斥器mutex)是一种用于共享内存的并发原语。它是“mutual exclusion”的缩写,也就是说,任意时间,它只允许一个线程访问某些数据。互斥器以难以使用著称,因为你不得不记住:

  1. 在使用数据之前尝试获取锁。
  2. 处理完被互斥器所保护的数据之后,必须解锁数据,这样其他线程才能够获取锁。

现实中也有互斥器的例子,想象一下在一个会议中,只有一个麦克风。如果一个成员要发言,他必须请求使用麦克风。一旦得到了麦克风,他可以畅所欲言,然后将麦克风交给下一个希望讲话的成员。如果成员在没有麦克风的时候就开始叫喊,或者在其他成员发言结束之前就拿走麦克风,是很不合适的。如果这个共享的麦克风因为此类原因而出现问题,会议将无法正常进行。

正确的管理互斥器异常复杂,这也是许多人之所以热衷于通道的原因。然而,在 Rust 中,得益于类型系统和所有权,我们不会在锁和解锁上出错。

Mutex<T>的 API

让我们看看列表 16-12 中使用互斥器的例子,现在不涉及多线程:

Filename: src/main.rs

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);

    {
        let mut num = m.lock().unwrap();
        *num = 6;
    }

    println!("m = {:?}", m);
}

Listing 16-12: Exploring the API of Mutex<T> in a single threaded context for simplicity

像很多类型一样,我们使用关联函数 new 来创建一个 Mutex<T>。使用lock方法获取锁,以访问互斥器中的数据。这个调用会阻塞,直到我们拥有锁为止。如果另一个线程拥有锁,并且那个线程 panic 了,则这个调用会失败。类似于列表 16-6 那样,我们暂时使用 unwrap() 进行错误处理,或者使用第九章中提及的更好的工具。

一旦获取了锁,就可以将返回值(在这里是num)作为一个数据的可变引用使用了。观察 Rust 类型系统如何保证使用值之前必须获取锁:Mutex<i32>并不是一个i32,所以必须获取锁才能使用这个i32值。我们是不会忘记这么做的,因为类型系统不允许。

你也许会怀疑,Mutex<T>是一个智能指针?是的!更准确的说,lock调用返回一个叫做MutexGuard的智能指针。类似我们在第十五章见过的智能指针,它实现了Deref来指向其内部数据。另外MutexGuard有一个用来释放锁的Drop实现。这样就不会忘记释放锁了。这在MutexGuard离开作用域时会自动发生,例如它发生于列表 16-12 中内部作用域的结尾。接着可以打印出互斥器的值并发现能够将其内部的i32改为 6。

在线程间共享Mutex<T>

现在让我们尝试使用Mutex<T>在多个线程间共享值。我们将启动十个线程,并在各个线程中对同一个计数器值加一,这样计数器将从 0 变为 10。注意,接下来的几个例子会出现编译错误,而我们将通过这些错误来学习如何使用 Mutex<T>,以及 Rust 又是如何辅助我们以确保正确。列表 16-13 是最开始的例子:

Filename: src/main.rs

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);
    let mut handles = vec![];

    for _ in 0..10 {
        let handle = thread::spawn(|| {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Listing 16-13: The start of a program having 10 threads each increment a counter guarded by a Mutex<T>

这里创建了一个 counter 变量来存放内含 i32Mutex<T>,类似列表 16-12 那样。接下来使用 range 创建了 10 个线程。使用了 thread::spawn 并对所有线程使用了相同的闭包:他们每一个都将调用 lock 方法来获取 Mutex<T> 上的锁,接着将互斥器中的值加一。当一个线程结束执行,num 会离开闭包作用域并释放锁,这样另一个线程就可以获取它了。

在主线程中,我们像列表 16-2 那样收集了所有的 join 句柄,调用它们的 join 方法来确保所有线程都会结束。之后,主线程会获取锁并打印出程序的结果。

之前提示过这个例子不能编译,让我们看看为什么!

error[E0373]: closure may outlive the current function, but it borrows
`counter`, which is owned by the current function
  -->
   |
9  |         let handle = thread::spawn(|| {
   |                                    ^^ may outlive borrowed value `counter`
10 |             let mut num = counter.lock().unwrap();
   |                           ------- `counter` is borrowed here
   |
help: to force the closure to take ownership of `counter` (and any other
referenced variables), use the `move` keyword, as shown:
   |         let handle = thread::spawn(move || {

这类似于列表 16-5 中解决了的问题。考虑到启动了多个线程,Rust 无法知道这些线程会运行多久,而在每一个线程尝试借用 counter 时它是否仍然有效。帮助信息提醒了我们如何解决它:可以使用 move 来给予每个线程其所有权。尝试在闭包上做一点改动:

thread::spawn(move || {

再次编译。这回出现了一个不同的错误!

error[E0382]: capture of moved value: `counter`
  -->
   |
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved (into closure) here
10 |             let mut num = counter.lock().unwrap();
   |                           ^^^^^^^ value captured here after move
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
   which does not implement the `Copy` trait

error[E0382]: use of moved value: `counter`
  -->
   |
9  |         let handle = thread::spawn(move || {
   |                                    ------- value moved (into closure) here
...
21 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value used here after move
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
   which does not implement the `Copy` trait

error: aborting due to 2 previous errors

move 并没有像列表 16-5 中那样解决问题。为什么呢?错误信息有点难懂,因为它表明 counter 被移动进了闭包,接着它在调用 lock 时被捕获。这似乎是我们希望的,然而不被允许。

让我们推理一下。这次不再使用 for 循环创建 10 个线程,只创建两个线程,看看会发生什么。将列表 16-13 中第一个for循环替换为如下代码:

let handle = thread::spawn(move || {
    let mut num = counter.lock().unwrap();

    *num += 1;
});
handles.push(handle);

let handle2 = thread::spawn(move || {
    let mut num2 = counter.lock().unwrap();

    *num2 += 1;
});
handles.push(handle2);

这里创建了两个线程,并将第二个线程所用的变量改名为 handle2num2。我们简化了例子,看是否能理解错误信息。此次编译给出如下信息:

error[E0382]: capture of moved value: `counter`
  -->
   |
8  |     let handle = thread::spawn(move || {
   |                                ------- value moved (into closure) here
...
16 |         let mut num = counter.lock().unwrap();
   |                       ^^^^^^^ value captured here after move
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
   which does not implement the `Copy` trait

error[E0382]: use of moved value: `counter`
  -->
   |
8  |     let handle = thread::spawn(move || {
   |                                ------- value moved (into closure) here
...
26 |     println!("Result: {}", *counter.lock().unwrap());
   |                             ^^^^^^^ value used here after move
   |
   = note: move occurs because `counter` has type `std::sync::Mutex<i32>`,
   which does not implement the `Copy` trait

error: aborting due to 2 previous errors

啊哈!第一个错误信息中说,counter 被移动进了 handle 所代表线程的闭包中。因此我们无法在第二个线程中对其调用 lock,并将结果储存在 num2 中时捕获counter!所以 Rust 告诉我们不能将 counter 的所有权移动到多个线程中。这在之前很难看出,因为我们在循环中创建了多个线程,而 Rust 无法在每次迭代中指明不同的线程(没有临时变量 num2)。

多线程和多所有权

在第十五章中,我们通过使用智能指针 Rc<T> 来创建引用计数的值,以便拥有多所有权。同时第十五章提到了 Rc<T> 只能在单线程环境中使用,不过还是在这里试用 Rc<T> 看看会发生什么。列表 16-14 将 Mutex<T> 装进了 Rc<T> 中,并在移入线程之前克隆了 Rc<T>。再用循环来创建线程,保留闭包中的 move 关键字:

Filename: src/main.rs

use std::rc::Rc;
use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Rc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = counter.clone();
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Listing 16-14: Attempting to use Rc<T> to allow multiple threads to own the Mutex<T>

再一次编译并...出现了不同的错误!编译器真是教会了我们很多!

error[E0277]: the trait bound `std::rc::Rc<std::sync::Mutex<i32>>:
std::marker::Send` is not satisfied
  -->
   |
11 |         let handle = thread::spawn(move || {
   |                      ^^^^^^^^^^^^^ the trait `std::marker::Send` is not
   implemented for `std::rc::Rc<std::sync::Mutex<i32>>`
   |
   = note: `std::rc::Rc<std::sync::Mutex<i32>>` cannot be sent between threads
   safely
   = note: required because it appears within the type
   `[closure@src/main.rs:11:36: 15:10
   counter:std::rc::Rc<std::sync::Mutex<i32>>]`
   = note: required by `std::thread::spawn`

哇哦,太长不看!说重点:第一个提示表明 Rc<Mutex<i32>> 不能安全的在线程间传递。理由也在错误信息中,“不满足 Send trait bound”(the trait bound Send is not satisfied)。下一部分将会讨论 Send,它是确保许多用在多线程中的类型,能够适合并发环境的 trait 之一。

不幸的是,Rc<T> 并不能安全的在线程间共享。当 Rc<T> 管理引用计数时,它必须在每一个 clone 调用时增加计数,并在每一个克隆被丢弃时减少计数。Rc<T> 并没有使用任何并发原语,来确保改变计数的操作不会被其他线程打断。在计数出错时可能会导致诡异的 bug,比如可能会造成内存泄漏,或在使用结束之前就丢弃一个值。如果有一个类型与 Rc<T> 相似,又以一种线程安全的方式改变引用计数,会怎么样呢?

原子引用计数 Arc<T>

答案是肯定的,确实有一个类似Rc<T>并可以安全的用于并发环境的类型:Arc<T>。字母“a”代表原子性atomic),所以这是一个原子引用计数atomically reference counted)类型。原子性是另一类这里还未涉及到的并发原语;请查看标准库中std::sync::atomic的文档来获取更多细节。其中的要点就是:原子性类型工作起来类似原始类型,不过可以安全的在线程间共享。

为什么不是所有的原始类型都是原子性的?为什么不是所有标准库中的类型都默认使用Arc<T>实现?线程安全带来性能惩罚,我们希望只在必要时才为此买单。如果只是在单线程中对值进行操作,原子性提供的保证并无必要,代码可以因此运行的更快。

回到之前的例子:Arc<T>Rc<T>除了Arc<T>内部的原子性之外没有区别。其 API 也相同,所以可以修改use行和new调用。列表 16-15 中的代码最终可以编译和运行:

Filename: src/main.rs

use std::sync::{Mutex, Arc};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = counter.clone();
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();

            *num += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Listing 16-15: Using an Arc<T> to wrap the Mutex<T> to be able to share ownership across multiple threads

这会打印出:

Result: 10

成功了!我们从 0 数到了 10,这可能并不是很显眼,不过一路上我们学习了很多关于Mutex<T>和线程安全的内容!这个例子中构建的结构可以用于比增加计数更为复杂的操作。能够被分解为独立部分的计算可以像这样被分散到多个线程中,并可以使用Mutex<T>来允许每个线程在他们自己的部分更新最终的结果。

你可能注意到了,因为counter是不可变的,不过可以获取其内部值的可变引用,这意味着Mutex<T>提供了内部可变性,就像Cell系列类型那样。正如第十五章中使用RefCell<T>可以改变Rc<T>中的内容那样,同样的可以使用Mutex<T>来改变Arc<T>中的内容。

回忆一下Rc<T>并没有避免所有可能的问题:我们也讨论了当两个Rc<T>相互引用时的引用循环的可能性,这可能造成内存泄露。Mutex<T>有一个类似的 Rust 同样也不能避免的问题:死锁。死锁deadlock)是一个场景中操作需要锁定两个资源,而两个线程分别拥有一个锁并永远相互等待的问题。如果你对这个主题感兴趣,尝试编写一个带有死锁的 Rust 程序,接着研究任何其他语言中使用互斥器的死锁规避策略并尝试在 Rust 中实现他们。标准库中Mutex<T>MutexGuard的 API 文档会提供有用的信息。

Rust 的类型系统和所有权规则,确保了线程在更新共享值时拥有独占的访问权限,所以线程不会以不可预测的方式覆盖彼此的操作。虽然为了使一切正确运行而在编译器上花了一些时间,但是我们节省了未来的时间,尤其是线程以特定顺序执行才会出现的诡异错误难以重现。

接下来,为了丰富本章的内容,让我们讨论一下SendSync trait 以及如何对自定义类型使用他们。

使用SyncSend trait 的可扩展并发

ch16-04-extensible-concurrency-sync-and-send.md
commit 9430a3d28a2121a938d704ce48b15d21062f880e

Rust 的并发模型中一个有趣的方面是:语言本身对并发知之甚少。我们之前讨论的几乎所有内容,都属于标准库,而不是语言本身的内容。由于不需要语言提供并发相关的基础设施,并发方案不受标准库或语言所限:我们可以编写自己的或使用别人编写的。

我们说“几乎所有内容都不属于语言本身”,那么属于语言本身的是什么呢?是两个 trait,都位于std::markerSyncSend

Send用于表明所有权可能被传送给其他线程

Send标记 trait 表明类型的所有权可能被在线程间传递。几乎所有的 Rust 类型都是Send的,不过有一些例外。比如标准库中提供的 Rc<T>:如果克隆Rc<T>值,并尝试将克隆的所有权传递给另一个线程,这两个线程可能会同时更新引用计数。正如上一部分提到的,Rc<T>被实现为用于单线程场景,这时不需要为拥有线程安全的引用计数而付出性能代价。

因为 Rc<T> 没有标记为 Send,Rust 的类型系统和 trait bound 会确保我们不会错误的把一个 Rc<T> 值不安全的在线程间传递。列表 16-14 曾尝试这么做,不过得到了一个错误,the trait Send is not implemented for Rc<Mutex<i32>>。而使用标记为 SendArc<T> 时,就没有问题了。

任何完全由 Send 的类型组成的类型也会自动被标记为 Send:几乎所有基本类型都是 Send 的,大部分标准库类型是Send的,除了Rc<T>,以及第十九章将会讨论的裸指针(raw pointer)。

Sync 表明多线程访问是安全的

Sync 标记 trait 表明一个类型可以安全的在多个线程中拥有其值的引用。换一种方式来说,对于任意类型 T,如果&TT的引用)是Send的话T就是Sync的,这样其引用就可以安全的发送到另一个线程。类似于 Send 的情况,基本类型是 Sync 的,完全由 Sync 的类型组成的类型也是 Sync 的。

Rc<T> 也不是 Sync 的,出于其不是Send的相同的原因。RefCell<T>(第十五章讨论过)和Cell<T>系列类型不是Sync的。RefCell<T>在运行时所进行的借用检查也不是线程安全的。Mutex<T>Sync的,正如上一部分所讲的它可以被用来在多线程中共享访问。

手动实现SendSync是不安全的

通常并不需要实现SendSync trait,由属于SendSync的类型组成的类型,自动就是SendSync的。因为他们是标记 trait,甚至都不需要实现任何方法。他们只是用来加强并发相关的不可变性的。

实现这些标记 trait 涉及到编写不安全的 Rust 代码,第十九章将会讲述具体的方法;当前重要的是,在创建新的由不是SendSync的部分构成的并发类型时需要多加小心,以确保维持其安全保证。The Nomicon 中有更多关于这些保证以及如何维持他们的信息。

总结

这不会是本书最后一个出现并发的章节;第二十章的项目会在更现实的场景中使用这些概念,而不像本章中讨论的这些小例子。

正如我们提到的,因为 Rust 本身很少有处理并发的部分内容,有很多的并发方案都由 crate 实现。他们比标准库要发展的更快;请在网上搜索当前最新的用于多线程场景的 crate。

Rust 提供了用于消息传递的通道,和像Mutex<T>Arc<T>这样可以安全的用于并发上下文的智能指针。类型系统和借用检查器会确保这些场景中的代码,不会出现数据竞争和无效的引用。一旦代码可以编译了,我们就可以坚信这些代码可以正确的运行于多线程环境,而不会出现其他语言中经常出现的那些难以追踪的 bug。并发编程不再是什么可怕的概念:无所畏惧地并发吧!

接下来,让我们讨论一下当 Rust 程序变得更大时,有哪些符合语言习惯的问题建模方法和结构化解决方案,以及 Rust 的风格是如何与面向对象编程(Object Oriented Programming)中那些你所熟悉的概念相联系的。

Rust 是一个面向对象的编程语言吗?

ch17-00-oop.md
commit 759801361bde74b47e81755fff545c66020e6e63

面向对象编程(Object-Oriented Programming)是一种起源于 20 世纪 60 年代的 Simula 编程语言的模式化编程方式,然后在 90 年代随着 C++ 语言开始流行。关于 OOP 是什么有很多相互矛盾的定义:在一些定义下,Rust 是面向对象的;在其他定义下,Rust 不是。在本章节中,我们会探索一些被普遍认为是面向对象的特性和这些特性是如何体现在 Rust 语言习惯中的。

什么是面向对象?

ch17-01-what-is-oo.md
commit 2a9b2a1b019ad6d4832ff3e56fbcba5be68b250e

关于一个语言被称为面向对象所需的功能,在编程社区内并未达成一致意见。Rust 被很多不同的编程范式影响;我们探索了十三章提到的来自函数式编程的特性。面向对象编程语言所共享的一些特性往往是对象、封装和继承。让我们看一下这每一个概念的含义以及 Rust 是否支持他们。

对象包含数据和行为

Design Patterns: Elements of Reusable Object-Oriented Software这本书被俗称为The Gang of Four book,是面向对象编程模式的目录。它这样定义面向对象编程:

Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations.

面向对象的程序是由对象组成的。一个对象包含数据和操作这些数据的过程。这些过程通常被称为方法操作

在这个定义下,Rust 是面向对象的:结构体和枚举包含数据而 impl 块提供了在结构体和枚举之上的方法。虽然带有方法的结构体和枚举并不被称为对象,但是他们提供了与对象相同的功能,参考 Gang of Four 中对象的定义。

隐藏了实现细节的封装

另一个通常与面向对象编程相关的方面是封装encapsulation)的思想:对象的实现细节不能被使用对象的代码获取到。唯一与对象交互的方式是通过对象提供的公有 API;使用对象的代码无法深入到对象内部并直接改变数据或者行为。封装使得改变和重构对象的内部时无需改变使用对象的代码。

就像我们在第七章讨论的那样,可以使用pub关键字来决定模块、类型函数和方法是公有的,而默认情况下一切都是私有的。比如,我们可以定义一个包含一个i32类型的 vector 的结构体AveragedCollection。结构体也可以有一个字段,该字段保存了 vector 中所有值的平均值。这样,希望知道结构体中的 vector 的平均值的人可以随时获取它,而无需自己计算。AveragedCollection会为我们缓存平均值结果。列表 17-1 有AveragedCollection结构体的定义:

文件名: src/lib.rs

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

列表 17-1: AveragedCollection结构体维护了一个整型列表和集合中所有元素的平均值。

注意,结构体自身被标记为pub,这样其他代码可以使用这个结构体,但是在结构体内部的字段仍然是私有的。这是非常重要的,因为我们希望保证变量被增加到列表或者被从列表删除时,也会同时更新平均值。可以通过在结构体上实现addremoveaverage方法来做到这一点,如列表 17-2 所示:

文件名: src/lib.rs

# pub struct AveragedCollection {
#     list: Vec<i32>,
#     average: f64,
# }
impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            },
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

列表 17-2: 在AveragedCollection结构体上实现了addremoveaverage公有方法

公有方法addremoveaverage是修改AveragedCollection实例的唯一方式。当使用add方法把一个元素加入到list或者使用remove方法来删除它时,这些方法的实现同时会调用私有的update_average方法来更新average字段。因为listaverage是私有的,没有其他方式来使得外部的代码直接向list增加或者删除元素,直接操作list可能会引发average字段不同步。average方法返回average字段的值,这使得外部的代码只能读取average而不能修改它。

因为我们已经封装好了AveragedCollection的实现细节,将来可以轻松改变类似数据结构这些方面的内容。例如,可以使用HashSet代替Vec作为list字段的类型。只要addremoveaverage公有函数的签名保持不变,使用AveragedCollection的代码就无需改变。如果将List暴露给外部代码时,未必都是这样,因为HashSetVec使用不同的方法增加或移除项,所以如果要想直接修改list的话,外部的代码可能不得不修改。

如果封装是一个语言被认为是面向对象语言所必要的方面的话,那么 Rust 就满足这个要求。在代码中不同的部分使用或者不使用pub决定了实现细节的封装。

作为类型系统的继承和作为代码共享的继承

继承Inheritance)是一个很多编程语言都提供的机制,一个对象可以定义为继承另一个对象的定义,这使其可以获得父对象的数据和行为,而不用重新定义。一些人定义面向对象语言时,认为继承是一个特色。

如果一个语言必须有继承才能被称为面向对象语言的话,那么 Rust 就不是面向对象的。无法定义一个结构体继承自另外一个结构体,从而获得父结构体的成员和方法。然而,如果你过去常常在你的编程工具箱使用继承,根据你希望使用继承的原因,Rust 也提供了其他的解决方案。

使用继承有两个主要的原因。第一个是为了重用代码:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。相反 Rust 代码可以使用默认 trait 方法实现来进行共享,在列表 10-14 中我们见过在Summarizable trait 上增加的summary方法的默认实现。任何实现了Summarizable trait 的类型都可以使用summary方法而无须进一步实现。这类似于父类有一个方法的实现,而通过继承子类也拥有这个方法的实现。当实现Summarizable trait 时也可以选择覆盖summary的默认实现,这类似于子类覆盖从父类继承的方法实现。

第二个使用继承的原因与类型系统有关:用来表现子类型可以在父类型被使用的地方使用。这也被称为多态polymorphism),意味着如果多种对象有一个相同的形态大小,它们可以替代使用。

虽然很多人使用“多态”("polymorphism")来描述继承,但是它实际上是一种特殊的多态,称为“子类型多态”("sub-type polymorphism")。也有很多种其他形式的多态,在 Rust 中带有泛型参数的 trait bound 也是多态,更具体的说是“参数多态”("parametric polymorphism")。不同类型多态的确切细节在这里并不关键,所以不要过于担心细节,只需要知道 Rust 有多种多态相关的特色就好,不同于很多其他 OOP 语言。

为了支持这种模式,Rust 有 trait 对象trait objects),这样就可以使用任意类型的值,只要这个值实现了指定的 trait。

继承最近在很多编程语言的设计方案中失宠了。使用继承来实现代码重用,会共享更多非必需的代码。子类不应该总是共享其父类的所有特性,然而继承意味着子类得到了其父类全部的数据和行为。这使得程序的设计更不灵活,并产生了无意义的方法调用或子类,以及由于方法并不适用于子类,却必需从父类继承而可能造成的错误。另外,某些语言只允许子类继承一个父类,进一步限制了程序设计的灵活性。

因为这些原因,Rust 选择了一个另外的途径,使用 trait 对象替代继承。让我们看一下在 Rust 中 trait 对象是如何实现多态的。

为使用不同类型的值而设计的 trait 对象

ch17-02-trait-objects.md
commit 67876e3ef5323ce9d394f3ea6b08cb3d173d9ba9

在第八章,我们谈到了 vector 只能存储同种类型元素的局限。在列表 8-1 中有一个例子,其中定义了存放包含整型、浮点型和文本型成员的枚举类型SpreadsheetCell,这样就可以在每一个单元格储存不同类型的数据,并使得 vector 仍然代表一行单元格。当编译时就知道类型集合全部元素的情况下,这种方案是可行的。

有时,我们希望使用的类型的集合对于使用库的程序员来说是可扩展的。例如,很多图形用户接口(GUI)工具有一个条目列表的概念,它通过遍历列表并对每一个条目调用 draw 方法来绘制在屏幕上。我们将要创建一个叫做 rust_gui 的包含一个 GUI 库结构的库 crate。GUI 库可以包含一些供开发者使用的类型,比如 ButtonTextField。使用 rust_gui 的程序员会想要创建更多可以绘制在屏幕上的类型:一个程序员可能会增加一个 Image,而另一个可能会增加一个 SelectBox。我们不会在本章节实现一个功能完善的 GUI 库,不过会展示各个部分是如何结合在一起的。

当写 rust_gui 库时,我们不知道其他程序员需要什么类型,所以无法定义一个 enum 来包含所有的类型。然而 rust_gui 需要跟踪所有这些不同类型的值,需要有在每个值上调用 draw 方法能力。我们的 GUI 库不需要确切地知道调用 draw 方法会发生什么,只需要有可用的方法供我们调用。

在可以继承的语言里,我们会定义一个名为 Component 的类,该类上有一个draw方法。其他的类比如ButtonImageSelectBox会从Component继承并拥有draw方法。它们各自覆写draw方法以自定义行为,但是框架会把所有的类型当作是Component的实例,并在其上调用draw

定义一个带有自定义行为的Trait

不过,在Rust语言中,我们可以定义一个 Draw trait,包含名为 draw 的方法。我们定义一个由trait对象组成的vector,绑定了某种指针的trait,比如&引用或者一个Box<T>智能指针。

之前提到,我们不会称结构体和枚举为对象,以区分其他语言的结构体和枚举对象。结构体或者枚举成员中的数据和impl块中的行为是分开的,而其他语言则是数据和行为被组合到一个对象里。Trait 对象更像其他语言的对象,因为他们将其指针指向的具体对象作为数据,将在 trait 中定义的方法作为行为,组合在了一起。但是,trait 对象和其他语言是不同的,我们不能向一个 trait 对象增加数据。trait 对象不像其他语言那样有用:它们的目的是允许从公有行为上抽象。

trait 对象定义了给定情况下应有的行为。当需要具有某种特性的不确定具体类型时,我们可以把 trait 对象当作 trait 使用。Rust 的类型系统会保证我们为 trait 对象带入的任何值会实现 trait 的方法。我们不需要在编译阶段知道所有可能的类型,却可以把所有的实例统一对待。列表 17-03 展示了如何定义一个名为Draw的带有draw方法的 trait。

文件名: src/lib.rs

pub trait Draw {
    fn draw(&self);
}

列表 17-3:Draw trait 的定义

因为我们已经在第十章讨论过如何定义 trait,你可能比较熟悉。下面是新的定义:列表 17-4 有一个名为 Screen 的结构体,里面有一个名为 components 的 vector,components 的类型是 Box<Draw>Box<Draw> 是一个 trait 对象:它是 Box 内部任意一个实现了 Draw trait 的类型的替身。

文件名: src/lib.rs

# pub trait Draw {
#     fn draw(&self);
# }
#
pub struct Screen {
    pub components: Vec<Box<Draw>>,
}

列表 17-4: 一个 Screen 结构体的定义,它带有一个字段components,其包含实现了 Draw trait 的 trait 对象的 vector

Screen 结构体上,我们将要定义一个 run 方法,该方法会在它的 components 上的每一个元素调用 draw 方法,如列表 17-5 所示:

文件名: src/lib.rs

# pub trait Draw {
#     fn draw(&self);
# }
#
# pub struct Screen {
#     pub components: Vec<Box<Draw>>,
# }
#
impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

列表 17-5:在 Screen 上实现一个 run 方法,该方法在每个 component 上调用 draw 方法

这与带 trait 约束的泛型结构体不同(trait 约束泛型参数)。泛型参数一次只能被一个具体类型替代,而 trait 对象可以在运行时允许多种具体类型填充 trait 对象。比如,我们已经定义了 Screen 结构体使用泛型和一个 trait 约束,如列表 17-6 所示:

文件名: src/lib.rs

# pub trait Draw {
#     fn draw(&self);
# }
#
pub struct Screen<T: Draw> {
    pub components: Vec<T>,
}

impl<T> Screen<T>
    where T: Draw {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

列表 17-6: 一种 Screen 结构体的替代实现,它的 run 方法使用通用类型和 trait 绑定

这个例子中,Screen 实例所有组件类型必需全是 Button,或者全是 TextField。如果你的组件集合是单一类型的,那么可以优先使用泛型和 trait 约束,因为其使用的具体类型在编译阶段即可确定。

Screen 结构体内部的 Vec<Box<Draw>> trait 对象列表,则可以同时包含 Box<Button>Box<TextField>。我们看它是怎么工作的,然后讨论运行时性能。

来自我们或者库使用者的实现

现在,我们增加一些实现了 Draw trait 的类型,再次提供 Button。实现一个 GUI 库实际上超出了本书的范围,因此 draw 方法留空。为了想象实现可能的样子,Button 结构体有 widthheightlabel字段,如列表 17-7 所示:

文件名: src/lib.rs

# pub trait Draw {
#     fn draw(&self);
# }
#
pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // Code to actually draw a button
    }
}

列表 17-7: 实一个现了Draw trait 的 Button 结构体

Button 上的 widthheightlabel 会和其他组件不同,比如 TextField 可能有 widthheight, label 以及 placeholder 字段。每个我们可以在屏幕上绘制的类型都会实现 Draw trait,在 draw 方法中使用不同的代码,定义了如何绘制 Button。除了 Draw trait,Button 也可能有一个 impl 块,包含按钮被点击时的响应方法。这类方法不适用于 TextField 这样的类型。

假定我们的库的用户相要实现一个包含 widthheightoptionsSelectBox 结构体。同时也在 SelectBox 类型上实现了 Draw trait,如 列表 17-8 所示:

文件名: src/main.rs

extern crate rust_gui;
use rust_gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // Code to actually draw a select box
    }
}

列表 17-8: 另外一个 crate 中,在 SelectBox 结构体上使用 rust_gui 和实现了Draw trait

库的用户现在可以在他们的 main 函数中创建一个 Screen 实例,然后把自身放入 Box<T> 变成 trait 对象,向 screen 增加 SelectBoxButton。他们可以在这个 Screen 实例上调用 run 方法,这又会调用每个组件的 draw 方法。 列表 17-9 展示了实现:

文件名: src/main.rs

use rust_gui::{Screen, Button};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No")
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

列表 17-9: 使用 trait 对象来存储实现了相同 trait 的不同类型

虽然我们不知道哪一天会有人增加 SelectBox 类型,但是我们的 Screen 能够操作 SelectBox 并绘制它,因为 SelectBox 实现了 Draw 类型,这意味着它实现了 draw 方法。

只关心值的响应,而不关心其具体类型,这类似于动态类型语言中的 duck typing:如果它像鸭子一样走路,像鸭子一样叫,那么它就是只鸭子!在 Listing 17-5 Screenrun 方法实现中,run 不需要知道每个组件的具体类型。它也不检查组件是 Button 还是 SelectBox 的实例,只管调用组件的 draw 方法。通过指定 Box<Draw> 作为 components 列表中元素的类型,我们约束了 Screen 需要这些实现了 draw 方法的值。

Rust 类型系统使用 trait 对象来支持 duck typing 的好处是,我们无需在运行时检查一个值是否实现了特定方法,或是担心调用了一个值没有实现的方法。如果值没有实现 trait 对象需要的 trait(方法),Rust 不会编译。

比如,列表 17-10 展示了当我们创建一个使用 String 做为其组件的 Screen 时发生的情况:

文件名: src/main.rs

extern crate rust_gui;
use rust_gui::Draw;

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(String::from("Hi")),
        ],
    };

    screen.run();
}

列表 17-10: 尝试使用一种没有实现 trait 对象的类型

我们会遇到这个错误,因为 String 没有实现 Draw trait:

error[E0277]: the trait bound `std::string::String: Draw` is not satisfied
  -->
   |
 4 |             Box::new(String::from("Hi")),
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not
   implemented for `std::string::String`
   |
   = note: required for the cast to the object type `Draw`

这个错误告诉我们,要么传入 Screen 需要的类型,要么在 String 上实现 Draw,以便 Screen 调用它的 draw 方法。

Trait 对象执行动态分发

回忆一下第十章我们讨论过的,当我们在泛型上使用 trait 约束时,编译器按单态类型处理:在需要使用范型参数的地方,编译器为每个具体类型生成非泛型的函数和方法实现。单态类型处理产生的代码实际就是做 static dispatch:方法的代码在编译阶段就已经决定了,当调用时,寻找那段代码非常快速。

当我们使用 trait 对象,编译器不能按单态类型处理,因为无法知道使用代码的所有可能类型。而是调用方法的时候,Rust 跟踪可能被使用的代码,在运行时找出调用该方法时应使用的代码。这也是我们熟知的 dynamic dispatch,查找过程会产生运行时开销。动态分发也会阻止编译器内联函数,失去一些优化途径。尽管获得了额外的灵活性,但仍然需要权衡取舍。

Trait 对象需要对象安全

不是所有的 trait 都可以被放进 trait 对象中; 只有对象安全的object safe)trait 才可以这样做. 一个 trait 只有同时满足如下两点时才被认为是对象安全的:

  • 该 trait 要求 Self 不是 Sized;
  • 该 trait 的所有方法都是对象安全的;

Self 是一个类型的别名关键字,它表示当前正被实现的 trait 类型或者是方法所属的类型. Sized是一个像在第十六章中介绍的SendSync那样的标记 trait, 在编译时它会自动被放进大小确定的类型里,比如i32和引用. 大小不确定的类型有 slice([T])和 trait 对象.

Sized 是一个默认会被绑定到所有常规类型参数的内隐 trait. Rust 中要求一个类型是Sized的最具可用性的用法是让Sized成为一个默认的 trait 绑定,这样我们就可以在大多数的常规的用法中不去写 T: Sized 了. 如果我们想在切片(slice)中使用一个 trait, 我们需要取消对Sized的 trait 绑定, 我们只需制定T: ?Sized作为 trait 绑定.

默认绑定到 Self: ?Sized 的 trait 可以被实现到是 Sized 或非 Sized 的类型上. 如果我们创建一个不绑定 Self: ?Sized 的 trait Foo,它看上去应该像这样:

trait Foo: Sized {
    fn some_method(&self);
}

Trait Sized现在就是 trait Foo的一个超级 traitsupertrait), 也就是说 trait Foo 需要实现了 Foo 的类型(即Self)是Sized. 我们将在第十九章中更详细的介绍超 trait(supertrait).

Foo那样要求SelfSized的 trait 不允许成为 trait 对象的原因是不可能为 trait 对象Foo实现 trait Foo: trait 对象是无确定大小的,但是 Foo 要求 SelfSized. 一个类型不可能同时既是有大小的又是无确定大小的.

第二点说对象安全要求一个 trait 的所有方法必须是对象安全的. 一个对象安全的方法满足下列条件:

  • 它要求 SelfSized 或者
  • 它符合下面全部三点:
    • 它不包含任意类型的常规参数
    • 它的第一个参数必须是类型 Self 或一个引用到 Self 的类型(也就是说它必须是一个方法而非关联函数并且以 self&self&mut self 作为第一个参数)
    • 除了第一个参数外它不能在其它地方用 Self 作为方法的参数签名

虽然这些规则有一点形式化, 但是换个角度想一下: 如果你的方法在它的参数签名的其它地方也需要具体的 Self 类型参数, 但是一个对象又忘记了它的具体类型是什么, 这时该方法就无法使用被它忘记的原先的具体类型. 当该 trait 被使用时, 被具体类型参数填充的常规类型参数也是如此: 这个具体的类型就成了实现该 trait 的类型的某一部分, 如果使用一个 trait 对象时这个类型被抹掉了, 就没有办法知道该用什么类型来填充这个常规类型参数.

一个 trait 的方法不是对象安全的一个例子是标准库中的 Clone trait. Clone trait 的 clone 方法的参数签名是这样的:

pub trait Clone {
    fn clone(&self) -> Self;
}

String 实现了 Clone trait, 当我们在一个 String 实例上调用 clone 方法时, 我们会得到一个 String 实例. 同样地, 如果我们在一个 Vec 实例上调用 clone 方法, 我们会得到一个 Vec 实例. clone 的参数签名需要知道 Self 是什么类型, 因为它需要返回这个类型.

如果我们像列表 17-3 中列出的 Draw trait 那样的 trait 上实现 Clone, 我们就不知道 Self 将会是一个 Button, 一个 SelectBox, 或者是其它的在将来要实现 Draw trait 的类型.

如果你做了违反 trait 对象的对象安全性规则的事情, 编译器将会告诉你. 比如, 如果你实现在列表 17-4 中列出的 Screen 结构, 你想让该结构像这样持有实现了 Clone trait 的类型而不是 Draw trait:

pub struct Screen {
    pub components: Vec<Box<Clone>>,
}

我们将会得到下面的错误:

error[E0038]: the trait `std::clone::Clone` cannot be made into an object
 -->
  |
2 |     pub components: Vec<Box<Clone>>,
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone` cannot be
  made into an object
  |
  = note: the trait cannot require that `Self : Sized`

面向对象设计模式的实现

ch17-03-oo-design-patterns.md
commit 67737ff868e3347588cc832eceb8fc237afc5895

让我们看看一个状态设计模式的例子以及如何在 Rust 中使用他们。状态模式state pattern)是指一个值有某些内部状态,而它的行为随着其内部状态而改变。内部状态由一系列继承了共享功能的对象表现(我们使用结构体和 trait 因为 Rust 没有对象和继承)。每一个状态对象负责它自身的行为和当需要改变为另一个状态时的规则。持有任何一个这种状态对象的值对于不同状态的行为以及何时状态转移毫不知情。当将来需求改变时,无需改变值持有状态或者使用值的代码。我们只需更新某个状态对象中的代码来改变它的规则,或者是增加更多的状态对象。

为了探索这个概念,我们将实现一个增量式的发布博文的工作流。这个我们希望发布博文时所应遵守的工作流,一旦完成了它的实现,将为如下:

  1. 博文从空白的草案开始。
  2. 一旦草案完成,请求审核博文。
  3. 一旦博文过审,它将被发表。
  4. 只有被发表的博文的内容会被打印,这样就不会意外打印出没有被审核的博文的文本。

任何其他对博文的修改尝试都是没有作用的。例如,如果尝试在请求审核之前通过一个草案博文,博文应该保持未发布的状态。

列表 17-11 展示这个工作流的代码形式。这是一个我们将要在一个叫做 blog 的库 crate 中实现的 API 的使用示例:

文件名: src/main.rs

extern crate blog;
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());

    post.request_review();
    assert_eq!("", post.content());

    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

列表 17-11: 展示了 blog crate 期望行为的代码

我们希望能够使用 Post::new 创建一个新的博文草案。接着希望能在草案阶段为博文编写一些文本。如果尝试立即打印出博文的内容,将不会得到任何文本,因为博文仍然是草案。这里增加的 assert_eq! 用于展示目的。断言草案博文的 content 方法返回空字符串将能作为库的一个非常好的单元测试,不过我们并不准备为这个例子编写单元测试。

接下来,我们希望能够请求审核博文,而在等待审核的阶段 content 应该仍然返回空字符串,当博文审核通过,它应该被发表,这意味着当调用 content 时我们编写的文本将被返回。

注意我们与 crate 交互的唯一的类型是 Post。博文可能处于的多种状态(草案,等待审核和发布)由 Post 内部管理。博文状态依我们在Post调用的方法而改变,但不必直接管理状态改变。这也意味着不会在状态上犯错,比如忘记了在发布前请求审核。

定义 Post 并新建一个草案状态的实例

让我们开始实现这个库吧!我们知道需要一个公有 Post 结构体来存放一些文本,所以让我们从结构体的定义和一个创建 Post 实例的公有关联函数 new 开始,如列表 17-12 所示。我们还需定义一个私有 trait StatePost 将在私有字段 state 中存放一个 Option 中的 trait 对象 Box<State>。稍后将会看到为何 Option 是必须的。State trait 定义了所有不同状态的博文所共享的行为,同时 DraftPendingReviewPublished 状态都会实现State 状态。现在这个 trait 并没有任何方法,同时开始将只定义Draft状态因为这是我们希望开始的状态:

文件名: src/lib.rs

pub struct Post {
    state: Option<Box<State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

列表 17-12: Post结构体的定义和新建 Post 实例的 new函数,State trait 和实现了 State 的结构体 Draft

当创建新的 Post 时,我们将其 state 字段设置为一个 Some 值,它存放了指向一个 Draft 结构体新实例的 Box。这确保了无论何时新建一个 Post 实例,它会从草案开始。因为 Poststate 字段是私有的,也就无法创建任何其他状态的 Post 了!。

存放博文内容的文本

Post::new 函数中,我们设置 content 字段为新的空 String。在列表 17-11 中,展示了我们希望能够调用一个叫做 add_text 的方法并向其传递一个 &str 来将文本增加到博文的内容中。选择实现为一个方法而不是将 content 字段暴露为 pub 是因为我们希望能够通过之后实现的一个方法来控制 content 字段如何被读取。add_text 方法是非常直观的,让我们在列表 17-13 的 impl Post 块中增加一个实现:

文件名: src/lib.rs

# pub struct Post {
#     content: String,
# }
#
impl Post {
    // ...snip...
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

列表 17-13: 实现方法 add_text 来向博文的 content 增加文本

add_text 获取一个 self 的可变引用,因为需要改变调用 add_textPost。接着调用 content 中的 Stringpush_str 并传递 text 参数来保存到 content 中。这不是状态模式的一部分,因为它的行为并不依赖博文所处的状态。add_text 方法完全不与 state 状态交互,不过这是我们希望支持的行为的一部分。

博文草案的内容是空的

调用 add_text 并像博文增加一些内容之后,我们仍然希望 content 方法返回一个空字符串 slice,因为博文仍然处于草案状态,如列表 17-11 的第 8 行所示。现在让我们使用能满足要求的最简单的方式来实现 content 方法 总是返回一个空字符 slice。当实现了将博文状态改为发布的能力之后将改变这一做法。但是现在博文只能是草案状态,这意味着其内容总是空的。列表 17-14 展示了这个占位符实现:

文件名: src/lib.rs

# pub struct Post {
#     content: String,
# }
#
impl Post {
    // ...snip...
    pub fn content(&self) -> &str {
        ""
    }
}

列表 17-14: 增加一个 Postcontent 方法的占位实现,它总是返回一个空字符串 slice

通过增加这个 content方法,列表 17-11 中直到第 8 行的代码能如期运行。

请求审核博文来改变其状态

接下来是请求审核博文,这应当将其状态由 Draft 改为 PendingReview。我们希望 post 有一个获取 self 可变引用的公有方法 request_review。接着将调用内部存放的状态的 request_review 方法,而这第二个 request_review 方法会消费当前的状态并返回要一个状态。为了能够消费旧状态,第二个 request_review 方法需要能够获取状态值的所有权。这就是 Option 的作用:我们将 take 字段 state 中的 Some 值并留下一个 None 值,因为 Rust 并不允许结构体中有空字段。接着将博文的 state 设置为这个操作的结果。列表 17-15 展示了这些代码:

文件名: src/lib.rs

# pub struct Post {
#     state: Option<Box<State>>,
#     content: String,
# }
#
impl Post {
    // ...snip...
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<State>;
}

struct Draft {}

impl State for Draft {
    fn request_review(self: Box<Self>) -> Box<State> {
        Box::new(PendingReview {})
    }
}

struct PendingReview {}

impl State for PendingReview {
    fn request_review(self: Box<Self>) -> Box<State> {
        self
    }
}

列表 17-15: 实现 PostState trait 的 request_review 方法

这里给 State trait 增加了 request_review 方法;所有实现了这个 trait 的类型现在都需要实现 request_review 方法。注意不用于使用self&self 或者 &mut self 作为方法的第一个参数,这里使用了 self: Box<Self>。这个语法意味着这个方法调用只对这个类型的 Box 有效。这个语法获取了 Box<Self> 的所有权,这是我们希望的,因为需要从老状态转换为新状态,同时希望老状态不再有效。

Draft 的方法 request_review 的实现返回一个新的,装箱的 PendingReview 结构体的实例,这是新引入的用来代表博文处于等待审核状态的类型。结构体 PendingReview 同样也实现了 request_review 方法,不过它不进行任何状态转换。它返回自身,因为请求审核已经处于 PendingReview 状态的博文应该保持 PendingReview 状态。

现在能够看出状态模式的优势了:Postrequest_review 方法无论 state 是何值都是一样的。每个状态负责它自己的规则。

我们将继续保持 Postcontent 方法不变,返回一个空字符串 slice。现在可以拥有 PendingReview 状态而不仅仅是 Draft 状态的 Post 了,不过我们希望在 PendingReview 状态下其也有相同的行为。现在列表 17-11 中直到 11 行的代码是可以执行的!

批准博文并改变 content 的行为

Postapprove 方法将与 request_review 方法类似:它会将 state 设置为审核通过时应处于的状态。我们需要为 State trait 增加 approve 方法,并需新增实现了 State 的结构体, Published 状态。列表 17-16 展示了新增的代码:

文件名: src/lib.rs

# pub struct Post {
#     state: Option<Box<State>>,
#     content: String,
# }
#
impl Post {
    // ...snip...
    pub fn approve(&mut self) {
        if let Some(s) = self.state.take() {
            self.state = Some(s.approve())
        }
    }
}

trait State {
    fn request_review(self: Box<Self>) -> Box<State>;
    fn approve(self: Box<Self>) -> Box<State>;
}

struct Draft {}

impl State for Draft {
#     fn request_review(self: Box<Self>) -> Box<State> {
#         Box::new(PendingReview {})
#     }
#
    // ...snip...
    fn approve(self: Box<Self>) -> Box<State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
#     fn request_review(self: Box<Self>) -> Box<State> {
#         Box::new(PendingReview {})
#     }
#
    // ...snip...
    fn approve(self: Box<Self>) -> Box<State> {
        Box::new(Published {})
    }
}

struct Published {}

impl State for Published {
    fn request_review(self: Box<Self>) -> Box<State> {
        self
    }

    fn approve(self: Box<Self>) -> Box<State> {
        self
    }
}

列表 17-16: 为 PostState trait 实现 approve 方法

类似于 request_review,如果对 Draft 调用 approve 方法,并没有任何效果,因为它会返回 self。当对 PendingReview 调用 approve 时,它返回一个新的、装箱的 Published 结构体的实例。Published 结构体实现了 State trait,同时对于 request_reviewapprove 方法来说,它返回自身,因为在这两种情况博文应该保持 Published 状态。

现在更新 Postcontent 方法:我们希望当博文处于 Published 时返回 content 字段的值,否则返回空字符串 slice。因为目标是将所有像这样的规则保持在实现了 State 的结构体中,我们将调用 state 中的值的 content 方法并传递博文实例(也就是 self)作为参数。接着返回 state 值的 content 方法的返回值,如列表 17-17 所示:

文件名: src/lib.rs

# trait State {
#     fn content<'a>(&self, post: &'a Post) -> &'a str;
# }
# pub struct Post {
#     state: Option<Box<State>>,
#     content: String,
# }
#
impl Post {
    // ...snip...
    pub fn content(&self) -> &str {
        self.state.as_ref().unwrap().content(&self)
    }
    // ...snip...
}

列表 17-17: 更新 Postcontent 方法来委托调用 Statecontent 方法

这里调用 Optionas_ref方法是因为需要 Option 中值的引用。接着调用 unwrap 方法,这里我们知道永远也不会 panic 因为 Post 的所有方法都确保在他们返回时 state 会有一个 Some 值。这就是一个第十二章讨论过的我们知道 None 是不可能的而编译器却不能理解的情况。

State trait 的 content 方法是博文返回什么内容的逻辑所在之处。我们将增加一个 content 方法的默认实现来返回一个空字符串 slice。这样就无需为 DraftPendingReview 结构体实现 content 了。Published 结构体会覆盖 content 方法并会返回 post.content 的值,如列表 17-18 所示:

文件名: src/lib.rs

# pub struct Post {
#     content: String
# }
trait State {
    // ...snip...
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// ...snip...
struct Published {}

impl State for Published {
    // ...snip...
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

列表 17-18: 为 State trait 增加 content 方法

注意这个方法需要生命周期注解,如第十章所讨论的。这里获取 post 的引用作为参数,并返回 post 一部分的引用,所以返回的引用的生命周期与 post 参数相关。

状态模式的权衡取舍

我们展示了 Rust 是能够实现面向对象的状态模式的,以便能根据博文所处的状态来封装不同类型的行为。Post 的方法并不知道这些不同类型的行为。这种组织代码的方式,为了找到所有已发布的博文不同行为只需查看一处代码:PublishedState trait 的实现。

一个不使用状态模式的替代实现可能会在 Post 的方法中,甚至于在使用 Post 的代码中(在这里是 main 中)用到 match 语句,来检查博文状态并在这里改变其行为。这可能意味着需要查看很多位置来理解处于发布状态的博文的所有逻辑!这在增加更多状态时会变得更糟:每一个 match 语句都会需要另一个分支。对于状态模式来说,Post 的方法和使用 Post 的位置无需match 语句,同时增加新状态只涉及到增加一个新 struct 和为其实现 trait 的方法。

这个实现易于增加更多功能。这里是一些你可以尝试对本部分代码做出的修改,来亲自体会一下使用状态模式随着时间的推移维护代码是什么感觉:

  • 只允许博文处于 Draft 状态时增加文本内容
  • 增加 reject 方法将博文的状态从 PendingReview 变回 Draft
  • 在将状态变为 Published 之前需要两次 approve 调用

状态模式的一个缺点是因为状态实现了状态之间的转换,一些状态会相互联系。如果在 PendingReviewPublished 之间增加另一个状态,比如 Scheduled,则不得不修改 PendingReview 中的代码来转移到 Scheduled。如果 PendingReview 无需因为新增的状态而改变就更好了,不过这意味着切换到另一个设计模式。

这个 Rust 中的实现的缺点在于存在一些重复的逻辑。如果能够为 State trait 中返回 selfrequest_reviewapprove 方法增加默认实现就好了,不过这会违反对象安全性,因为 trait 不知道 self 具体是什么。我们希望能够将 State 作为一个 trait 对象,所以需要这个方法是对象安全的。

另一个最好能去除的重复是 Postrequest_reviewapprove 这两个类似的实现。他们都委托调用了 state 字段中 Option 值的同一方法,并在结果中为 state 字段设置了新值。如果 Post 中的很多方法都遵循这个模式,我们可能会考虑定义一个宏来消除重复(查看附录 E 以了解宏)。

这个完全按照面向对象语言的定义实现的面向对象模式的缺点在于没有尽可能的利用 Rust 的优势。让我们看看一些代码中可以做出的修改,来将无效的状态和状态转移变为编译时错误。

将状态和行为编码为类型

我们将展示如何稍微反思状态模式来进行一系列不同的权衡取舍。不同于完全封装状态和状态转移使得外部代码对其毫不知情,我们将将状态编码进不同的类型。当状态是类型时,Rust 的类型检查就会使任何在只能使用发布的博文的地方使用草案博文的尝试变为编译时错误。

让我们考虑一下列表 17-11 中 main 的第一部分:

文件名: src/main.rs

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());
}

我们仍然希望使用 Post::new 创建一个新的草案博文,并仍然希望能够增加博文的内容。不过不同于存在一个草案博文时返回空字符串的 content 方法,我们将使草案博文完全没有 content 方法。这样如果尝试获取草案博文的内容,将会得到一个方法不存在的编译错误。这使得我们不可能在生产环境意外显示出草案博文的内容,因为这样的代码甚至就不能编译。列表 17-19 展示了 Post 结构体、DraftPost 结构体以及各自的方法的定义:

文件名: src/lib.rs

pub struct Post {
    content: String,
}

pub struct DraftPost {
    content: String,
}

impl Post {
    pub fn new() -> DraftPost {
        DraftPost {
            content: String::new(),
        }
    }

    pub fn content(&self) -> &str {
       &self.content
    }
}

impl DraftPost {
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

列表 17-19: 带有 content 方法的 Post 和没有 content 方法的 DraftPost

PostDraftPost 结构体都有一个私有的 content 字段来储存博文的文本。这些结构体不再有 state 字段因为我们将类型编码为结构体的类型。Post 将代表发布的博文,它有一个返回 contentcontent 方法。

仍然有一个 Post::new 函数,不过不同于返回 Post 实例,它返回 DraftPost 的实例。现在不可能创建一个 Post 实例,因为 content 是私有的同时没有任何函数返回 PostDraftPost 上定义了一个 add_text 方法,这样就可以像之前那样向 content 增加文本,不过注意 DraftPost 并没有定义 content 方法!所以所有博文都强制从草案开始,同时草案博文没有任何可供展示的内容。任何绕过这些限制的尝试都会产生编译错误。

实现状态转移为不同类型的转移

那么如何得到发布的博文呢?我们希望强制的规则是草案博文在可以发布之前必须被审核通过。等待审核状态的博文应该仍然不会显示任何内容。让我们通过增加另一个结构体 PendingReviewPost 来实现这个限制,在 DraftPost 上定义 request_review 方法来返回 PendingReviewPost,并在 PendingReviewPost 上定义 approve 方法来返回 Post,如列表 17-20 所示:

文件名: src/lib.rs

# pub struct Post {
#     content: String,
# }
#
# pub struct DraftPost {
#     content: String,
# }
#
impl DraftPost {
    // ...snip...

    pub fn request_review(self) -> PendingReviewPost {
        PendingReviewPost {
            content: self.content,
        }
    }
}

pub struct PendingReviewPost {
    content: String,
}

impl PendingReviewPost {
    pub fn approve(self) -> Post {
        Post {
            content: self.content,
        }
    }
}

列表 17-20: PendingReviewPost 通过调用 DraftPostrequest_review 创建,approve 方法将 PendingReviewPost 变为发布的 Post

request_reviewapprove 方法获取 self 的所有权,因此会消费 DraftPostPendingReviewPost 实例,并分别转换为 PendingReviewPost 和 发布的 Post。这样在调用 request_review 之后就不会遗留任何 DraftPost 实例,后者同理。PendingReviewPost 并没有定义 content 方法,所以类似 DraftPost 尝试读取它的内容是一个编译错误。因为唯一得到定义了 content 方法的 Post 实例的途径是调用 PendingReviewPostapprove 方法,而得到 PendingReviewPost 的唯一办法是调用 DraftPostrequest_review 方法,现在我们就将发博文的工作流编码进了类型系统。

这也意味着不得不对 main做出一些小的修改。因为 request_reviewapprove 返回新实例而不是修改被调用的结构体,我们需要增加更多的 let post = 覆盖赋值来保存返回的实例。也不能再断言草案和等待审核的博文的内容为空字符串了,我们也不再需要他们:不能编译尝试使用这些状态下博文内容的代码。更新后的 main 的代码如列表 18-21 所示:

Filename: src/main.rs

extern crate blog;
use blog::Post;

fn main() {
    let mut post = Post::new();

    post.add_text("I ate a salad for lunch today");

    let post = post.request_review();

    let post = post.approve();

    assert_eq!("I ate a salad for lunch today", post.content());
}

列表 17-21: main 中使用新的博文工作流实现的修改

不得不修改 main 来重新赋值 post 使得这个实现不再完全遵守面向对象的状态模式:状态间的转换不再完全封装在 Post 实现中。然而,得益于类型系统和编译时类型检查我们得到了不可能拥有无效状态的属性!这确保了特定的 bug,比如显示未发布博文的内容,将在部署到生产环境之前被发现。

尝试在这一部分开始所建议的增加额外需求的任务来体会使用这个版本的代码是何感觉。

即便 Rust 能够实现面向对象设计模式,也有其他像将状态编码进类型这样的模式存在。这些模式有着不同于面向对象模式的权衡取舍。虽然你可能非常熟悉面向对象模式,重新思考这些问题来利用 Rust 提供的像在编译时避免一些 bug 这样有益功能。在 Rust 中面向对象模式并不总是最好的解决方案,因为 Rust 拥有像所有权这样的面向对象语言所没有的功能。

总结

阅读本章后,不管你是否认为 Rust 是一个面向对象语言,现在你都见识了 trait 对象是一个 Rust 中获取部分面向对象功能的方法。动态分发可以通过牺牲一些运行时性能来为你的代码提供一些灵活性。这些灵活性可以用来实现有助于代码可维护性的面向对象模式。Rust 也有像所有权这样不同于面向对象语言的功能。面向对象模式并不总是利用 Rust 实力的最好方式。

接下来,让我们看看另一个提供了很多灵活性的 Rust 功能:模式。贯穿本书我们都曾简单的见过他们,但并没有见识过他们的全部本领。让我们开始吧!

模式用来匹配值的结构

ch18-00-patterns.md
commit 3d47ebddad51b0080a19857e1495675a8e9376ef

模式是 Rust 中特殊的语法,它用来匹配类型中的结构,无论类型是简单还是复杂。模式由一些常量组成;解构数组、枚举、结构体或者是元组;变量、通配符和占位符。这些部分描述了我们要处理的数据的“形状”。

我们通过将一些值与模式相比较来使用它。如果模式匹配这些值,我们对值部分进行相应处理。回忆一下第六章讨论 match 表达式时像硬币分类器那样使用模式。我们可以为形状中的片段命名,就像在第六章中命名出现在二十五美分硬币上的州那样,如果数据符合这个形状,就可以使用这些命名的片段。

本章是所有模式相关内容的参考。我们将涉及到使用模式的有效位置,refutableirrefutable 模式的区别,和你可能会见到的不同类型的模式语法。

所有可能会用到模式的位置

ch18-01-all-the-places-for-patterns.md
commit 4ca9e513e532a4d229ab5af7dfcc567129623bf4

模式出现在 Rust 的很多地方。你已经在不经意间使用了很多模式!本部分是一个所有有效模式位置的参考。

match 分支

如第六章所讨论的,一个模式常用的位置是 match 表达式的分支。在形式上 match 表达式由 match 关键字、用于匹配的值和一个或多个分支构成。这些分支包含一个模式和在值匹配分支的模式时运行的表达式:

match VALUE {
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
    PATTERN => EXPRESSION,
}

穷尽性和默认模式 _

match 表达式必须是穷尽的。当我们把所有分支的模式都放在一起,match 表达式所有可能的值都应该被考虑到。一个确保覆盖每个可能值的方法是在最后一个分支使用捕获所有的模式,比如一个变量名。一个匹配任何值的名称永远也不会失败,因此可以覆盖之前分支模式匹配剩下的情况。

这有一个额外的模式经常被用于结尾的分支:_。它匹配所有情况,不过它从不绑定任何变量。这在例如只希望在某些模式下运行代码而忽略其他值的时候很有用。

if let 表达式

第六章讨论过了 if let 表达式,以及它是如何成为编写等同于只关心一个情况的 match 语句的简写的。if let 可以对应一个可选的 else 和代码在 if let 中的模式不匹配时运行。

列表 18-1 展示了甚至可以组合并匹配 if letelse ifelse if let。这些代码展示了一系列针对不同条件的检查来决定背景颜色应该是什么。为了达到这个例子的目的,我们创建了硬编码值的变量,在真实程序中则可能由询问用户获得。如果用户指定了中意的颜色,我们将使用它作为背景颜色。如果今天是星期二,背景颜色将是绿色。如果用户指定了他们的年龄字符串并能够成功将其解析为数字的话,我们将根据这个数字使用紫色或者橙色。最后,如果没有一个条件符合,背景颜色将是蓝色:

文件名: src/main.rs

fn main() {
    let favorite_color: Option<&str> = None;
    let is_tuesday = false;
    let age: Result<u8, _> = "34".parse();

    if let Some(color) = favorite_color {
        println!("Using your favorite color, {}, as the background", color);
    } else if is_tuesday {
        println!("Tuesday is green day!");
    } else if let Ok(age) = age {
        if age > 30 {
            println!("Using purple as the background color");
        } else {
            println!("Using orange as the background color");
        }
    } else {
        println!("Using blue as the background color");
    }
}

列表 18-1: 结合 if letelse ifelse if letelse

这个条件结构允许我们支持复杂的需求。使用这里硬编码的值,例子会打印出 Using purple as the background color

注意 if let 也可以像 match 分支那样引入覆盖变量:if let Ok(age) = age 引入了一个新的覆盖变量 age,它包含 Ok 成员中的值。这也意味着 if age > 30 条件需要位于这个代码块内部;不能将两个条件组合为 if let Ok(age) = age && age > 30,因为我们希望与 30 进行比较的被覆盖的 age 直到大括号开始的新作用域才是有效的。

另外注意这样有很多情况的条件并没有 match 表达式强大,因为其穷尽性没有为编译器所检查。如果去掉最后的 else 块而遗漏处理一些情况,编译器也不会报错。这个例子可能过于复杂以致难以重写为一个可读的 match,所以需要额外注意处理了所有的情况,因为编译器不会为我们检查穷尽性。

while let

一个与 if let 类似的结构体是 while let:它允许只要模式匹配就一直进行 while 循环。列表 18-2 展示了一个使用 while let 的例子,它使用 vector 作为栈并打以先进后出的方式打印出 vector 中的值:

let mut stack = Vec::new();

stack.push(1);
stack.push(2);
stack.push(3);

while let Some(top) = stack.pop() {
    println!("{}", top);
}

列表 18-2: 使用 while let 循环只要 stack.pop() 返回 Some就打印出其值

这个例子会打印出 3、2 和 1。pop 方法取出 vector 的最后一个元素并返回Some(value),如果 vector 是空的,它返回 Nonewhile 循环只要 pop 返回 Some 就会一直运行其块中的代码。一旦其返回 Nonewhile循环停止。我们可以使用 while let 来弹出栈中的每一个元素。

for 循环

for 循环,如同第三章所讲的,是 Rust 中最常见的循环结构。那一章所没有讲到的是 for 可以获取一个模式。列表 18-3 中展示了如何使用 for 循环来解构一个元组。enumerate 方法适配一个迭代器来产生元组,其包含值和值的索引:

let v = vec![1, 2, 3];

for (index, value) in v.iter().enumerate() {
    println!("{} is at index {}", value, index);
}

列表 18-3: 在 for 循环中使用模式来解构 enumerate 返回的元组

这会打印出:

1 is at index 0
2 is at index 1
3 is at index 2

第一个 enumerate 调用会产生元组 (0, 1)。当这个匹配模式 (index, value)index 将会是 0 而 value 将会是 1。

let 语句

matchif let 都是本书之前明确讨论过的使用模式的位置,不过他们不是仅有的使用过模式的地方。例如,考虑一下这个直白的 let 变量赋值:

let x = 5;

本书进行了不下百次这样的操作。你可能没有发觉,不过你这正是在使用模式!let 语句更为正式的样子如下:

let PATTERN = EXPRESSION;

我们见过的像 let x = 5; 这样的语句中变量名位于 PATTERN 位置;变量名不过是形式特别朴素的模式。

通过 let,我们将表达式与模式比较,并为任何找到的名称赋值。所以例如 let x = 5; 的情况,x 是一个模式代表“将匹配到的值绑定到变量 x”。同时因为名称 x 是整个模式,这个模式实际上等于“将任何值绑定到变量 x,不过它是什么”。

为了更清楚的理解 let 的模式匹配的方面,考虑列表 18-4 中使用 let 和模式解构一个元组:

let (x, y, z) = (1, 2, 3);

列表 18-4: 使用模式解构元组并一次创建三个变量

这里有一个元组与模式匹配。Rust 会比较值 (1, 2, 3) 与模式 (x, y, z) 并发现值匹配这个模式。在这个例子中,将会把 1 绑定到 x2 绑定到 y3 绑定到 z。你可以将这个元组模式看作是将三个独立的变量模式结合在一起。

在第十六章中我们见过另一个解构元组的例子,列表 16-6 中,那里解构 mpsc::channel() 的返回值为 tx(发送者)和 rx(接收者)。

函数参数

类似于 let,函数参数也可以是模式。列表 18-5 中的代码声明了一个叫做 foo 的函数,它获取一个 i32 类型的参数 x,这看起来应该很熟悉:

fn foo(x: i32) {
    // code goes here
}

列表 18-5: 在参数中使用模式的函数签名

x 部分就是一个模式!类似于之前对 let 所做的,可以在函数参数中匹配元组。列表 18-6 展示了如何可以将传递给函数的元组拆分为值:

文件名: src/main.rs

fn print_coordinates(&(x, y): &(i32, i32)) {
    println!("Current location: ({}, {})", x, y);
}

fn main() {
    let point = (3, 5);
    print_coordinates(&point);
}

列表 18-6: 一个在参数中解构元组的函数

这会打印出 Current location: (3, 5)。当传递值 &(3, 5)print_coordinates 时,这个值会匹配模式 &(x, y)x 得到了值 3,而 y得到了值 5。

因为如第十三章所讲闭包类似于函数,也可以在闭包参数中使用模式。

在这些可以使用模式的位置中的一个区别是,对于 for 循环、let 和函数参数,其模式必须是 irrefutable 的。接下来让我们讨论这个。