写这样一本书并不容易:Rust 这门语言如此卓尔不群,我们固然有能力在一开始就展示出其独特的、令人惊叹的特性,但更为重要的是它的各个部分之间能够良好协作,共同服务于我们在第 1 章中设定的目标——安全、高性能的系统编程。该语言的每个部分都与其他部分配合得天衣无缝。

因此,我们并不打算每次讲透一个语言特性,而是准备了一些小而完 备的程序作为导览,每个程序都会在其上下文中介绍该语言的更多特 性。

作为暖场,我们会设计一个简单的程序,它可以解析命令行参数 并进行简单计算,而且带有单元测试。这会展示 Rust 的一些核 心类型并引入特型的概念。 接下来,我们一起构建一个 Web 服务器。我们将使用第三方库来 处理 HTTP 的细节,并介绍字符串处理、闭包和错误处理功能。 第三个程序会绘制一张美丽的分形图,将计算工作分派到多个线 程以提高效率。这包括一个泛型函数的示例,以说明该如何处理 像素缓冲区之类的问题,并展示 Rust 对并发的支持。 最后,我们会展示一个强大的命令行工具,它利用正则表达式来 处理文件。这展示了 Rust 标准库的文件处理功能,以及最常用 的第三方正则表达式库。 Rust 承诺会在对性能影响最小的情况下防止未定义行为,这在潜移默 化中引导着每个部分的设计——从标准数据结构(如向量和字符串) 到使用第三方库的方式。关于如何做好这些的细节会贯穿全书。但就 目前而言,我们只想向你证明 Rust 是一门功能强大且易于使用的语 言。 当然,你要先在计算机上安装 Rust。

2.1 rustup 与 Cargo

安装 Rust 的最佳方式是使用 rustup。请转到 rustup.rs 网站并 按照那里的说明进行操作。 还可以到 Rust 网站获取针对 Linux、macOS 和 Windows 的预构建 包。Rust 也已经包含在某些操作系统的发行版中。建议使用 rustup,因为它是专门管理 Rust 安装的工具,就像 Ruby 中的 RVM 或 Node 中的 NVM。例如,当 Rust 发布新版本时,你就可以通 过键入 rustup update 来实现一键升级。 无论采用哪种方式,完成安装之后,你的命令行中都会有 3 条新命 令:

$ cargo --version

cargo 1.49.0 (d00d64df9 2020-12-05)

$ rustc --version

rustc 1.49.0 (e1884a8e3 2020-12-29)

$ rustdoc --version

rustdoc 1.49.0 (e1884a8e3 2020-12-29)

在这里,$ 是命令提示符,在 Windows 上,则会是 C:> 之类的文 本。在刚才的记录中,我们运行了 3 条已安装的命令,并要求每条命 令报告其版本号。下面来逐个看看每条命令。 cargo 是 Rust 的编译管理器、包管理器和通用工具。可以用 Cargo 启动新项目、构建和运行程序,并管理代码所依赖的任何 外部库。 rustc 是 Rust 编译器。通常 Cargo 会替我们调用此编译器, 但有时也需要直接运行它。 rustdoc 是 Rust 文档工具。如果你在程序源代码中以适当形 式的注释编写文档,那么 rustdoc 就可以从中构建出格式良好 的 HTML。与 rustc 一样,通常 Cargo 会替我们运行 rustdoc。 为便于使用,Cargo 可以为我们创建一个新的 Rust 包,并适当准备 一些标准化的元数据:

$ cargo new hello
Created binary (application) `hello` package

该命令会创建一个名为 hello 的新包目录,用于构建命令行可执行文 件。 查看包的顶层目录:

$ cd hello
$ ls -la
total 24
drwxrwxr-x. 4 jimb jimb 4096 Sep 22 21:09 .
drwx------. 62 jimb jimb 4096 Sep 22 21:09 ..
drwxrwxr-x. 6 jimb jimb 4096 Sep 22 21:09 .git
-rw-rw-r--. 1 jimb jimb 7 Sep 22 21:09 .gitignore
-rw-rw-r--. 1 jimb jimb 88 Sep 22 21:09 Cargo.toml
drwxrwxr-x. 2 jimb jimb 4096 Sep 22 21:09 src

我们看到 Cargo 已经创建了一个名为 Cargo.toml 的文件来保存此包 的元数据。目前这个文件还没有多少内容:

cat Cargo.toml
[package]
name = "hello"
version = "0.1.0"
edition = "2021"
  • 请到“The Cargo Book”查看更多的键及其定义

[dependencies]

如果程序依赖于其他库,那么可以把它们记录在这个文件中,Cargo 将为我们下载、构建和更新这些库。第 8 章会详细介绍 Cargo.toml 文件。

Cargo 已将我们的包设置为与版本控制系统 git 一起使用,并为此 创建了一个元数据子目录 .git 和一个 .gitignore 文件。可以通过 在命令行中将 –vcs none 传给 cargo new 来要求 Cargo 跳过 此步骤。

src 子目录包含实际的 Rust 代码:

$ cd src
$ ls -l
$ cat main.rs
total 4
-rw-rw-r--. 1 jimb jimb 45 Sep 22 21:09 main.rs

Cargo 似乎已经替我们写好一部分程序了。main.rs 文件包含以下文 本:

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

rustc直接编译运行

$ rustc main.rs && ./main

在 Rust 中,你甚至不需要编写自己的“Hello, World!”程序。这是 Rust 新程序样板的职责,该程序样板包括两个文件,总共 13 行代 码。

可以在包内的任意目录下调用 cargo run 命令来构建和运行程序:

$ cargo run
Compiling hello v0.1.0 (/home/jimb/rust/hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.28s
Running `/home/jimb/rust/hello/target/debug/hello`
Hello, world!

这里 Cargo 先调用 Rust 编译器 rustc,然后运行了它生成的可执 行文件。Cargo 将可执行文件放在此包顶层的 target 子目录中:

$ ls -l ../target/debug
total 580
drwxrwxr-x. 2 jimb jimb 4096 Sep 22 21:37 build
drwxrwxr-x. 2 jimb jimb 4096 Sep 22 21:37 deps
drwxrwxr-x. 2 jimb jimb 4096 Sep 22 21:37 examples
-rwxrwxr-x. 1 jimb jimb 576632 Sep 22 21:37 hello
-rw-rw-r--. 1 jimb jimb 198 Sep 22 21:37 hello.d
drwxrwxr-x. 2 jimb jimb 68 Sep 22 21:37 incremental
$ ../target/debug/hello

Hello, world! 完工之后,Cargo 还可以帮我们清理生成的文件。

$ cargo clean
$ ../target/debug/hello
bash: ../target/debug/hello: No such file or directory

2.2 Rust 函数

Rust 在语法设计上刻意减少了原创性。如果你熟悉 C、C++、Java 或 JavaScript,那么就能通过 Rust 程序的一般性构造找到自己的快速 学习之道。这是一个使用欧几里得算法计算两个整数的最大公约数的 函数。可以将这些代码添加到 src/main.rs 的末尾:

fn gcd(mut n: u64, mut m: u64) -> u64 {
    assert!(n != 0 && m != 0);
    while m != 0 {
        if m < n {
            let t = m;
            m = n;
            n = t;
        }
        m = m % n;
    }
    n
}

fn(发音为 /fʌn/)关键字引入了一个函数。这里我们定义了一个名 为 gcd 的函数,它有两个参数(n 和 m),每个参数都是 u64 类 型,即一个无符号的 64 位整数。-> 标记后面紧跟着返回类型,表 示此函数返回一个 u64 值。4 空格缩进是 Rust 的标准风格。 Rust 的“机器整数类型名”揭示了它们的大小和符号:i32 是一个 带符号的 32 位整数,u8 是一个无符号的 8 位整数(“字节” 值),以此类推。isize 类型和 usize 类型保存着恰好等于“指针 大小”的有符号整数和无符号整数,在 32 位平台上是 32 位长,在 64 位平台上则是 64 位长。Rust 还有 f32 和 f64 这两种浮点类 型,它们分别是 IEEE 单精度浮点类型和 IEEE 双精度浮点类型,就 像 C 和 C++ 中的 float 和 double。

默认情况下,一经初始化,变量的值就不能再改变了,但是在参数 n 和 m 之前放置 mut(发音为 /mjuːt/,是 mutable 的缩写)关键字 将会准许我们在函数体中赋值给它们。实际上,大多数变量是不需要 被赋值的,而对于那些确实需要被赋值的变量,mut 关键字相当于用 一个醒目的提示来帮我们阅读代码。

函数的主体始于一次 assert! 宏调用,以验证这两个参数都不为 0。这里的 ! 字符标明此句为宏调用,而不是函数调用。就像 C 和 C++ 中的 assert 宏一样,Rust 的 assert! 会检查其参数是否为 真,如果非真,则终止本程序并提供一条有帮助的信息,其中包括导 致本次检查失败的源代码位置。这种突然的终止在 Rust 中称为 panic。与可以跳过断言的 C 和 C++ 不同,Rust 总是会检查这些断 言,而不管程序是如何编译的。还有一个 debug_assert! 宏,在 编译发布版程序时会跳过其断言以提高速度。

这个函数的核心是一个包含 if 语句和赋值语句的 while 循环。与 C 和 C++ 不同,Rust 不需要在条件表达式周围使用圆括号,但必须 在受其控制的语句周围使用花括号。

let 语句会声明一个局部变量,比如本函数中的 t。只要 Rust 能从 变量的使用方式中推断出 t 的类型,就不需要标注其类型。在此函 数中,通过匹配 m 和 n,可以推断出唯一适用于 t 的类型是 u64。Rust 只会推断函数体内部的类型,因此必须像之前那样写出函 数参数的类型和返回值的类型。如果想明确写出 t 的类型,那么可 以这样写:

let t: u64 = m;

Rust 有 return 语句,但这里的 gcd 函数并不需要。如果一个函 数体以没有尾随着分号的表达式结尾,那么这个表达式就是函数的返 回值。事实上,花括号包起来的任意代码块都可以用作表达式。例 如,下面是一个打印了一条信息然后以 x.cos() 作为其值的表达 式:

{
    println!("evaluating cos x");
    x.cos()
}

在 Rust 中,当控制流“正常离开函数的末尾”时,通常会以上述形 式创建函数的返回值,return 语句只会用在从函数中间显式地提前 返回的场景中。

2.3 编写与运行单元测试

Rust 语言内置了对测试的简单支持。为了测试 gcd 函数,可以在 src/main.rs 的末尾添加下面这段代码:

#[test]
fn test_gcd() {
    assert_eq!(gcd(14, 15), 1);
    assert_eq!(gcd(2 * 3 * 5 * 11 * 17,
                   3 * 7 * 11 * 13 * 19),
               3 * 11);
}

这里我们定义了一个名为 test_gcd 的函数,该函数会调用 gcd 并检查它是否返回了正确的值。此定义顶部的 #[test] 将 test_gcd 标记为“测试函数”,在正常编译时会跳过它,但如果用 cargo test 命令运行我们的程序,则会自动包含并调用它。可以让 测试函数分散在源代码树中,紧挨着它们所测试的代码,cargo test 会自动收集并运行它们。

#[test] 标记是属性(attribute)的示例之一。属性是一个开放式 体系,可以用附加信息给函数和其他声明做标记,就像 C++ 和 C# 中 的属性或 Java 中的注解(annotation)一样。属性可用于控制编译 器警告和代码风格检查、有条件地包含代码(就像 C 和 C++ 中的 #ifdef 一样)、告诉 Rust 如何与其他语言编写的代码互动,等 等。后面还会介绍更多的属性示例。

将 gcd 和 test_gcd 的定义添加到本章开头创建的 hello 包中, 如果当前目录位于此包子树中的任意位置,可以用如下方式运行测 试。

$ cargo test
Compiling hello v0.1.0 (/home/jimb/rust/hello)
Finished test [unoptimized + debuginfo] target(s) in 0.35s
Running unittests
(/home/jimb/rust/hello/target/debug/deps/hello-2375...)
running 1 test
test test_gcd ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0
filtered out

2.4 处理命令行参数

为了让我们的程序接收一系列数值作为命令行参数并打印出它们的最 大公约数,可以将 src/main.rs 中的 main 函数替换为以下内容:

use std::env;
use std::str::FromStr;
fn main() {
    let mut numbers = Vec::new();
    for arg in env::args().skip(1) {
        numbers.push(u64::from_str(&arg).expect("error parsing argument"));
    }
    if numbers.len() == 0 {
        eprintln!("Usage: gcd NUMBER ...");
        std::process::exit(1);
    }
    let mut d = numbers[0];
    for m in &numbers[1..] {
        d = gcd(d, *m);
    }
    println!("The greatest common divisor of {:?} is {}", numbers, d);
}

我们来逐段分析一下:

use std::str::FromStr;

use std::env;

第一个 use 声明将标准库中的 FromStr 特型引入了当前作用域。 特型是可以由类型实现的方法集合。任何实现了 FromStr 特型的类 型都有一个 from_str 方法,该方法会尝试从字符串中解析这个类 型的值。u64 类型实现了 FromStr,所以我们将调用 u64::from_str 来解析程序中的命令行参数。尽管我们从未在程序 的其他地方用到 FromStr 这个名字,但仍然要 use(使用)它,因 为要想使用某个特型的方法,该特型就必须在作用域内。第 11 章会 详细介绍特型。

第二个 use 声明引入了 std::env 模块,该模块提供了与执行环 境交互时会用到的几个函数和类型,包括 args 函数,该函数能让我 们访问程序中的命令行参数。

继续看程序中的 main 函数:

fn main() {

main 函数没有返回值,所以可以简单地省略 -> 和通常会跟在参数 表后面的返回类型。

let mut numbers = Vec::new();

我们声明了一个可变的局部变量 numbers 并将其初始化为空向量。 Vec 是 Rust 的可增长向量类型,类似于 C++ 的 std::vector、 Python 的列表或 JavaScript 的数组。虽然从设计上说向量可以动态 扩充或收缩,但仍然要标记为 mut,这样 Rust 才能把新值压入末 尾。

numbers 的类型是 Vec,这是一个可以容纳 u64 类型的值 的向量,但和以前一样,不需要把类型写出来。Rust 会推断它,一部 分原因是我们将 u64 类型的值压入了此向量,另一部分原因是我们 将此向量的元素传给了 gcd,后者只接受 u64 类型的值。

for arg in env::args().skip(1) {

这里使用了 for 循环来处理命令行参数,依次将变量 arg 指向每 个参数并运行循环体。

std::env 模块的 args 函数会返回一个迭代器,此迭代器会按需 生成每个参数,并在完成时给出提示。各种迭代器在 Rust 中无处不 在,标准库中也包括一些迭代器,这些迭代器可以生成向量的元素、 文件每一行的内容、通信信道上接收到的信息,以及几乎任何有意义 的循环变量。Rust 的迭代器非常高效,编译器通常能将它们翻译成与 手写循环相同的代码。第 15 章会展示迭代器的工作原理并给出相关 示例。

“生成”只是沿袭普遍译法,事实上,这里并不会创建任何新条目,只是把已有条目提供 给消费者。——译者注

除了与 for 循环一起使用,迭代器还包含大量可以直接使用的方 法。例如,args 返回的迭代器生成的第一个值永远是正在运行的程 序的名称。如果想跳过它,就要调用迭代器的 skip 方法来生成一个 新的迭代器,新迭代器会略去第一个值。

numbers.push(u64::from_str(&arg).expect("error parsing argument"));

这里我们调用了 u64::from_str 来试图将命令行参数 arg 解析 为一个无符号的 64 位整数。u64::from_str 并不是 u64 值上的 某个方法,而是与 u64 类型相关联的函数,类似于 C++ 或 Java 中 的静态方法。from_str 函数不会直接返回 u64,而是返回一个指 明本次解析已成功或失败的 Result 值。Result 值是以下两种变 体之一: - 形如 Ok(v) 的值,表示解析成功了,v 是所生成的值; - 形如 Err(e) 的值,表示解析失败了,e 是解释原因的错误 值。 执行任何可能会失败的操作(例如执行输入或输出或者以其他方式与 操作系统交互)的函数都会返回一个 Result 类型,其 Ok 变体会 携带成功结果(传输的字节数、打开的文件等),而其 Err 变体会 携带错误码,以指明出了什么问题。与大多数现代语言不同,Rust 没 有异常(exception):所有错误都使用 Result 或 panic 进行处 理,详见第 7 章。

我们用 Result 的 expect 方法来检查本次解析是否成功。如果结 果是 Err(e),那么 expect 就会打印出一条包含 e 的消息并直接 退出程序。但如果结果是 Ok(v),则 expect 会简单地返回 v 本 身,最终我们会将其压入这个数值向量的末尾。

    if numbers.len() == 0 {
        eprintln!("Usage: gcd NUMBER ...");
        std::process::exit(1);
    }

空数组没有最大公约数,因此要检查此向量是否至少包含一个元素, 如果没有则退出程序并报错。这里我们用 eprintln! 宏将错误消息 写入标准错误流

    let mut d = numbers[0];
    for m in &numbers[1..] {
        d = gcd(d, *m);
    }

该循环使用 d 作为其运行期间的值,不断地把它更新为已处理的所 有数值的最大公约数。和以前一样,必须将 d 标记为可变,以便在 循环中给它赋值。

这个 for 循环有两个值得注意的地方。首先,我们写了 for m in &numbers[1..],那么这里的 & 运算符有什么用呢?其次,我们写 了 gcd(d, *m),那么 *m 中的 * 又有什么用呢?这两个细节是紧 密相关的。

迄今为止,我们的代码只是在对简单的值(例如适合固定大小内存块 的整数)进行操作。但现在我们要迭代一个向量,它可以是任意大 小,而且可能会非常大。Rust 在处理这类值时非常慎重:它想让程序 员控制内存消耗,明确每个值的生存时间,同时还要确保当不再需要 这些值时能及时释放内存。

所以在进行迭代时,需要告诉 Rust,该向量的所有权应该留在 numbers 上,我们只是为了本次循环而借用它的元素。

&numbers[1..] 中的 & 运算符会从向量中借用从第二个元素开始 的引用。for 循环会遍历这些被引用的元素,让 m 依次借出每个元 素。*m 中的 * 运算符会将 m 解引用,产生它所引用的值,这就是要 传给 gcd 的下一个 u64。最后,由于 numbers 拥有着此向量,因 此当 main 末尾的 numbers 超出作用域时,Rust 会自动释放它。 Rust 的所有权规则和引用规则是 Rust 内存管理和并发安全的关键所 在,第 4 章和第 5 章会对此进行详细讨论。只有熟悉了这些规则, 才算熟练掌握了 Rust。但是对于这个介绍性的导览,你只需要知道 &x 借用了对 x 的引用,而 *r 访问的是 r 所引用的值就足够了。

继续我们的程序:

println!("The greatest common divisor of {:?} is {}",numbers, d);

遍历 numbers 的元素后,程序会将结果打印到标准输出流。

println! 宏会接受一个模板字符串,在模板字符串中以 {…} 形 式标出的位置按要求格式化并插入剩余的参数,最后将结果写入标准 输出流。 C 和 C++ 要求 main 在程序成功完成时返回 0,在出现问题时返回 非零的退出状态,而 Rust 假设只要 main 完全返回,程序就算成功 完成。只有显式地调用像 expectstd::process::exit 这样 的函数,才能让程序以表示错误的状态码终止。 cargo run 命令可以将参数传给程序,因此可以试试下面这些命令 行处理:

$ cargo run 42 56
Compiling hello v0.1.0 (/home/jimb/rust/hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.22s
Running `/home/jimb/rust/hello/target/debug/hello 42 56`
The greatest common divisor of [42, 56] is 14

$ cargo run 799459 28823 27347
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `/home/jimb/rust/hello/target/debug/hello 799459
28823 27347`
The greatest common divisor of [799459, 28823, 27347] is 41

$ cargo run 83
Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `/home/jimb/rust/hello/target/debug/hello 83`
The greatest common divisor of [83] is 83
$ cargo run

Finished dev [unoptimized + debuginfo] target(s) in 0.02s
Running `/home/jimb/rust/hello/target/debug/hello`
Usage: gcd NUMBER ...

本节使用了 Rust 标准库中的一些特性。如果你好奇还有哪些别的特 性,强烈建议看看 Rust 的在线文档。它具有实时搜索功能,能让你 的探索更容易,其中还包括指向源代码的链接。安装 Rust 时, rustup 命令会自动在你的计算机上安装一份文档副本。你既可以在 Rust 网站上查看标准库文档,也可以使用以下命令打开浏览器查看。

$ rustup doc --std

2.5 搭建 Web 服务器

Rust 的优势之一是在 crates.io 网站上发布的大量免费的可用包。 cargo 命令可以让你的代码轻松使用 crates.io 上的包:它将下载 包的正确版本,然后会构建包,并根据用户的要求更新包。一个 Rust 包,无论是库还是可执行文件,都叫作 crate(发音为 /kreɪt/,意思 是“板条箱”) 。Cargo 和 crates.io 的名字都来源于这个术语。 相对于包,crate 更强调自己的硬边界,也暗示着更高的安全性。助记:马车(Cargo)上 装着一些板条箱。——译者注 为了展示这种工作过程,我们将使用 actix-web(Web 框架 crate)、serde(序列化 crate)以及它们所依赖的各种其他 crate 来组装出一个简单的 Web 服务器。如图 2-1 所示,该网站会提示用 户输入两个数值并计算它们的最大公约数。

图 2-1:计算最大公约数的网页

图 2-1:计算最大公约数的网页

首先,让 Cargo 创建一个新包,命名为 actix-gcd:

$ cargo new actix-gcd
Created binary (application) `actix-gcd` package
$ cd actix-gcd

利用cargo添加包

$ cargo add actix-web
    Updating crates.io index
      Adding actix-web v4.9.0 to dependencies
    ...
$ cargo add serde --features derive
    Updating crates.io index
      Adding serde v1.0.217 to dependencies
    ...

然后,编辑新项目的 Cargo.toml 文件以列出所要使用的包,其内容 应该是这样的:

[package]
[package]
name = "actix-gcd"
version = "0.1.0"
edition = "2024"

[dependencies]
actix-web = "4.9.0"
serde = { version = "1.0.217", features = ["derive"] }
cat Cargo.toml

Cargo.toml 中 [dependencies] 部分的每一行都给出了 crates.io 上的 crate 名称,以及我们想要使用的那个 crate 的版 本。在本例中,我们需要 1.0.8 版的 actix-web crate 和 1.0 版的 serde crate。crates.io 上这些 crate 的版本很可能比此处 展示的版本新,但通过指明在测试此代码时所使用的特定版本,可以 确保即使发布了新版本的包,这些代码仍然能继续编译。第 8 章会 更详细地讨论版本管理。

crate 可能具备某些可选特性:一部分接口或实现不是所有用户都需 要的,但将其包含在那个 crate 中仍然有意义。例如,serde crate 就提供了一种非常简洁的方式来处理来自 Web 表单的数据,但根据 serde 的文档,只有选择了此 crate 的 derive 特性时它才可用, 因此我们在 Cargo.toml 文件中请求了它。 请注意,只需指定要直接用到的那些 crate 即可,cargo 会负责把 它们自身依赖的所有其他 crate 带进来。 在第一次迭代中,我们将实现此 Web 服务器的一个简单版本:它只会 给出让用户输入要计算的数值的页面。actix-gcd/src/main.rs 的内 容如下所示:

use actix_web::{App, HttpResponse, HttpServer, web};
#[actix_web::main]
async fn main() {
    let server = HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(get_index))
    });

    println!("Serving on http://localhost:3000...");
    server
        .bind("127.0.0.1:3000")
        .expect("error binding server to address")
        .run()
        .await
        .expect("error running server");
}

async fn get_index() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("text/html")
        .body(
            r#"
                <title>GCD Calculator</title>
                <form action="/gcd" method="post">
                <input type="text" name="n"/>
                <input type="text" name="m"/>
                <button type="submit">Compute GCD</button>
                </form>
            "#
        )
}

use 声明可以让来自 actix-web crate 的定义用起来更容易些。当 我们写下 use actix_web::{…} 时,花括号中列出的每个名称 都可以直接用在代码中,而不必每次都拼出全名,比如 actix_web::HttpResponse 可以简写为 HttpResponse。(稍 后还会提及 serde crate。)

这次我们的主函数是一个带有 #[actix_web::main] 属性的异步函数(async fn)。Actix 是使用异步代码编写的,以支持同时处理数千个连接,而无需生成数千个系统线程。Rust 的 async 和 await 功能与 C# 和 JavaScript 中的 async/await,或 Erlang 中的轻量级进程属于同一类别。它们是一种处理不需要一直占用 CPU 的任务的方式——也许是因为,就像在我们的例子中,这些任务会花费大量时间等待网络。单个 CPU 核心可以保持许多并发异步任务以响应方式运行,而无需系统线程的开销。

main 函数很简单:它调用 HttpServer::new 创建了一个响应单 个路径 “/” 请求的服务器,打印了一条信息以提醒我们该如何连接 它,然后监听本机的 TCP 端口 3000。 我们传给 HttpServer::new 的参数是 Rust 闭包表达式 || { App::new() … }。闭包是一个可以像函数一样被调用的值。这个 闭包没有参数,如果有参数,那么可以将参数名放在两条竖线 || 之 间。{ … } 是闭包的主体。当我们启动服务器时,Actix 会启动一 个线程池来处理传入的请求。每个线程都会调用这个闭包来获取 App 值的新副本,以告诉此线程该如何路由这些请求并处理它们。 闭包会调用 App::new 来创建一个新的空白 App,然后调用它的 route 方法为路径 “/” 添加一个路由。提供给该路由的处理程序 web::get().to(get_index) 会通过调用函数 get_index 来处 理 HTTP 的 GET 请求。route 方法的返回值就是调用它的那个 App,不过其现在已经有了新的路由。由于闭包主体的末尾没有分号, 因此此 App 就是闭包的返回值,可供 HttpServer 线程使用。 get_index 函数会构建一个 HttpResponse 值,该值表示对 HTTP GET / 请求的响应。HttpResponse::Ok() 表示 HTTP 200 OK 状态,意味着请求成功。我们会调用它的 content_type 方法和 body 方法来填入该响应的细节,每次调用都会返回在前一次基础上 修改过的 HttpResponse。最后会以 body 的返回值作为 get_index 的返回值。 由于响应文本包含很多双引号,因此我们使用 Rust 的“原始字符 串”语法来编写它:首先是字母 r、0 到多个井号(#)标记、一个双 引号,然后是字符串本体,并以另一个双引号结尾,后跟相同数量的 (#) 标记。任何字符都可以出现在原始字符串中而不被转义,包括双引 号。事实上,Rust 根本不认识像 \” 这样的转义序列。我们总是可以 在引号周围使用比文本内容中出现过的 # 更多的 # 标记,以确保字 符串能在期望的地方结束。 编写完 main.rs 后,可以使用 cargo run 命令来执行为运行它而 要做的一切工作:获取所需的 crate、编译它们、构建我们自己的程 序、将所有内容链接在一起,最后启动 main.rs。

$ cargo run
 Updating crates.io index
 Downloading crates ...
 Downloaded serde v1.0.100
 Downloaded actix-web v1.0.8
 Downloaded serde_derive v1.0.100
...
 Compiling serde_json v1.0.40
 Compiling actix-router v0.1.5
 Compiling actix-http v0.2.10
 Compiling awc v0.2.7
 Compiling actix-web v1.0.8
 Compiling gcd v0.1.0 (/home/jimb/rust/actix-gcd)
 Finished dev [unoptimized + debuginfo] target(s) in 1m 24s
 Running `/home/jimb/rust/actix-gcd/target/debug/actix-gcd`
Serving on http://localhost:3000...

此刻,在浏览器中访问给定的 URL 就会看到图 2-1 所示的页面。 但很遗憾,单击“Compute GCD”除了将浏览器导航到一个空白页面 外,没有做任何事。为了继续解决这个问题,可以往 App 中添加另一 个路由,以处理来自表单的 POST 请求。 现在终于用到我们曾在 Cargo.toml 文件中列出的 serde crate 了:它提供了一个便捷工具来协助处理表单数据。首先,将以下 use 指令添加到 src/main.rs 的顶部:

use serde::Deserialize;

Rust 程序员通常会将所有的 use 声明集中放在文件的顶部,但这并 非绝对必要:Rust 允许这些声明以任意顺序出现,只要它们出现在适 当的嵌套级别即可。 接下来,定义一个 Rust 结构体类型,用以表示期望从表单中获得的 值:

#[derive(Deserialize)]
struct GcdParameters {
 n: u64,
 m: u64,
}

上述代码定义了一个名为 GcdParameters 的新类型,它有两个字段 (n 和 m),每个字段都是一个 u64,这是我们的 gcd 函数想要的 参数类型。 此 struct 定义上面的注解是一个属性,就像之前用来标记测试函数 的 #[test] 属性一样。在类型定义之上放置一个 # [derive(Deserialize)] 属性会要求 serde crate 在程序编译 时检查此类型并自动生成代码,以便从 HTML 表单 POST 提交过来的 格式化数据中解析出此类型的值。事实上,该属性足以让你从几乎任 何种类的结构化数据(JSON、YAML、TOML 或许多其他文本格式和二 进制格式中的任何一种)中解析 GcdParameters 的值。serde crate 还提供了一个 Serialize 属性,该属性会生成代码来执行相 反的操作,获取 Rust 值并以结构化的格式序列化它们。 有了这个定义,就可以很容易地编写处理函数了:

async fn post_gcd(form: web::Form<GcdParameters>) -> HttpResponse {
    if form.n == 0 || form.m == 0 {
        return HttpResponse::BadRequest()
            .content_type("text/html")
            .body("Computing the GCD with zero is boring.");
    }

    let response = format!(
        "The greatest common divisor of the numbers {} and {} \
            is <b>{}</b>\n",
        form.n,
        form.m,
        gcd(form.n, form.m),
    );

    HttpResponse::Ok()
        .content_type("text/html")
        .body(response)
}

对于用作 Actix 请求处理程序的函数,其参数必须全都是 Actix 知 道该如何从 HTTP 请求中提取出来的类型。post_gcd 函数接受一个 参数 form,其类型为 web::Form。当且仅当 T 可以从 HTML 表单提交过来的数据反序列化时,Actix 才能知道该 如何从 HTTP 请求中提取任意类型为 web::Form 的值。由于我 们已经将 #[derive(Deserialize)] 属性放在了 GcdParameters 类型定义上,Actix 可以从表单数据中反序列化 它,因此请求处理程序可以要求以 web::Form 值作为参数。这些类型和函数之间的关系都是在编译期指定的。如果 使用了 Actix 不知道该如何处理的参数类型来编写处理函数,那么 Rust 编译器会直接向你报错。 来看看 post_gcd 内部,如果任何一个参数为 0,则该函数会先行返 回 HTTP 400 BAD REQUEST 错误,因为如果它们为 0,我们的 gcd 函数将崩溃。同时,post_gcd 会使用 format! 宏来为此请求构造 出响应体。format! 与 println! 很像,但它不会将文本写入标准 输出,而是会将其作为字符串返回。一旦获得响应文本,post_gcd 就会将其包装在 HTTP 200 OK 响应中,设置其内容类型,并将它返 回给请求者。 还必须将 post_gcd 注册为表单处理程序。为此,可以将 main 函 数替换成以下这个版本:

#[actix_web::main]
async fn main() {
    let server = HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(get_index))
            .route("/gcd", web::post().to(post_gcd))
    });

    println!("Serving on http://localhost:3000...");
    server
        .bind("127.0.0.1:3000")
        .expect("error binding server to address")
        .run()
        .await
        .expect("error running server");
}

这里唯一的变化是添加了另一个 route 调用,确立 web::post().to(post_gcd) 作为路径 “/gcd” 的处理程序。 最后剩下的部分是我们之前编写的 gcd 函数,它位于 actix￾gcd/src/main.rs 文件中。有了它,你就可以中断运行中的服务器, 重新构建并启动程序了: 最终版本

use actix_web::{App, HttpResponse, HttpServer, web};
#[actix_web::main]
async fn main() {
    let server = HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(get_index))
            .route("/gcd", web::post().to(post_gcd))
    });

    println!("Serving on http://localhost:3000...");
    server
        .bind("127.0.0.1:3000")
        .expect("error binding server to address")
        .run()
        .await
        .expect("error running server");
}

async fn get_index() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("text/html")
        .body(
            r#"
                <title>GCD Calculator</title>
                <form action="/gcd" method="post">
                <input type="text" name="n"/>
                <input type="text" name="m"/>
                <button type="submit">Compute GCD</button>
                </form>
            "#
        )
}

use serde::Deserialize;
#[derive(Deserialize)]
struct GcdParameters {
    n: u64,
    m: u64,
}
async fn post_gcd(form: web::Form<GcdParameters>) -> HttpResponse {
    if form.n == 0 || form.m == 0 {
        return HttpResponse::BadRequest()
            .content_type("text/html")
            .body("Computing the GCD with zero is boring.");
    }

    let response = format!(
        "The greatest common divisor of the numbers {} and {} \
            is <b>{}</b>\n",
        form.n,
        form.m,
        gcd(form.n, form.m),
    );

    HttpResponse::Ok()
        .content_type("text/html")
        .body(response)
}

fn gcd(mut n: u64, mut m: u64) -> u64 {
    assert!(n != 0 && m != 0);
    while m != 0 {
        if m < n {
            let t = m;
            m = n;
            n = t;
        }
        m = m % n;
    }
    n
}
$ cargo run
 Compiling actix-gcd v0.1.0 (/home/jimb/rust/actix-gcd)
 Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs
 Running `target/debug/actix-gcd`
Serving on http://localhost:3000...

这一次,访问 http://localhost:3000,输入一些数值,然后单击 “Compute GCD”按钮,应该会看到一些实质性结果,如图 2-2 所 示。

image.png 图 2-2:展示计算最大公约数结果的网页

2.6 并发

Rust 的一大优势是它对并发编程的支持。Rust 中用来确保内存安全 的那些规则也同样可以让线程在共享内存的时候避免数据竞争。 如果使用互斥锁来协调对共享数据结构进行更改的多个线程,那 么 Rust 会确保只有持有锁才能访问这些数据,并会在完工后自 动释放锁。而在 C 和 C++ 中,互斥锁和它所保护的数据之间的 联系只能体现在注释中。

如果想在多个线程之间共享只读数据,那么 Rust 能确保你不会 意外修改数据。而在 C 和 C++ 中,虽然类型系统也可以帮你解 决这个问题,但很容易出错。

如果将数据结构的所有权从一个线程转移给另一个线程,那么 Rust 能确保你真的放弃了对它的所有访问权限。而在 C 和 C++ 中,要由你来检查发送线程上的任何代码是否会再次接触数据。

如果你弄错了,那么后果可能取决于处理器缓存中正在发生什 么,以及你最近对内存进行过多少次写入。我们或多或少都在这 方面吃过一点儿苦头。

本节将引导你写出第二个多线程程序。

你已经写完了第一个程序:用 Actix Web 框架实现的最大公约数服务 器,它使用线程池来运行请求处理函数。如果服务器同时收到多个请 求,那么它就会在多个线程中同时运行 get_index 函数和 post_gcd 函数。这可能有点儿令人震撼,因为我们在编写这些函数 时甚至都没有考虑过并发。但 Rust 能确保这样做是安全的,无论你 的服务器变得多么复杂:只要程序编译通过了,就一定不会出现数据 竞争。所有 Rust 函数都是线程安全的。 本节的程序绘制了曼德博集(一组分形几何图形,包括著名的海龟图 等),这是一种对复数反复运行某个简单函数而生成的分形图。人们 通常把“绘制曼德博集”称为易并行算法,因为其线程之间的通信模 式非常简单,第 19 章会介绍更复杂的模式,但这里的任务已足以演 示一些基本要素了。 首先,创建一个新的 Rust 项目:

$ cargo new mandelbrot
 Created binary (application) `mandelbrot` package
$ cd mandelbrot

所有代码都将放在 mandelbrot/src/main.rs 中,我们将向 mandelbrot/Cargo.toml 添加一些依赖项。

在进入并发曼德博实现之前,先来讲一下接下来将要执行的计算。

2.6.1 什么是曼德博集

在阅读代码时,具体了解一下它要执行的任务是很有帮助的,所以, 我们可以稍微了解一点儿纯数学。先从一个简单的案例开始,然后添 加复杂的细节,直到抵达曼德博集最核心的计算领域。 下面是一个使用 Rust 特有语法实现的 loop 语句无限循环:

fn square_loop(mut x: f64) {
    loop {
        x = x * x;
    }
}

在现实世界中,Rust 能看出 x 从未用来做任何事,因此不会计算它 的值。但目前,假设代码能按编写的方式运行。那么 x 的值会如何变 化呢?对任何小于 1 的数值求平方会使它变得更小,因此它会趋近于 0;1 的平方会得到 1;对大于 1 的数值求平方会使它变大,因此它 会趋近于无穷大;对一个负数求平方会先使其变为正数,之后它的变 化情况和前面的情况类似,如图 2-3 所示。

image.png

图 2-3:重复对数值求平方的效果 因此,根据传给 square_loop 的值,x 的取值为 0 或 1、趋近 0 或趋近无穷大。 现在考虑一个略有不同的循环:

fn square_add_loop(c: f64) {
    let mut x = 0.;
    loop {
        x = x * x + c;
    }
}

这一次,x 从 0 开始,我们通过对它求平方后再加上 c 来调整它在 每次迭代中的进度。这更难看出 x 的变化情况了,但通过一些实验会 发现,如果 c 大于 0.25 或小于 -2.0,那么 x 最终会变得无限大, 否则,它就会停留在 0 附近的某个地方。 下一个问题:如果不再使用 f64 值而是改用复数做同样的循环会怎 样?crates.io 上的 num crate 已经提供了开箱即用的复数类型,因 此要在程序的 Cargo.toml 文件的 [dependencies] 部分添加一行 num。这是迄今为止的整个文件(稍后会添加更多):

[package]
name = "mandelbrot"
version = "0.1.0"
edition = "2024"

[dependencies]
num = "0.4"
image = "0.25"

现在可以编写此循环的倒数第二个版本了:

use num::Complex;

fn complex_square_add_loop(c: Complex<f64>) {
    let mut z = Complex { re: 0.0, im: 0.0 };
    loop {
        z = z * z + c;
    }
}

传统上会用 z 来代表复数,因此我们重命名了循环变量。表达式 Complex { re: 0.0, im: 0.0 } 是使用 num crate 的 Complex 类型编写复数 0 的方式。Complex 是一种 Rust 结构体 类型(或 struct),其定义如下:

struct Complex<T> {
 /// 复数的实部
 re: T,
 /// 复数的虚部
 im: T,
}

上述代码定义了一个名为 Complex 的结构体,该结构体有两个字 段,即 re 和 im。Complex 是一种泛型结构体:可以把在类型名称 之后的 读作“对于任意类型 T”。例如,Complex 是一 个复数,其 re 字段和 im 字段为 f64 值,Complex 则使 用 32 位浮点数,等等。根据此定义,像 Complex { re: 0.24, im: 0.3 } 这样的表达式就会生成一个 Complex 值,其 re 字段 已初始化为 0.24,im 字段已初始化为 0.3。 num crate 支持用 *、+ 和其他算术运算符来处理 Complex 值,因 此该函数的其余部分仍然像之前的版本那样工作,只是它会将数值视 作复平面上而不是实数轴上的点进行运算。第 12 章会讲解如何让 Rust 的运算符与自定义类型协同工作。 我们终于抵达了纯数学之旅的终点。曼德博集的定义是:令 z 不会 “飞到”无穷远的复数 c 的集合。我们最初的简单平方循环是可以预 测的:任何大于 1 或小于 -1 的数值都会“飞”出去。把 + c 放入 每次迭代中会使变化情况更难预测:正如前面所说,大于 0.25 或小 于 -2.0 的 c 值会导致 z“飞”出去。但是将此游戏推广到复数就会 生成真正奇异而美丽的图案,这就是我们所要绘制的分形图。 由于复数 c 具有实部 c.re 和虚部 c.im,因此可以把它们视为笛 卡儿平面上某个点的 x 坐标和 y 坐标,如果 c 在曼德博集中,就在 其中用黑色着色,否则就用浅色。因此,对于图像中的每个像素,必 须在复平面上的相应点位运行前面的循环,看看它是逃逸到无穷远还 是永远绕着原点运行,并相应地将其着色。 无限循环需要一段时间才能完成,但是对缺乏耐心的人来说有两个小 技巧。首先,如果不再永远运行循环而只是尝试一些有限次数的迭 代,事实证明仍然可以获得该集合的一个不错的近似值。我们需要多 少次迭代取决于想要绘制的边界的精度。其次,业已证明,一旦 z 离 开了以原点为中心的半径为 2 的圆,它最终就一定会“飞到”无穷远 的地方。所以下面是循环的最终版本,也是程序的核心:

use num::Complex;
/// 尝试测定`c`是否位于曼德博集中,使用最多`limit`次迭代来判定
///
/// 如果`c`不是集合成员之一,则返回`Some(i)`,其中的`i`是`c`离开以原点
/// 为中心的半径为2的圆时所需的迭代次数。如果`c`似乎是集合成员之一(确
/// 切而言是达到了迭代次数限制但仍然无法证明`c`不是成员),则返回`None`
fn escape_time(c: Complex<f64>, limit: usize) -> Option<usize> {
    let mut z = Complex { re: 0.0, im: 0.0 };
    for i in 0..limit {
        if z.norm_sqr() > 4.0 {
            return Some(i);
        }
        z = z * z + c;
    }
    None
}

此函数会接受两个参数:c 是我们要测试其是否属于曼德博集的复 数,limit 是要尝试的迭代次数上限,一旦超出这个次数就放弃并认 为 c 可能是成员。

该函数的返回值是一个 Option。Rust 的标准库中对 Option 类型的定义如下所示:

enum Option<T> {
 None,
 Some(T),
}

Option 是一种枚举类型(通常称为“枚举”,enum),因为它的定 义枚举了这个类型的值可能是几种变体之一:对于任意类型 T, Option 类型的值要么是 Some(v),其中 v 的类型为 T;要么 是 None,表示没有可用的 T 值。与之前讨论的 Complex 类型一 样,Option 是一种泛型类型:你可以使用 Option 来表示任何 一种类型 T 的可选值。 在这个例子中,escape_time 返回一个 Option 来指示 c 是否在曼德博集中——如果不在,是迭代了多少次才发现的。如果 c 不在集合中,那么 escape_time 就会返回 Some(i),其中 i 是 z 在离开半径为 2 的圆之前的迭代次数。否则,c 显然在集合 中,并且 escape_time 返回 None。

for i in 0..limit {

前面的示例展示了如何用 for 循环遍历命令行参数和向量元素,这个 for 循环则只是遍历从 0 开始到 limit(不含)的整数范围。

z.norm_sqr() 方法调用会返回 z 与原点距离的平方。要判断 z 是否已经离开半径为 2 的圆,不必计算平方根,只需将此距离的平方 与 4.0 进行比较即可,这样速度更快。

你可能已经注意到我们使用了 /// 来标记函数定义上方的注释行, Complex 结构体成员上方的注释同样以 /// 开头。这些叫作文档型 注释,rustdoc 实用程序知道如何解析它们和它们所描述的代码,并 生成在线文档。Rust 的标准库文档就是以这种形式编写的。第 8 章 会详细讲解文档型注释。

该程序的其余部分所“关心”的是决定以何种分辨率绘制此集合中的 哪个部分,并将此项工作分发给多个线程以加快计算速度。

2.6.2 解析并配对命令行参数

该程序会接受几个命令行参数来控制我们要写入的图像的分辨率以及 要绘制曼德博集里哪部分的图像。由于这些命令行参数遵循着一种共 同的格式,因此我们写了一个解析它们的函数:

use std::str::FromStr;
/// 把字符串`s`(形如`"400x600"`或`"1.0,0.5"`)解析成一个坐标对
///
/// 具体来说,`s`应该具有<left><sep><right>的格式,其中<sep>是由`separator`
/// 参数给出的字符,而<left>和<right>是可以被`T::from_str`解析的字符串。
/// `separator`必须是ASCII字符
///
/// 如果`s`具有正确的格式,就返回`Some<(x, y)>`;如果无法正确解析,就返回`None`
fn parse_pair<T: FromStr>(s: &str, separator: char) -> Option<(T,
                                                               T)> {
    match s.find(separator) {
        None => None,
        Some(index) => {
            match (T::from_str(&s[..index]), T::from_str(&s[index +
                1..])) {
                (Ok(l), Ok(r)) => Some((l, r)),
                _ => None
            }
        }
    }
}
#[test]
fn test_parse_pair() {
    assert_eq!(parse_pair::<i32>("", ','), None);
    assert_eq!(parse_pair::<i32>("10,", ','), None);
    assert_eq!(parse_pair::<i32>(",10", ','), None);
    assert_eq!(parse_pair::<i32>("10,20", ','), Some((10, 20)));
    assert_eq!(parse_pair::<i32>("10,20xy", ','), None);
    assert_eq!(parse_pair::<f64>("0.5x", 'x'), None);
    assert_eq!(parse_pair::<f64>("0.5x1.5", 'x'), Some((0.5,
                                                        1.5)));
}

parse_pair 的定义是一个泛型函数:

fn parse_pair<T: FromStr>(s: &str, separator: char) -> Option<(T,
T)> {

可以把 子句读作“对于实现了 FromStr 特型的任 意类型 T……”,这样就能高效地一次定义出整个函数家族: parse_pair:: 是能解析一对 i32 值的函数、 parse_pair:: 是能解析一对 f64 浮点值的函数,等等。这 很像 C++ 中的函数模板。Rust 程序员会将 T 称作 parse_pair 的 类型参数。当使用泛型函数时,Rust 通常能帮我们推断出类型参数, 并且我们不必像这里的测试代码那样把它们明确写出来。 我们的返回类型是 Option<(T, T)>:它或者是 None,或者是一个 值 Some((v1, v2)),其中 (v1, v2) 是由两个 T 类型的值构成 的`元组。parse_pair 函数没有使用显式 return 语句,因此它的 返回值是其函数体中最后一个(也是唯一的一个)表达式的值:

match s.find(separator) {
 None => None,
 Some(index) => {
 ...
 }
}

String 类型的 find 方法会在字符串中搜索与 separator 相匹 配的字符。如果 find 返回 None,那么就意味着字符串中没有出现 分隔符,这样整个 match 表达式的计算结果就为 None,表明解析 失败。否则,index 值就是此分隔符在字符串中的位置。

match (T::from_str(&s[..index]), T::from_str(&s[index + 1..])) {
 (Ok(l), Ok(r)) => Some((l, r)),
 _ => None
}

这里初步展现了 match(匹配)表达式的强大之处。match 的参数 是如下元组表达式: (T::from_str(&s[..index]), T::from_str(&s[index + 1..])) 表达式 &s[..index] 和 &s[index + 1..] 都是字符串的切片, 分别位于分隔符之前和之后。类型参数 T 的关联函数 from_str 会 获取其中的每一个元素并尝试将它们解析为类型 T 的值,从而生成结 果元组。下面是我们要匹配的目标:

(Ok(l), Ok(r)) => Some((l, r)),

仅当此元组的两个元素都是 Result 类型的 Ok 变体时,该模式才能 匹配上,这表明两个解析都成功了。如果是这样,那么

Some((l,r)) 

就是匹配表达式的值,也就是函数的返回值。 _ => None 通配符模式 _ 会匹配任意内容并忽略其值。如果运行到此处,则表明 parse_pair 已然失败,因此其值为 None,并继而作为本函数的返回值。

现在有了 parse_pair,就很容易编写一个函数来解析一对浮点坐标 并将它们作为 Complex 值返回:

/// 把一对用逗号分隔的浮点数解析为复数
fn parse_complex(s: &str) -> Option<Complex<f64>> {
    match parse_pair(s, ',') {
        Some((re, im)) => Some(Complex { re, im }),
        None => None
    }
}
#[test]
fn test_parse_complex() {
    assert_eq!(parse_complex("1.25,-0.0625"),
               Some(Complex { re: 1.25, im: -0.0625 }));
    assert_eq!(parse_complex(",-0.0625"), None);
}

parse_complex 函数调用了 parse_pair,如果坐标解析成功则构 建一个 Complex 值,如果失败则传回给它的调用者。

如果你很细心,可能会注意到我们用了简写形式来构建 Complex值。 用同名变量来初始化结构体中的字段是很常见的写法,所以 Rust 不会强迫你写成 Complex { re: re, im: im },而会让你简写 成 Complex { re, im }。这是从 JavaScript 和 Haskell 中的类 似写法借鉴来的。

2.6.3 从像素到复数的映射

我们的程序需要在两个彼此相关的坐标空间中运行:输出图像中的每 个像素对应于复平面上的一个点。这两个空间之间的关系取决于要绘 制曼德博集的哪一部分以及所请求图像的分辨率,这些都要通过命令 行参数指定。以下函数会将图像空间转换为复数空间:

/// 给定输出图像中像素的行和列,返回复平面中对应的坐标
///
/// `bounds`是一个`pair`,给出了图像的像素宽度和像素高度。`pixel`是表示该
/// 图像中特定像素的(column, row)二元组。`upper_left`参数和`lower_right`
/// 参数是在复平面中表示指定图像覆盖范围的点
fn pixel_to_point(bounds: (usize, usize),
                  pixel: (usize, usize),
                  upper_left: Complex<f64>,
                  lower_right: Complex<f64>)
                  -> Complex<f64>
{
    let (width, height) = (lower_right.re - upper_left.re,
                           upper_left.im - lower_right.im);
    Complex {
        re: upper_left.re + pixel.0 as f64 * width / bounds.0 as
            f64,
        im: upper_left.im - pixel.1 as f64 * height / bounds.1 as
            f64
        // 为什么这里要用减法?这是因为在屏幕坐标系中pixel.1是
        // 向下递增的,但复数的虚部是向上递增的
    }
}
#[test]
fn test_pixel_to_point() {
    assert_eq!(pixel_to_point((100, 200), (25, 175),
                              Complex { re: -1.0, im: 1.0 },
                              Complex { re: 1.0, im: -1.0 }),
               Complex { re: -0.5, im: -0.75 });
}

图 2-4 说明了 pixel_to_point 所执行的计算规则。 image.png 图 2-4:复平面与图像像素的对应关系

pixel_to_point 的代码只是简单的计算,就不详细解释了。但 是,有几点需要指出一下。下列形式的表达式引用的是元组中的元 素: pixel.0 这里引用的是 pixel 元组的第一个元素。 pixel.0 as f64 这是 Rust 的类型转换语法:这会将 pixel.0 转换为 f64 值。与 C 和 C++ 不同,Rust 通常会拒绝在数值类型之间进行隐式转换,因此你必须写出所需的转换。这可能有些烦琐,但明确说明发生了哪些 转换以及发生于何时是非常有帮助的。隐式整数转换看似“人畜无害”,但从历史上看,它们一直是现实世界 C 和 C++ 代码中缺陷和安全漏洞的常见来源。

2.6.4 绘制曼德博集

要绘制出曼德博集,只需对复平面上的每个点调用 escape_time, 并根据其结果为图像中的像素着色:

/// 将曼德博集对应的矩形渲染到像素缓冲区中
///
/// `bounds`参数会给出缓冲区`pixels`的宽度和高度,此缓冲区的每字节都
/// 包含一个灰度像素。`upper_left`参数和 `lower_right`参数分别指定了
/// 复平面中对应于像素缓冲区左上角和右下角的点
fn render(pixels: &mut [u8],
          bounds: (usize, usize),
          upper_left: Complex<f64>,
          lower_right: Complex<f64>)
{
    assert!(pixels.len() == bounds.0 * bounds.1);
    for row in 0..bounds.1 {
        for column in 0..bounds.0 {
            let point = pixel_to_point(bounds, (column, row),
                                       upper_left, lower_right);
            pixels[row * bounds.0 + column] =
                match escape_time(point, 255) {
                    None => 0,
                    Some(count) => 255 - count as u8
                };
        }
    }
}

此刻,这一切看起来都很熟悉。

pixels[row * bounds.0 + column] =
    match escape_time(point, 255) {
        None => 0,
        Some(count) => 255 - count as u8,
    };

如果 escape_time 认为该 point 属于本集合,render 就会将相 应像素的颜色渲染为黑色 (0)。否则,render 会将需要更长时间才 能逃离圆圈的数值渲染为较深的颜色。

2.6.5 写入图像文件

image crate 提供了读取和写入各种图像格式的函数,以及一些基本 的图像处理函数。特别是,此 crate 包含一个 PNG 图像文件格式的 编码器,该程序使用这个编码器来保存计算的最终结果。为了使用 image,请将下面这行代码添加到 Cargo.toml 的 [dependencies] 部分: image = “0.13.0” 然后可以这样写:

use image::{ExtendedColorType, ImageEncoder, ImageError};
use image::codecs::png::PngEncoder;
use std::fs::File;

/// 把`pixels`缓冲区(其尺寸由`bounds`给出)写入名为`filename`的文件中
fn write_image(
    filename: &str,
    pixels: &[u8],
    bounds: (usize, usize),
) -> Result<(), ImageError> {
    let output = File::create(filename)?;

    let encoder = PngEncoder::new(output);
    encoder.write_image(
        pixels,
        bounds.0 as u32,
        bounds.1 as u32,
        ExtendedColorType::L8,
    )?;

    Ok(())
}

这个函数的操作一目了然:它打开一个文件并尝试将图像写入其中。 我们给编码器传入来自 pixels 的实际像素数据、来自 bounds 的 宽度和高度,然后是最后一个参数,以说明如何解释 pixels 中的字 节:值 ColorType::Gray(8) 表示每字节都是一个 8 位的灰度 值。 这些也同样一目了然。该函数值得一看的地方在于当出现问题时它是 如何处理的。一旦遇到错误,就要将错误报告给调用者。正如之前提 过的,Rust 中的容错函数应该返回一个 Result 值,成功时为 Ok(s)(其中 s 是成功值),失败时为 Err(e)(其中 e 是错误代 码)。那么 write_image 的成功类型和错误类型是什么呢? 当一切顺利时,write_image 函数只是把所有值得一看的东西都写 到了文件中,没有任何有用的返回值。所以它的成功类型就是单元 (unit)类型 (),而如此命名是因为这个类型只有一个值 ()。单元 类型类似于 C 和 C++ 中的 void。 如果发生错误,那么可能是因为 File::create 无法创建文件或 encoder.encode 无法将图像写入其中,此 I/O 操作就会返回错误 代码。File::create 的返回类型是 Result,而 encoder.encode 的返回类型是 Result<(), std::io::Error>,所以两者共享着相同的错误类 型,即 std::io::Error。write_image 函数也应该这么做。在 任何情况下,失败都应导致立即返回,并传出用以描述错误原因的 std::io::Error 值。 所以,为了正确处理 File::create 的结果,需要 match 它的返 回值,如下所示:

let output = match File::create(filename) {
    Ok(f) => f,
    Err(e) => {
        return Err(e);
    }
};

成功时,就将 output 赋值为 Ok 值中携带的 File。失败时,就将 错误透传给调用者。 这种 match 语句在 Rust 中是一种非常常见的模式,所以该语言提 供了 ? 运算符作为它的简写形式。因此,与其每次在尝试可能失败的 事情时都明确地写出这个逻辑,不如使用以下等效且更易读的语句:

let output = File::create(filename)?;

如果 File::create 失败,那么 ? 运算符就会从 write_image 返回,并传出此错误。否则,output 就会持有已成功打开的 File。  

新手常犯的一个错误就是试图在 main 函数中使用 ?。但 是,由于 main 本身不返回值,因此这样做行不通。应该使用 match 语句,或者像 unwrap 和 expect 这样的简写方法。还 可以选择简单地把 main 改成返回一个 Result,稍后会介绍这 种方式。

2.6.6 并发版曼德博程序

万事俱备,可以展示一下 main 函数了,我们可以在其中利用并发来 完成任务。为简单起见,先来看一个非并发版本:

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();

    if args.len() != 5 {
        let program = &args[0];
        eprintln!("Usage: {program} FILE PIXELS LEFT,TOP RIGHT,BOTTOM");
        eprintln!("Example: {program} mandel.png 1000x750 -1.20,0.35 -1,0.20");
        std::process::exit(1);
    }

    let bounds: (usize, usize) = parse_pair(&args[2], 'x')
        .expect("error parsing image dimensions");
    let upper_left = parse_complex(&args[3])
        .expect("error parsing upper left corner point");
    let lower_right = parse_complex(&args[4])
        .expect("error parsing lower right corner point");

    let mut pixels = vec![0; bounds.0 * bounds.1];

    render(&mut pixels, bounds, upper_left, lower_right);

    write_image(&args[1], &pixels, bounds)
        .expect("error writing PNG file");
}

将命令行参数收集到一个 String 向量中后,我们会解析每个参数, 然后开始计算。

let mut pixels = vec![0; bounds.0 * bounds.1];

宏调用 vec![v; n] 创建了一个 n 元素长的向量,其元素会被初始 化为 v,因此前面的代码创建了一个长度为 bounds.0 * bounds.1 的全零向量,其中 bounds 是从命令行解析得来的图像 分辨率。我们将使用此向量作为单字节灰度像素值的矩形数组,如图 2-5 所示。

image.png

图 2-5:使用向量作为矩形像素阵列 下一行值得关注的代码是:

render(&mut pixels, bounds, upper_left, lower_right);

这会调用 render 函数来实际计算图像。表达式 &mut pixels 借 用了一个对像素缓冲区的可变引用,以允许 render 用计算出来的灰 度值填充它,不过 pixels 仍然是此向量的拥有者。其余的参数传入 了图像的尺寸和要绘制的复平面矩形。

write_image(&args[1], &pixels, bounds)
 .expect("error writing PNG file");

最后,将这个像素缓冲区作为 PNG 文件写入磁盘。在这个例子中,我 们向缓冲区传入了一个共享(不可变)引用,因为 write_image 不 需要修改缓冲区的内容。 此时,可以在发布模式下构建和运行程序,它启用了许多强力的编译 器优化,几秒后会在文件 mandel.png 中写入一个漂亮的图像:

$ cargo build --release
 Updating crates.io index
 Compiling autocfg v1.0.1
 ...
 Compiling image v0.13.0
 Compiling mandelbrot v0.1.0 ($RUSTBOOK/mandelbrot)
 Finished release [optimized] target(s) in 25.36s
$ time target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35
-1,0.20
real 0m4.678s
user 0m4.661s
sys 0m0.008s

此命令会创建一个名为 mandel.png 的文件,你可以使用系统的图像 查看器或在 Web 浏览器中查看该文件。如果一切顺利,它应该如图 2-6 所示。

image.png

图 2-6:并行曼德博程序的结果 在之前的记录中,我们使用过 Unix 的 time 程序来分析程序的运行 时间——对图像的每个像素运行曼德博计算总共需要大约 5 秒。但是 几乎所有的现代机器都有多个处理器核心,而这个程序只使用了一 个。如果可以将此工作分派给机器提供的所有计算资源,则应该能更 快地画完图像。 为此,可以将图像分成多个部分(每个处理器一个),并让每个处理 器为分派给它的像素着色。为简单起见,可以将其分成一些水平条 带,如图 2-7 所示。当所有处理器都完成后,可以将像素写入磁盘 中。

image.png

图 2-7:将像素缓冲区划分为一些条带以进行并行渲染 crossbeam crate 提供了许多有价值的并发设施,包括这里正需要 的一个作用域线程设施。要使用此设施,必须将下面这行代码添加到 Cargo.toml 文件中: crossbeam = “0.8” 然后要找出调用 render 的代码行并将其替换为以下内容:

let threads = std::thread::available_parallelism()
    .expect("error querying CPU count")
    .get();
let rows_per_band = bounds.1.div_ceil(threads);

let bands = pixels.chunks_mut(rows_per_band * bounds.0);
crossbeam::scope(|spawner| {
    for (i, band) in bands.enumerate() {
        let top = rows_per_band * i;
        let height = band.len() / bounds.0;
        let band_bounds = (bounds.0, height);
        let band_upper_left =
            pixel_to_point(bounds, (0, top), upper_left, lower_right);
        let band_lower_right =
            pixel_to_point(bounds, (bounds.0, top + height),
                           upper_left, lower_right);

        spawner.spawn(move || {
            render(band, band_bounds, band_upper_left, band_lower_right);
        });
    }
});

仍以刚才的方式分步进行讲解: 我们首先使用std::thread::available_parallelism()询问系统应该创建多少线程。此函数返回Result>:要么是错误,要么是包含保证为非零的usize的Ok值。我们使用.expect()来省去错误情况,并使用NonZero类型的get方法将其转换为普通usize。 let rows_per_band = bounds.1 / threads + 1;

接下来,我们计算每个带应该有多少行像素。我们将行数除以线程数,向上取整,这样即使高度不是线程的倍数,条带也会覆盖整个图像。Rust的数值类型提供了数十种方法,包括用于整数除法四舍五入的div_ceil。在另一种语言中,我们可能会写(bounds.1+threads-1)/threads,但调用div_ceil更短、更具表现力,而且同样快。(这种方法在加法溢出的极端情况下也是正确的,尽管在这里不太可能!)

let bands: Vec<&mut [u8]> =
 pixels.chunks_mut(rows_per_band * bounds.0).collect();

这里我们将像素缓冲区划分为几个条带。缓冲区的 chunks_mut 方 法会返回一个迭代器,该迭代器会生成此缓冲区的可变且不重叠的切 片,每个切片都包含 rows_per_band * bounds.0 个像素,换句 话说,rows_per_band 包含整行的像素。chunks_mut 生成的最后 一个切片包含的行数可能少一些,但每一行都包含同样数量的像素。 最后,此迭代器的 collect 方法会构建一个向量来保存这些可变且 不重叠的切片。 现在可以使用 crossbeam 库了: crossbeam::scope(|spawner| { … }).unwrap(); 参数 |spawner| { … } 是 Rust 闭包,它需要一个参数 spawner。请注意,与使用 fn 声明的函数不同,无须声明闭包参数 的类型,Rust 将推断它们及其返回类型。在这里, crossbeam::scope 调用了此闭包,并将一个值作为 spawner 参 数传给闭包,以便闭包使用 spawner 来创建新线程。 crossbeam::scope 函数会等待所有线程执行完毕后返回。这种机 制能让 Rust 确保这些线程不会在 pixels 超出作用域后再访问分配 给自己的那部分,并能让我们确保当 crossbeam::scope 返回时, 图像的计算已然完成。如果一切顺利,那么 crossbeam::scope 就 会返回 Ok(()),但如果我们启动的任何线程发生了 panic,则它会 返回一个 Err。我们会对该 Result 调用 unwrap,这样一来,在 那种情况下我们也会发生 panic,并且用户会收到报告。

for (i, band) in bands.into_iter().enumerate() {

在这里,我们遍历了像素缓冲区的各个条带。into_iter() 迭代器 会为循环体的每次迭代赋予独占一个条带的所有权,确保一次只有一 个线程可以写入它(第 5 章会详细解释 into_iter() 迭代器的工 作原理)。然后,枚举适配器生成了一些元组,将向量中的元素与其 索引配对。

let top = rows_per_band * i;
let height = band.len() / bounds.0;
let band_bounds = (bounds.0, height);
let band_upper_left =
 pixel_to_point(bounds, (0, top), upper_left, lower_right);
let band_lower_right =
 pixel_to_point(bounds, (bounds.0, top + height),
 upper_left, lower_right);

给定索引和条带的实际大小(回想一下,最后一个条带可能比其他条 带矮),可以生成 render 需要的一个边界框,但它只会引用缓冲区 的这个条带,而不是整个图像。同样,我们会重新调整渲染器的 pixel_to_point 函数的用途,以找出条带的左上角和右下角落在 复平面上的位置。

spawner.spawn(move |_| {
 render(band, band_bounds, band_upper_left, band_lower_right);
});

最后,创建一个线程,运行 move | _ | { … } 闭包。前面的 move 关键字表示这个闭包会接手它所用变量的所有权,特别是,只 有此闭包才能使用可变切片 band。参数列表 |_| 意味着闭包会接受 一个参数,但不使用它(另一个用以启动嵌套线程的启动器)。 如前所述,crossbeam::scope 调用会确保所有线程在它返回之前 都已完成,这意味着将图像保存到文件中是安全的,这就是我们下一 步要做的。

2.6.7 运行曼德博绘图器

我们在这个程序中使用了几个外部 crate:num 用于复数运算, image 用于写入 PNG 文件,crossbeam 用于提供“作用域线程创 建”原语。下面是包含所有这些依赖项的最终 Cargo.toml 文件:

[package]
name = "mandelbrot"
version = "0.1.0"
edition = "2021"
[dependencies]
num = "0.4"
image = "0.13"
crossbeam = "0.8"

接下来就可以构建并运行程序了:

$ cargo build --release
 Updating crates.io index
 Compiling crossbeam-queue v0.3.2
 Compiling crossbeam v0.8.1
 Compiling mandelbrot v0.1.0 ($RUSTBOOK/mandelbrot)
 Finished release [optimized] target(s) in #.## secs
$ time target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35
-1,0.20
real 0m1.436s
user 0m4.922s
sys 0m0.011s

这里我们再次使用 time 来查看程序运行所需的时间,请注意,尽管 我们仍然花费了将近 5 秒的处理器时间,但实际运行时间仅为 1.5 秒左右。你可以通过注释掉执行此操作的代码并再次进行测量来验证 这部分时间是否花在了写入图像文件上。在测试此代码的笔记本计算 机上,并发版本将曼德博计算时间缩短了近 3/4。第 19 章会展示如 何对此做实质性改进。

和以前一样,该程序会创建一个名为 mandel.png 的文件。有了这个 更快的版本,你就可以根据自己的喜好更改命令行参数,更轻松地探 索曼德博集了。

2.6.8 大“安”无形

这个并行程序与用任何其他语言写出来的程序并没有本质区别:我们 将像素缓冲区的片段分给不同的处理器,由每个处理器单独处理,并 在它们都完工时展示结果。那么 Rust 的并发支持有什么独到之处 呢?

这里并没有展示那些被编译器一票否决的 Rust 程序。本章中展示的 代码能正确地在线程之间对缓冲区进行分区,但是这些代码的许多小 型变体将无法正确进行分区(因此会导致数据竞争),不过这些变体 里没有一个能逃过 Rust 编译器的静态检查。C 编译器或 C++ 编译器 将乐于帮助你探索具有微妙数据竞争的广阔程序空间,而 Rust 会预 先告诉你什么时候可能出错。

第 4 章和第 5 章会讲解 Rust 的内存安全规则。第 19 章会讲解这 些规则如何确保适当的安全并发环境。

2.7 文件系统与命令行工具

Rust 在命令行工具领域构筑了重要的基本应用场景。作为一种现代、 安全、快速的系统编程语言,它为程序员提供了一个工具箱,他们可 以用这个工具箱组装出灵活的命令行界面,从而复现或扩展现有工具 的功能。例如,bat 命令 就提供了一个支持语法高亮的替代方案 cat,并内置了对分页工具的支持,而 hyperfine 可以自动对任何 通过命令或管道运行的程序执行基准测试。

这是一个跨平台的命令行工具,可以用来浏览和交互式地查看文件内容。——译者注 虽然如此复杂的内容已经超出了本书的范畴,但 Rust 可以让你轻松 步入符合工效学的命令行领域。本节将向你展示如何构建自己的搜索 与替换工具,并内置丰富多彩的输出和友好的错误消息。

首先,创建一个新的 Rust 项目:

$ cargo new quickreplace
 Created binary (application) `quickreplace` package
$ cd quickreplace

我们的程序要用到另外两个 crate:用于在终端中创建彩色输出的 text-colorizer 以及执行实际搜索和替换的 regex。和以前一 样,将这些 crate 放在 Cargo.toml 中,告诉 cargo 我们需要它 们:

[package]
name = "quickreplace"
version = "0.1.0"
edition = "2021"
[dependencies]
text-colorizer = "1"
regex = "1"

凡是达到 1.0 版的 Rust crate 都会遵循“语义化版本控制”规则: 在主版本号 1 发生变化之前,所有更新都应当在兼容前序版本的基础 上扩展。因此,如果针对某个 crate 的 1.2 版测试过我们的程序, 那它应该仍然适用于 1.3、1.4 等版本,但 2.0 版可能会引入不兼容 的变更。如果在 Cargo.toml 文件中只是请求版本 “1” 的 crate, 那么 Cargo 就会使用 2.0 之前的 crate 里最新的可用版本。

2.7.1 命令行界面

这个程序的界面非常简单。它有 4 个参数:要搜索的字符串(或正则 表达式)、要替换成的字符串(或正则表达式)、输入文件的名称和 输出文件的名称。我们将从包含这些参数的结构体开始写 main.rs 文 件:

#[derive(Debug)]
struct Arguments {
 target: String,
 replacement: String,
 filename: String,
 output: String,
}

#[derive(Debug)] 属性会让编译器生成一些额外的代码,这能让 我们在 println! 中使用 {:?} 来格式化 Arguments 结构体。 如果用户输入的参数个数不对,那么通常会打印出一份关于如何使用 本程序的简单说明。我们会使用一个名为 print_usage 的简单函 数来完成此操作,并从 text-colorizer 导入所有内容,以便为这 些输出添加一些颜色:

use text_colorizer::*;
fn print_usage() {
 eprintln!("{} - change occurrences of one string into
another",
 "quickreplace".green());
 eprintln!("Usage: quickreplace <target> <replacement> <INPUT>
<OUTPUT>");
}

只要将 .green() 添加到字符串字面量的末尾,就可以生成包裹在 适当 ANSI 转义码中的字符串,从而在终端模拟器中显示为绿色。然 后,在打印之前将生成的字符串插到信息中的其他部分。 现在可以开始收集并处理程序的参数了:

use std::env;
fn parse_args() -> Arguments {
 let args: Vec<String> = env::args().skip(1).collect();
 if args.len() != 4 {
 print_usage();
 eprintln!("{} wrong number of arguments: expected 4, got
{}.",
 "Error:".red().bold(), args.len());
 std::process::exit(1);
 }
 Arguments {
 target: args[0].clone(),
 replacement: args[1].clone(),
 filename: args[2].clone(),
 output: args[3].clone()
 }
}

为了获取用户输入的参数,我们会使用与前面例子中相同的 args 迭 代器。.skip(1) 会跳过迭代器的第一个值(正在运行的程序的名 称),让结果中只含命令行参数。 首先 collect() 方法会生成一个 Vec 参数。然后我们会检查它的 参数个数是否正确,如果不正确,则打印一条信息并以返回一个错误 代码的形式退出。接下来我们再次对部分信息进行着色,并用 .bold() 把这段文本加粗。如果参数个数正确,就把它们放入一个 Arguments 结构体中,并返回该结构体。 下面添加一个只会调用 parse_args 并打印输出的 main 函数:

fn main() {
 let args = parse_args();
 println!("{:?}", args);
}

现在,运行本程序,可以看到它正常输出了错误消息:

$ cargo run
 Updating crates.io index
Compiling libc v0.2.82
Compiling lazy_static v1.4.0
Compiling memchr v2.3.4
Compiling regex-syntax v0.6.22
Compiling thread_local v1.1.0
Compiling aho-corasick v0.7.15
Compiling atty v0.2.14
Compiling text-colorizer v1.0.0
Compiling regex v1.4.3
Compiling quickreplace v0.1.0 (/home/jimb/quickreplace)
Finished dev [unoptimized + debuginfo] target(s) in 6.98s
Running `target/debug/quickreplace`
quickreplace - change occurrences of one string into another
Usage: quickreplace <target> <replacement> <INPUT> <OUTPUT>
Error: wrong number of arguments: expected 4, got 0

如果传给程序的参数个数正确,那么它就会打印出 Arguments 结构 体的文本表示:

$ cargo run "find" "replace" file output
 Finished dev [unoptimized + debuginfo] target(s) in 0.01s
 Running `target/debug/quickreplace find replace file output`
Arguments { target: "find", replacement: "replace", filename:
"file", output:
"output" }

这是一个很好的开端。这些参数都已被正确提取并放置在 Arguments 结构体的正确部分中。

2.7.2 读写文件

接下来,我们需要用某种方法从文件系统中实际获取数据,以便进行 处理,并在完工后将数据写回去。Rust 有一套健壮的输入 / 输出工 具,但标准库的设计者知道读写文件是很常用的操作,所以刻意简化 了它。我们所要做的是导入模块 std::fs,然后就可以访问 read_to_string 函数和 write 函数了:

use std::fs;

std::fs::read_to_string 会返回一个 Result。如果此函数成功,就会生成一个 String;如 果失败,就会生成一个 std::io::Error,这是标准库中用来表示 I/O 问题的类型。类似地,std::fs::write 会返回一个 Result<(), std::io::Error>:在成功的时候不返回任何内 容,一旦出现问题就返回错误详情。

fn main() {
    let args = parse_args();
    let data = match fs::read_to_string(&args.filename) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("{} failed to read from file '{}': {:?}",
                      "Error:".red().bold(), args.filename, e);
            std::process::exit(1);
        }
    };
    match fs::write(&args.output, &data) {
        Ok(_) => {},
        Err(e) => {
            eprintln!("{} failed to write to file '{}': {:?}",
                      "Error:".red().bold(), args.output, e);
            std::process::exit(1);
        }
    };
}

在这里,我们使用前面写好的 parse_args() 函数并将生成的文件 名传给 read_to_string 和 write。对这些函数的输出使用 match 语句可以优雅地处理错误,打印出文件名、错误原因,并用一 点儿醒目的颜色引起用户的注意。 有了这个改写后的 main 函数,运行程序时就可以看到下面这些了, 当然,新旧文件的内容是完全相同的:

$ cargo run "find" "replace" Cargo.toml Copy.toml
 Compiling quickreplace v0.1.0 (/home/jimb/rust/quickreplace)
 Finished dev [unoptimized + debuginfo] target(s) in 0.01s
 Running `target/debug/quickreplace find replace Cargo.toml

Copy.toml` 该程序确实读取了输入文件 Cargo.toml,也确实写入了输出文件 Copy.toml,但是由于我们尚未编写任何代码来实际进行查找和替换, 因此输出中没有任何变化。通过运行 diff 命令轻松进行查验,该命 令确实没有检测到任何差异。

$ diff Cargo.toml Copy.toml

2.7.3 查找并替换

这个程序的最后一步是实现它的实际功能:查找并替换。为此,我们 将使用 regex crate,它会编译并执行正则表达式。它提供了一个名 为 Regex 的结构体,表示已编译的正则表达式。Regex 有一个 replace_all 方法,该方法名副其实:在一个字符串中搜索此正则 表达式的所有匹配项,并用给定的替代字符串替换每个匹配项。可以 将这段逻辑提取到一个函数中:

use regex::Regex;
fn replace(target: &str, replacement: &str, text: &str)
           -> Result<String, regex::Error>
{
    let regex = Regex::new(target)?;
    Ok(regex.replace_all(text, replacement).to_string())
}

注意看这个函数的返回类型。就像之前使用过的标准库函数一样, replace 也会返回一个 Result,但这次它携带着 regex crate提供的错误类型。

Regex::new 会编译用户提供的正则表达式,如果给定的字符串无效,那么它就会失败。与曼德博程序中一样,我们使用 ? 符号在Regex::new 失败的情况下短路它,但该函数将返回 regex crate特有的错误类型。一旦正则表达式编译完成,它的 replace_all方法就能用给定的替代字符串替换 text 中的任何匹配项。 如果 replace_all 找到了匹配项,那么它就会返回一个新的String,而这些匹配项会被替换成我们给它的文本。否则,replace_all 就会返回指向原始文本的指针,以回避不必要的内存分配和复制。然而,在这个例子中,我们想要一个独立的副本,因此无论是哪种情况,都要使用 to_string 方法来获取 String 并返回包裹在 Result::Ok 中的字符串,就像其他函数中的做法一样。

现在,是时候将这个新函数合并到 main 代码中了:

fn main() {
    let args = parse_args();
    let data = match fs::read_to_string(&args.filename) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("{} failed to read from file '{}': {:?}",
                      "Error:".red().bold(), args.filename, e);
            std::process::exit(1);
        }
    };
    let replaced_data = match replace(&args.target,
                                      &args.replacement, &data) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("{} failed to replace text: {:?}",
                      "Error:".red().bold(), e);
            std::process::exit(1);
        }
    };
    match fs::write(&args.output, &replaced_data) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("{} failed to write to file '{}': {:?}",
                      "Error:".red().bold(), args.filename, e);
            std::process::exit(1);
        }
    };
}

完成了最后一步,程序已经就绪,你可以测试它了:

$ echo "Hello, world" > test.txt
$ cargo run "world" "Rust" test.txt test-modified.txt
 Compiling quickreplace v0.1.0 (/home/jimb/rust/quickreplace)
 Finished dev [unoptimized + debuginfo] target(s) in 0.88s
 Running `target/debug/quickreplace world Rust test.txt testmodified.txt`
$ cat test-modified.txt
Hello, Rust

错误处理做得也很到位,它优雅地向用户报告错误:

$ cargo run "[[a-z]" "0" test.txt test-modified.txt
 Finished dev [unoptimized + debuginfo] target(s) in 0.01s
 Running `target/debug/quickreplace '[[a-z]' 0 test.txt testmodified.txt`
Error: failed to replace text: Syntax(
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~
regex parse error:
 [[a-z]
 ^
error: unclosed character class
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~
)

当然,这个简单的演示中还缺少许多特性,但已经“五脏俱全”。至此,你已经了解了如何读取和写入文件、传播和显示错误,以及为输出着色以提升终端程序里的用户体验。 在未来的章节中,我们将探讨应用程序开发中的高级技术,从数据的集合以及使用迭代器进行函数式编程到可实现高效并发的异步编程技术,但首先,你得在第 3 章的 Rust 基本数据类型方面打下坚实的基础。