Rust给作者写书带来了挑战:这种语言的特点不是可以放在一页纸中吹嘘的某一个具体牛逼的特性,而是其精巧设计的每个部分共同平滑实现最后一章所提出的目标:安全、高性能编程。语言的每一部分都与其它部分进行了完美的结合。
因此我们不是先一个个讲解语言特性,而是准备了一些小而完整的程序,每个程序都会涉及到语言的一些特性:
- 热身程序里对命令行参数进行简单运算,同时包含了单元测试。这展示了Rust的主要类型并引入了trait。
- 紧接着我们构建一个web服务端。我们会使用第三方库来处理HTTP的细节并介绍字符串处理、闭包和错误处理。
- 第3个程序将计算分布至多个线程中,提升速度。包含泛型函数示例,描述了如何处理像素缓冲,并展示了Rust对并发的支持
- 最后我们展示了一个强大的命令行工具,使用正则表达式处理文件。这里用到了处理文件的Rust标准库以及最常用的第三方正则表达式库。
Rust承诺的让未知行为对性能产生最小化影响,在系统的每个细节的设计中都有体现,不管是向量和字符串这样的数据结构,还是Rust程序使用第三方库的方式。实现的细节贯穿本书。现在我们还是先展示Rust这一能力强大、使用愉悦的语言。
首先自然是要在电脑上安装Rust。
注:以译者粗浅的理解,Rust中的trait就是接口。它可以对标其它语言中的接口,和Go语言中的interface用法基本一致,不过大家好像更乐意保留trait的说法,所以均不进行翻译。
本文中的完整代码及有关Rust的后续整理请参见: GitHub仓库
rustup和Cargo
安装Rust的最佳方式是使用rustup
。访问https://rustup.rs,然后按其中的步骤操作。
你也可以访问Rust官网获取一个Linux、macOS和Windows的预构建包。在一些操作系统的分发系统中也包含Rust。我们推荐rustup
的原因 是这是一个管理Rust安装的工作,类似于Ruby的RVM或Node的NVM。例如,在Rust发布新版本时,我们只需要输入rustup update
进行升级即可。
不论选择什么方式,在完成安装后,命令行会有3个命令可供使用:
1 2 3 4 5 6 |
$ cargo --version cargo 1.61.0 (a028ae42f 2022-04-29) $ rustc --version rustc 1.61.0 (fe5b13d68 2022-05-18) $ rustdoc --version rustdoc 1.61.0 (fe5b13d68 2022-05-18) |
以上的$
为命令行提示符,在Windows系统中类似C:\>
。上面的代码中我们运行了三条刚刚安装的命令,打印出各条命令的版本。这3个命令依次是:
cargo
是Rust的编译管理器、包管理器和通用工具。可以使用Cargo开启一个新项目、构建和运行程序,以及管理代码所依赖的外部库。rustc
是Rust的编译器。通常我们可以让Cargo为我们调用编译器,但有时直接运行也有其用途。rustdoc
是Rust的文档工具。如果在源代码中按所需格式在注释中编写文档,rustdoc
可以通过注释构建出美观的HTML。类似于rustc
,通常我们可以让Cargo调用rustdoc
。
为方便起见,Cargo可为我们新建Rust包,包含一些编排好的标准元数据:
1 2 |
$ cargo new hello Created binary (application) `hello` package |
这条命令会新建一个名为hello的包目录,可用于构建命令执行程序。
查看该包的顶级目录:
1 2 3 4 5 6 7 8 9 |
$ cd hello/ $ ls -la total 16 drwxr-xr-x 6 alan staff 192 Jun 17 14:41 . drwxr-xr-x 4 alan staff 128 Jun 17 14:41 .. drwxr-xr-x 9 alan staff 288 Jun 17 14:41 .git -rw-r--r-- 1 alan staff 8 Jun 17 14:41 .gitignore -rw-r--r-- 1 alan staff 174 Jun 17 14:41 Cargo.toml drwxr-xr-x 3 alan staff 96 Jun 17 14:41 src |
可以看到Cargo创建了Cargo.toml 文件,其中存储包的元数据。现在该文件中的内容不多:
1 2 3 4 5 6 7 8 |
[package] name = "hello" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] |
如果程序需要依赖其它库,我们可以在这个文件中进行记录,Cargo会处理对这些库的下载、构建和升级。在第8章中会详细讲解Cargo.toml 。
Cargo使用git
版本控制系统来构建包,创建一个.git元数据目录和一个.gitignore文件。可以在运行cargo new
命令时传递--vcs none
来告诉Cargo跳过这一步。
src子目录中包含真实的Rust代码:
1 2 3 4 |
$ cd src/ $ ls -l total 8 -rw-r--r-- 1 alan staff 45 Jun 17 14:41 main.rs |
Cargo已经代我们编写了一个程序。main.rs中的内容如下:
1 2 3 |
fn main() { println!("Hello, world!"); } |
在Rust中,我们甚至不用写自己的“Hello, World!”程序。这就是Rust基本程序,两个文件,十多行代码。
可以在包内的任意路径执行cargo run
命令运行程序:
1 2 3 4 |
$ cargo run Finished dev [unoptimized + debuginfo] target(s) in 0.04s Running `/Users/alan/Desktop/demo/Rust/hello/target/debug/hello` Hello, world! |
这里Cargo调用了Rust编译器rustc
,然后运行其生成的可执行文件。Cargo将可执行文件放到了包顶级目录的target子目录下:
1 2 3 4 5 6 7 8 9 10 |
$ ls -l ../target/debug/ total 952 drwxr-xr-x 2 alan staff 64 Jun 17 15:02 build drwxr-xr-x 12 alan staff 384 Jun 17 15:02 deps drwxr-xr-x 2 alan staff 64 Jun 17 15:02 examples -rwxr-xr-x 1 alan staff 480768 Jun 17 15:02 hello -rw-r--r-- 1 alan staff 104 Jun 17 15:02 hello.d drwxr-xr-x 3 alan staff 96 Jun 17 15:02 incremental $ ../target/debug/hello Hello, world! |
测试完成后,可通过Cargo清理所生成这些文件:
1 2 3 |
$ cargo clean $ ../target/debug/hello -bash: ../target/debug/hello: No such file or directory |
Rust的函数
Rust刻意没有独创语法。如果读者熟悉C、C++、Java或JavaScript任意一门编程语言,就可以很容易了解Rust程序的基本结构。以下是计算两个整数最大公约数的函数,使用了欧几里得算法。可以在src/main.rs的最后添加以下代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
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
关键字(读作fun)开启了一个函数。此处我们将函数命名为gcd
,它接收两个参数n
和m
,类型均为u64
,无符号64位整形。->
后接返回类型:这个函数返回一个u64
值。Rust的标准样式是4个空格的缩进。
Rust的机器整型名称反映了其大小及符号:i32
为有符号32位整型。u8
是无符号8位整型(用于byte类型)等等。isize
和usize
类型存储指针大小的有符号和无符号整理,32位的平台上为32位,64位平台上为64位。Rust还有两个浮点类型:f32
和f64
,是IEEE单精度和双精度浮点类型,和C与C++中一样。
默认变量一旦初始化,其值就无法修改,但在参数n
和m
之前加一个mut
(读作mute,为mutable的缩写)关键字就可以在函数体中对其赋值。实际编码中,大部分变量不进行重新赋值,需要的时候加上mut
关键字有助于代码的阅读。
函数体中首先调用了assert!
宏,验证两个参数均不为空。!
字符标记其为宏调用,而不是函数调用。和C与C++中的assert
宏一样,Rust的assert!
检测其参数是否为true,若不是则终止程序,使用帮助消息提示检测失败的代码位置;这种突然终止称为panic。和C与C++中可跳过断言不同,不论程序如何编译Rust都会进行断言检测。还有一个debug_assert!
宏,在程序快速编译时会跳过该断言。
我们函数的核心代码是包含if
语句及赋值的while
循环。和C与C++不同,Rust中条件表达式不要求加括号,但控制语句两边需加花括号。
let
语句声明一个本地变量,就像函数中的t
。只要Rust能推导出如何使用变量,我们就不需要写t
的类型。在以上的函数中,要匹配m
和n
,t
只能是u64
类型。Rust只在函数体中进行类型推导:必须要对函数参数和返回值加上类型,参见上例。如果想写t
的类型,可以这么写:
1 |
let t: u64 = m; |
Rust有return
语句,但gcd
函数不需要使用它。如果函数体的最后一个表达式不以分号结尾,那就是函数的返回值。使用花括号包裹的任意代码块都可作为表达式。例如,以下是打印一条消息、然后运算x.cos()
作为其值的表达式:
1 2 3 4 |
{ println!("evaluating cos x"); x.cos() } |
在Rust中使用这种形式在函数运行至结尾时形成函数的值很常见,只有在函数中间需要提早返回时才使用return
语句。
编写及运行单元测试
Rust语言中集成了测试,使用简单。测试所开发的gcd
函数,我们可以在src/main.rs的末尾添加这段代码:
1 2 3 4 5 6 7 8 |
#[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]
标记是一种属性。属性是一套使用附加信息标记函数和其它声明的非闭口体系,和C++、C#的属性或Java中的注解类似。用于控制编译器警告和代码样式检查,包含代码条件(类似C和C++的#ifdef
),告诉Rust如何与其它语言交互等等。在学习的过程中还会有属性的其它示例。
在本章开篇创建的hello包中加入了gcd
和test_gcd
定义后,当前目录为包的子目录,我们可以这样运行测试:
1 2 3 4 5 6 7 8 9 |
$ cargo test Compiling hello v0.1.0 (/Users/alan/Desktop/demo/Rust/hello) Finished test [unoptimized + debuginfo] target(s) in 10.32s Running unittests src/main.rs (target/debug/deps/hello-d8431eadb2c13995) running 1 test test test_gcd ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s |
处理命令行参数
我们可以将src/main.rs中的main
函数替换为如下的代码,来让程序接受一系列数字作为命令行参数,打印出最大公约数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
use std::str::FromStr; use std::env; fn main() { let mut numbers = Vec::new(); for arg in env::args().skip(1) { numbers.push(u64::from_str(&arg).expect("解析参数错误")); } if numbers.len() == 0 { eprintln!("用法: gcd NUMBER..."); std::process::exit(1); } let mut d = numbers[0]; for m in &numbers[1..] { d = gcd(d, *m); } println!("{:?}的最大公约数是{}", numbers, d); } |
这是一大段代码,让我们逐一分解:
1 2 |
use std::str::FromStr; use std::env; |
第一个use
声明将标准库traitFromStr
引入作用域中。trait是一个各种类型可实现的方法集合。任意实现FromStr
trait 的类型,都具有一个将该类型的值解析为字符串的from_str
方法。u64
类型实现了FromStr
,所以我们调用u64::from_str
来解析命令行参数。虽然在程序其它地方没有用到FromStr
,但必须将trait加入到作用域中才能使用其方法。这部分在第11章中进行详解。
第二个use
声明引入了std::env
模块,它提供了一些有用的方法和类型用于与执行环境交互,其中就有访问程序命令行参数的args
函数。
下面走进程序的main
函数:
1 |
fn main() { |
main
函数不返回值,此处可以略去一般跟在参数列表之后的->
和返回类型。
1 |
let mut numbers = Vec::new(); |
我们声明了一个本地可变变量numbers
并将其初始化为一个空向量。Vec
是Rust可增长向量类型,可类比C++的std::vector
、Python的列表以及JavaScript的数组。虽然向量设计为动态伸缩,我们仍需将变量标记为mut
Rust才能让我们在其尾部追加数字。
numbers
的类型为Vec<u64>
,一个u64
值向量,但像前央一样,我们不需要写出来。Rust可代我们推导,一部分是因为我们在向量中追加了u64
类型值,另一部分是国为我们将向量传递给了gcd
,它仍接收u64
类型值。
1 |
for arg in env::args().skip(1) { |
这里我们使用一个for
循环来处理命令行参数,依次使用每个参数设置arg
变量,在循环体中运行。
std::env
模块的args
函数返回一个迭代器-按需生成每个参数的值,以及标记何时完成。Rust中迭代器无处不在,标准库中包含其它迭代器,生成的有向量元素、文件行、通讯通道中接收的消息以及其它可以循环的所有东西。Rust迭代器极其高效:编译器通常可将它们转换成手写循环同样的代码。我们会在第15章中学习其原理并给出示例。
除了使用for
循环,迭代器还包含一堆可以直接使用的方法。例如,由args
返回的第一个由迭代器生成的值总是所运行的程序名,我们希望跳过它,所以调用了迭代器的skip
方法生成一个忽略首个值的新迭代器。
1 2 |
numbers.push(u64::from_str(&arg) .expect("error parsing argument")); |
这里我们调用了u64::from_str
来尝试将命令行参数arg
解析为一个无符号64位整数。这里没有对u64
值调用某个方法,u64::from_str
是与u64
关联的一个函数,类似C++或Java中的静态方法。from_str
函数不直接返回一个u64
,而是返回一个表明解析是否成功的Result
值。Result
值有两种变体:
- 写为
Ok(v)
的值,表明解析成功,v
为所生成的值 - 写为
Err(e)
的值,表明解析失败,e
是说明其原因的错误
执行可能会失败的函数,如输入或输出或与操作系统的交互,可返回Result
类型,Ok
变体携带成功结果(传输的字节数、打开的文件等等),Err
变体携带表明问题的错误码。与大部分现代编程语言不同,Rust中没有异常,所有的错误均由Result
或panic处理,在第7章中进行了讲解。
Result
的expect
方法检测解析是否成功。如果结果为Err(e)
,expect
打印包含e
描述的消息并立即退出程序。但如果结果为Ok(v)
,expect
仅返回v
本身,我们最终会将其推入数字向量的尾部。
1 2 3 4 |
if numbers.len() == 0 { eprintln!("用法: gcd NUMBER..."); std::process::exit(1); } |
空数据集是没有最大公约数的,因此检测向量是否至少有一个元素,如无则程序报错退出。我们使用eprintln!
宏来对标准错误输出流写消息。
1 2 3 4 |
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..]
中的&
运算符依次借助每个元素。*m
中的*
解引用m
,生成其指向的值,正是下一个传递给gcd
的u64
。最后,因为numbers
持有向量,在main
的最后numbers
不在作用域中时Rust会自动释放它。
Rust的所有权规则和指针是其内存管理和并发安全的关键,我们会在第4章及第5章中详细讨论。读者需要适应这些规则才能适应Rust,但对于入门之旅,读者只需要知道&x
借用了对x
的引用,以及*r
是指针r
所指向的值。
继续回到程序:
1 |
println!("{:?}的最大公约数是{}", numbers, d); |
对numbers
的元素进行完遍历,程序将结果打印到标准输出流中。println!
宏接收一个模板字符串,将{...}
形式剩余参数的格式化版本按模板字符串的位置替换,并将结果写至标准输出流中。
不同于C和C++要求main
在程序成功执行时返回零,或是在失败时返回非零退出状态,Rust假定只要main
进行了返回程序就是成功执行。仅在显式调用expect
或std::process::exit
这样的函数时我们才能让程序带错误状态码终止。
cargo run
命令允许我们对程序传递参数,所以我们可以在命令中进行如下运行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
$ cargo run 42 56 Compiling hello v0.1.0 (/Users/alan/Desktop/demo/Rust/hello) Finished dev [unoptimized + debuginfo] target(s) in 13.34s Running `target/debug/hello 42 56` [42, 56]的最大公约数是14 $ cargo run 799459 28823 27347 Finished dev [unoptimized + debuginfo] target(s) in 0.00s Running `target/debug/hello 799459 28823 27347` [799459, 28823, 27347]的最大公约数是41 $ cargo run 83 Finished dev [unoptimized + debuginfo] target(s) in 0.00s Running `target/debug/hello 83` [83]的最大公约数是83 $ cargo run Finished dev [unoptimized + debuginfo] target(s) in 0.00s Running `target/debug/hello` 用法: gcd NUMBER... |
本节中我们使用了Rust标准库中的一些功能。如果你还想知道有什么其它功能,强烈推荐阅读Rust的在线文档。文档可在线实时搜索方便查看,甚至还包含了源代码链接。rustup
命令在安装Rust时自动在电脑上安装了一份副本。可在Rust官网或通过以下命令在本地浏览器中查看标准库文档:
1 |
$ rustup doc --std |
提供网页服务
Rust优势之一是在crates.io网站上免费发布库包集。cargo
命令让我们很容易地在代码中使用crates.io包:它会按要求下载正版本的包、构建包及升级包。Rust包,不论是库还是可执行文件,都称为crate,Cargo和crates.io的名称皆源于此。
为进行演示,我们使用web框架crateactix-web
、序列化crateserde
和一些其它的依赖包开发了一个简单的web服务端。如图2-1所示,我们的网站会让用户填写两个数字并计算它们的最大公约数。
图2-1:用于计算最大公约数的网页
首先我们使用Cargo为我们新建一个包,命名为actix-gcd
:
1 2 3 |
$ cargo new actix-gcd Created binary (application) `actix-gcd` package $ cd actix-gcd |
然后编辑项目的Cargo.toml 来添加希望使用的包,内容如下:
1 2 3 4 5 6 7 8 9 10 |
[package] name = "actix-gcd" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] actix-web = "1.0.8" serde = { version = "1.0", features = ["derive"] } |
Cargo.toml 文件[dependencies]
内的每一行放crates.io上crate的名称,以及希望使用的版本。本例中,actix-web
我们使用的版本是1.0.8
,serde
的版本是1.0
。在crates.io上这些包可能还有更新的版本,但通过指定测试代码使用的版本,我们可以保障即使发布了新版本的包代码仍可编译通过。我们会在第8章中讨论版本管理。
crate还有一些可选特性:一些接口或实现并非所有用户会使用,但放在包中也毫无违和感。serde
包提供了处理网页表单数据的简便方式,但按照serde
的文档,仅在选取了derive
特性时才能使用,因此Cargo.toml 文件中进行了相应的请求。
注意我们只需写下直接使用的crate名称,cargo
会处理它们的依赖。
在第一版中,我们会让web服务端尽量简单:仅有供用户输入待计算数字的页面。在actix-gcd/src/main.rs中加入如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
use actix_web::{web, App, HttpResponse, HttpServer}; 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 to address") .run() .expect("error runing server"); } 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
包的一些定义更易于获取。在写下use actix_web::{...}
时,花括号中的每个名称都可以直接在代码中使用,这样就不用在每次使用时写下完整名称actix_web::HttpResponse
,直接调用HttpResponse
即可。(稍后会用到serde
包。)
main
函数很简单:它调用HttpServer::new
创建一个服务端用于响应单个路径"/"
的请求,打印出提示如何连接的消息,然后设置在本机监听的TCP端口。
传递给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
函数构建了一个响应HTTP GET /
请求的HttpResponse
值。HttpResponse::Ok()
代表HTTP 200 OK
状态,表示请求成功。我们调用其content_type
和body
方法来填充响应的详情,每次调用带上所做修改返回其所应用的HttpResponse
。最后body
的返回值即为get_index
的返回值。
因为请求文本中包含了大量的双引号,我们使用Rust原生字符串语法进行编写:字母r
加零个或多个井号(即#
),一个双引号,然后接字符串内容,再以双引号和同等数量的井号结尾。原生字符串内的任意字符都不会转义,包括双引号,事实上也不识别\"
这样的转义。我们在双引号两边使用比文本更多的井号来标识字符串的终止。
编写完了main.rs,我们可以运行cargo run
命令来完成运行所需要的任务:获取所需crate、编译包、构建程序、链接包及启动服务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
$ 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_urlencoded v0.6.1 Compiling actix-router v0.1.5 Compiling actix-http v0.2.11 Compiling awc v0.2.8 Compiling actix-web v1.0.9 Compiling actix-gcd v0.1.0 (/Users/alan/Desktop/demo/Rust/actix-gcd) Finished dev [unoptimized + debuginfo] target(s) in 1m 09s Running `target/debug/actix-gcd` Serving on http://localhost:3000... |
此时,我们就可以在浏览器中访问所给定的URL,查看到图2-1中所显示的页面。
不过点击Compute GCD还没效果,只是会跳转到一个空页面。下面就来修改这个问题,对App
添加一个路由处理来自表单的POST
请求。
终于要使用Cargo.toml文件中所列出的serde
包了:它提供了一个方便的工作协助我们处理表单数据。首先,我们需要在src/main.rs的顶部添加如下use
指令:
1 |
use serde::Deserialize; |
Rust程序员通常将use
声明放在文件顶部,但并没有严格的要求:Rust允许声明使用任意的排序,只要嵌套层级正确即可。
接下来,我们定义表示获取表单值的Rust结构体类型:
1 2 3 4 5 |
#[derive(Deserialize)] struct GcdParameters { n: u64, m: u64, } |
以上定义了一个名为GcdParameters
的新类型,包含两个字段n
和m
,均为u64
,即gcd
函数所需的参数类型。
struct
上方的注解为属性,类似前面标记测试函数的#[test]
属性。在类型定义上方加#[derive(Deserialize)]
属性告知serde
包在程序编译时检查该类型,自动生成解析POST
请求HTML表单格式数据值的代码。其实这个属性可从各种数据结构中解析出GcdParameters
的值:JSON、YAML、TOML或其它任意文本和二进制格式。serde
包还提供了Serialize
属性可生成反向的代码,将Rust值写入到结构化格式中。
定义好结构体,我们就可以轻松编写处理函数了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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<GcdParameters>
。Actix 从HTTP请求中提取web::Form<T>
任意类型的条件只需要是T
可在HTML表单POST
数据中反序列化。因为我们对GcdParameters
类型声明添加了#[derive(Deserialize)]
属性,Actix可从数据中对其反序列化,因此请求处理函数可以使用web::Form<GcdParameters>
值作为参数。类型与函数的关联在编译期间完成,如果处理函数使用了Actix无法处理的参数类型,Rust编译器会马上告诉你错误。
进入post_gcd
,该函数在其中一个参数为0时返回400 BAD REQUEST
错误,因为gcd
函数在接收到0时会panic。然后使用format!
宏构造了一个对请求的响应。format!
宏类似于println!
宏,区别在于它不是对标准输出写文本,而是将其返回为字符串。一旦获取到了响应文本,post_gcd
会将其封装到一个HTTP 200 OK
响应中,设置内容类型并将其返回给发送者。
我们还需要将post_gcd
注册为表单的处理方法。替换main
函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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 to address") .run() .expect("error runing server"); } |
这里唯一的变化是添加了一个route
调用,为"/gcd"
路径建立了一个web::post().to(post_gcd
处理方法。
最后只差之前编写的gcd
函数了,将其放到actix-gcd/src/main.rs文件中。加好后可以中断此前运行的服务,重新构建、重启程序:
1 2 3 4 5 |
$ cargo run Compiling actix-gcd v0.1.0 (/Users/alan/Desktop/demo/Rust/actix-gcd) Finished dev [unoptimized + debuginfo] target(s) in 2.79s Running `target/debug/actix-gcd` Serving on http://localhost:3000... |
此时访问http://localhost:3000,输入一些数字,再点击Compute GCD按钮,就可查看结果了(图2-2)。
图2-2:显示计算最大公约数结果的网页
并发
Rust的一大优势是对于并发编程的支持。Rust的免内存错误保障确保了线程的内存共享不会产生数据抢占。例如:
- 如果使用互斥体来协调对共享数据结构做出修改的线程,Rust确保只有在持有锁的时候才能访问数据,在使用完成后自动释放锁。在C和C++中互斥体及其保护数据之间的关系留待评论吧。
- 如果希望在线程间共享只读数据,Rust会确保数据不会被误修改。在C和C++中,类型系统会有所作用,但很容易出错。
- 如果将数据结构的所有权由一个线程传递给另一个线程,Rust会确保真的清除了对其的所有访问。在C和C++中,检测发送线程不再碰数据需由程序员完成。如果稍有不慎,结果会取决于处理器缓存的处理以及最近对内存写入了多少次。苦不堪言。
本节中我们一起编写第二个多线程程序。
我们已经写了第一个程序:使用Actix web框架实现最大公约数服务端,它使用了线程池来运行请求处理函数。如果服务端接收到并发请求,可能会在多个线程中同时运行get_form
和post_gcd
函数。你能会惊讶于在编写这些函数时我们并没有考虑到并发问题。但Rust保障了安全性,不管服务端有多复杂:如果程序完成编译,就没有数据抢占的问题。Rust的所有函数都是线程安全的。
本节的程序绘制曼德勃罗(Mandelbrot)集,一个对复数简单函数进行迭代生成的几何图形。绘制Mandelbrot集通常称为易并行计算(embarrassingly parallel ),因为其线程间的通讯模式很简单。我们会在第19章中讨论复杂模式,但此处只演示一些基本用法。
首先我们创建一个新Rust项目:
1 2 3 |
$ cargo new mandelbrot Created binary (application) `mandelbrot` package $ cd mandelbrot |
所有代码都放在mandelbrot/src/main.rs中,我们会在mandelbrot/Cargo.toml中添加一些依赖。
在实现并发Mandelbrot集前,我们需要描述下将要执行的运算。
Mandelbrot集究竟是什么
在阅读代码时,最好能对所实现的内容有深入了解,所以我们来浅述一下纯数学知识。先从简单的例子开始然后不断添加复杂的细节直至触及Mandelbrot集运算的核心。
这里有一个无限循环,使用Rust的语法编写,一个loop
语句:
1 2 3 4 5 |
fn square_loop(mut x: f64) { loop { x = x * x; } } |
在真实场景中,Rust会知道从未使用过x
,因此不会计算其值。在现在假定代码运行了。x
的值会发生什么变化?对小于1的数字取平方值会越来越小,因此无限接近于0;对1取平方值仍为1;对大于1的数据取平方值会越来越大,无限接近正无穷;对负数取平方值会变成正值,结果与前面的情况一致(图2-3)。
图2-3:反复对数字取平方值的效果
因此根据对square_loop
所传递的值,x
会保持为0或1、无限接近0亦或无限接近正无穷。
接下来对循环做些许调整:
1 2 3 4 5 6 |
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
包提供了复数类型的支持,因此必须在程序的Cargo.toml 文件[dependencies]
下添加一行num
。该文件当前的完整内容如下(我们会不断新增):
1 2 3 4 5 6 7 8 9 |
[package] name = "mandelbrot" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] num = "0.4" |
现在我们编写倒数第二个版本的循环:
1 2 3 4 5 6 7 8 |
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
包的Complex
类型编写复数零值的方式。Complex
是Rust的结构体类型(或struct),定义如下:
1 2 3 4 5 6 |
struct Complex<T> { /// Real portion of the complex number pub re: T, /// Imaginary portion of the complex number pub im: T, } |
以上代码定义了结构体Complex
,有两个字段,re
和im
。Complex
是一种泛型结构体,可以把类型名称后的<T>
看成是“任意类型T
”。例如,Complex<f64>
是一个re
和im
都是f64
类型的复数,Complex<f32>
则使用32位浮点数,以此类推。基于这个定义,Complex { re: 0.24, im: 0.3 }
这样的表达式会生成一个Complex
值,其中re
字段初始化为0.24,而im
字段初始化为0.3。
num
包处理了对复数值的*
、+
及其它算术运算符,所以其它函数都像先前版本一样,只是它操作的是复数平面上的点,而不只是实部数字线上的点。我们会在第12章中讨论Rust是如何对自定义类型使用运算符的。
最后,我们到达了纯数学之旅的终点。Mandelbrot集由复数集c
进行定义,z
不接近无穷大。我们的原始平方循环很容易预测:任意大于1或小于-1的数字都会趋近无穷。对每次迭代+ c
让行为更难预测:前面已经说到,如果c
的值大于0.25或小于–2会让z
值膨胀。但将其扩展为复数会生成奇异而美丽的图形,这就是我们要绘制的。
因为复数c
具有实部和虚部c.re
和c.im
,我们将其看成笛卡尔平面某个点的x
和y
坐标,如c
位于Mandelbrot集中点为黑色,否则为浅色。因此对图像中的每个像素,我们都需要对复数平面的相应点执行前述循环,看它是逃逸到无穷大还是围绕着原点,并相应地上色。
无限循环需要花很久运行,但对于耐心有限的人有两个小技巧。首先,我们不进行无限循环而是进行限定数量的迭代,我们仍然可得到很好的集合估值。要迭代多少次取决于我们希望绘制边界的精确度。另外据发现,如果z
离开以原点为中心半径为2的圆之外,最终一定会离原点无限远。因此以下是循环的最终版本,以及程序的核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
/// 尝试决定`c`是否位于Mandelbrot集中,最多进行`limit`次来作出决策。 /// /// 如果`c`不是成员,返回`Some(i)`,其中`i`为`c`离开以原点为中心半径为2区域所需的次数。 /// 如果`c`是成员(更确切的说是如果迭代了limit次后还无法证明`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
我们希望测试是否为Mandelbrot集成员,接收的limit为在放弃迭代并声明c
可能为其成员的上限迭代次数。
函数的返回值为Option<usize>
。Rust标准库定义Option
类型如下:
1 2 3 4 |
enum Option<T> { None, Some(T), } |
Option
为枚举类型,通常称为enum,因其定义枚举此类型可能值的多个变体:对于任意类型T
,Option<T>
类型的值要么是Some(v)
,其中v
是类型为T
的值,要么是None
,表示没有T
值。和前面讨论的Complex
类型一样,Option
是一个泛型:可以使用Option<T>
来表过任意类型T
的值。
本例的escape_time
返回Option<usize>
,表明 c
是否在Mandelbrot集中,以及如不在需要迭代多少次来查询。如果c
不在集中,escape_time
返回Some(i)
,其中i
是对z
进行迭代确定是否在半径为2的圆之外的次数。否则的话,c
在集中,escape_time
返回None
。
1 |
for i in 0..limit { |
前面的例子展示过for
循环对命令行参数和向量元素进行迭代,此处的for
循环仅对从0
到limit
(不含)的整数区间进行迭代。
z.norm_sqr()
方法调用方法返回从原点到z
距离的平方。要决定z
是否离开半径为2的圆,我们没有计算平方根,只是拿平方距离与4.0进行比对,这样更快。
读者可能注意到我们使用///
来标记函数定义上方的注释行,Complex
结构体成员上方的注释也以///
开头。它们是文档注释,rustdoc
工具可同时解析它们及所描述的代码,生成在线文档。Rust标准库的文档就是这么写的。我们会在第8章中讲解文档注释。
剩余的代码用于决定以什么分辨率绘制点集的哪一部分,并将任务分布到多个线程中来加速运算。
解析坐标对命令行参数
程序接收多个命令行参数控制所写图像的分辨率以及图像显示哪部分Mandelbrot集。因为这些命令行参数都遵循统一的格式,解析函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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,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
是一个泛型函数:
1 |
fn parse_pair<T: FromStr>(s: &str, separator: char) -> Option<(T, T)> { |
<T: FromStr>
语句应读为:对于任意实现了FromStr
接口的类型T
。这让我们可以一次性定义整个函数组:parse_pair::<i32>
是解析i32
值对的函数,parse_pair::<f64>
是解析浮点值对的函数等等。这很像是C++里的函数模板。Rust程序员会称T
为parse_pair
的类型参数。在使用泛型函数时,Rust通常能自己推导出类型参数,无需像测试代码中那样写出来。
返回类型为Option<(T, T)>
:为None
或Some((v1, v2))
,其中(v1, v2)
是包含两个值的元组,类型都是T
。parse_pair
函数没有使用显式的返回语句,所以其返回值是函数体中的最后一个(也是唯一一个)表达式:
1 2 3 4 5 6 |
match s.find(separator) { None => None, Some(index) => { ... } } |
String
类型的find
方法在字符串中搜索匹配separator
的字符。如果find
返回None
,表示字符串中没有分隔字符,整个match
表达式的结果为None
,表示解析失败。否则我们拿到分隔符在字符串中的索引下标。
1 2 3 4 |
match (T::from_str(&s[..index]), T::from_str(&s[index + 1..])) { (Ok(l), Ok(r)) => Some((l, r)), _ => None } |
这里展示了match
表示式的强大。匹配的参数为以下元组表达式:
1 |
(T::from_str(&s[..index]), T::from_str(&s[index + 1..])) |
表达式&s[..index]
和&s[index + 1..]
字符串的切片,分列分隔符前后。类型参数T
的关联函数from_str
分别接收这两个切片并尝试将它们解析为类型T
的值。所进行的匹配如下:
1 |
(Ok(l), Ok(r)) => Some((l, r)), |
这一模式仅在元组元素为Result
类型的Ok
变体时才进行匹配,表明两者的解析都成功了。如果解析成功,匹配表达式的值为Some((l, r))
,因此也是函数的返回值。
1 |
_ => None |
通配模式_
匹配任意内容,忽略其值。如果运行到这里,parse_pair
就失败了,因此结果为None
,同样也是函数的返回值。
既然已经有了parse_pair
,就可以很容易地编写解析浮点坐标对的函数并作为Complex<f64>
返回值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
/// 解析一对以逗号分隔的浮点数值为复数。 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的标记法。
将像素与复数进行映射
程序需要在两个关联坐标空间中运行:输出图像的每个像素对应复数平面中的一个点。这两个空间的关联与我们所要绘制的Mandelbrot集的区间以及命令行参数所决定的图像像素相关。以下函数将图片空间转化为复数空间:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
/// 给定输出图像中像素的行列,返回复数平面中对应的点。 /// /// `bounds`按像素给定图像的宽高。 /// `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
所执行的运算。
pixel_to_point
的代码为简单运算,这里不再详述。但是有一些点需要指出。这种形式的表达式指向元组的元素:
1 |
pixel.0 |
它指向的是元组pixel
的第一个元素。
1 |
pixel.0 as f64 |
这是Rust类型转换语法:将pixel.0
转换为f64
值。不同于C和C++,Rust通常禁止数值类型之间的隐式转换,必须写出所要做的转换。可能很麻烦,但显式描述转换的类型和时机非常有帮助。隐式整型转换看似没毛病,但从历史看它们经常导致C和C++真实代码的bug和安全漏洞。
图2-4:复杂平面与图像像素之间的关系
绘制点集
绘制Mandelbrot集,对图像中的每个相应我们只需对复数平面中的每个点应用escape_time
,像素颜色由其结果决定:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
/// 将Mandelbrot集的矩形渲染为像素缓冲。 /// /// `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, }; } } } |
下面的代码读者应该很熟悉了。
1 2 3 4 5 |
pixels[row * bounds.0 + column] = match escape_time(point, 255) { None => 0, Some(count) => 255 - count as u8 }; |
escape_time
如判断point
属于点集,render
就会将像素上为黑色(0
)。否则,render
会对逃离圆圈用时更久的数赋更深的颜色。
写图像文件
image
包提供了读取和写大量格式图像的函数,以及一些基本图像操作函数。具体来说,它包含有PNG图像文件格式的编码器,本例程序使用它来保存运算的结果。要使用image
包,需要在Cargo.toml文件的[dependencies]
下加入如下代码:
1 |
image = "0.13.0" |
添加完之后,可以写如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
use image::png::PNGEncoder; use image::ColorType; use std::fs::File; /// 写缓冲`pixels`,大小由`bounds`指定, 文件名为`filename`。 fn write_image( filename: &str, pixels: &[u8], bounds: (usize, usize), ) -> Result<(), std::io::Error> { let output = File::create(filename)?; let encoder = PNGEncoder::new(output); encoder.encode(pixels, bounds.0 as u32, bounds.1 as u32, ColorType::Gray(8))?; Ok(()) } |
这一函数的操作非常明了:打开一个文件然后尝试写入图像。我们对编码器传递了pixels
中的实际像素数据,以及bounds
中的宽和高,以及用于解析pixels
中字节的最后那个参数:ColorType::Gray(8)
值表示每个字节是一个8比特的灰度值。
这都很容易理解。要关注的是函数处理错误的方式。在遇到错误时,我们需要向调用者报错。前面提到过,Rust中会出错的函数要返回一个Result
值,成功时为Ok(s)
,其中s
为成功值,或是在失败是返回Err(e)
,e
为错误码。那么write_image
的成功值和错误类型是什么呢?
一切顺利的话,write_image
函数没有什么要返回的值,它将数据写入到文件中。因此其成功类型为单元类型()
,这么叫是因为它仅一个值,也写作()
。单元类型与C和C++中的void
相似。
在出错时,原因可能是File::create
无法创建文件或是encoder.encode
无法在文件中写入图像,I/O操作返回一个错误码。File::create
的返回类型是Result<std::fs::File, std::io::Error>
,而encoder.encode
的返回类型是Result<(), std::io::Error>
,所以两者共享同一错误类型std::io::Error
。write_image
函数同样返回该类型合情合理。在这两种情况下,应立即返回错误,传递描述产生错误的std::io::Error
值。
所以要正常处理File::create
的结果,我们匹配其返回值,如下:
1 2 3 4 5 6 |
let output = match File::create(filename) { Ok(f) => f, Err(e) => { return Err(e); } }; |
成功时output
为携带Ok
值的File
。出错时,将错误传递给调用方。
Rust中此类match
语句很常见,语言提供了一个?
运算符作为整个内容的简写。因此在可能出错时我们不需要每次显式地写出这段逻辑,可以使用同等效果、可读性更强的语句:
1 |
let output = File::create(filename)?; |
如果File::create
失败,?
运算符从write_image
中返回,传递错误。否则,output
持有成功打开的File
。
注:初学者常见的错误是在
main
函数中使用?
。但因为main
本身并不返回值,它不会生效;应当使用match
语句,或是像unwrap
和expect
这样的简写方法。还有一种选择,将main
修改为返回Result
,在稍后会讲到。
Mandelbrot的并发程序
所有的准备都已完成,我们可以开始编写main
函数了,我们会在其中用到并发。首先是一个简单的非并发版本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
use std::env; fn main() { let args: Vec<String> = env::args().collect(); if args.len() != 5 { eprintln!("Usage: {} FILE PIXELS UPPERLEFT LOWERRIGHT", args[0]); eprintln!( "Example: {} mandel.png 1000x750 -1.20,0.35 -1,0.20", args[0] ); std::process::exit(1); } let bounds = parse_pair(&args[2], 'x').expect("解析图像尺寸出错"); let upper_left = parse_complex(&args[3]).expect("解析左上角点出错"); let lower_right = parse_complex(&args[4]).expect("解析右下角点出错"); let mut pixels = vec![0; bounds.0 * bounds.1]; render(&mut pixels, bounds, upper_left, lower_right); write_image(&args[1], &pixels, bounds).expect("写入PNG文件出错"); } |
在将命令行参数汇总至String
向量后,我们逐一进行解析然后开始运算。
1 |
let mut pixels = vec![0; bounds.0 * bounds.1]; |
宏调用vec![v; n]
创建一个元素长度为n
的向量,元素初始化到v
中,所以以上代码创建一个由零组成的向量,长度为bounds.0 * bounds.1
,bounds
是从命令行中解析的图像尺寸。我们将这个向量用作单字节灰度像素值的矩形数组,参见图2-5。
下一行要讨论的代码是:
1 |
render(&mut pixels, bounds, upper_left, lower_right); |
它调用render
函数来实际计算图像。&mut pixels
表达式从像素缓冲借用一个可变指针,允许render
对其填充运算后的灰度值,虽然此时pixels
仍是向量的所有者。剩下的参数传递图像的尺寸以及我们选择绘制的复数平面矩形区。
1 |
write_image(&args[1], &pixels, bounds).expect("写入PNG文件出错"); |
图2-5:将向量用作像素矩形数组
最后,我们将像素缓冲以PNG文件写入至磁盘。本例中我们将共享(不可变)指针传递给缓冲,因为write_image
无需修改缓冲中的内容。
至此,我们可以发布模式构建、运行程序,这种模式启用很多强大的编译器优化,数秒后,就会在mandel.png文件中写入一张漂亮的图片:
1 2 3 4 5 6 7 8 9 10 11 |
$ cargo build --release Compiling autocfg v1.1.0 ... Compiling image v0.13.0 Compiling mandelbrot v0.1.0 (/Users/alan/Desktop/demo/Rust/mandelbrot) Finished release [optimized] target(s) in 26.29s AlansMac:mandelbrot alan$ time target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20 real 0m5.120s user 0m4.604s sys 0m0.035s |
该命令创建一个名为mandel.png的文件,通过系统的图片浏览程序或浏览器可进行查看。如果一切顺利,可看到图2-6这样的图片。
图2-6:并行Mandelbrot程序运行结果
在上面的命令中,我们使用了Unix的time
程序来分析程序运行时长:整体大约花了5秒钟来运行对图像每个像素的Mandelbrot运算。但所有的现代机器都有多处理器,这个程序只用到了单核。我们可以将任务分布到机器提供的所有计算资源上,就能更快地生成图片。
为此,我们对图像分区,每个处理器一块,并让一个处理器为像素着色。为进行简化,我们将其分成横向区块,参见图2-7。在所有处理器都完成之后,我们可以将像素写入磁盘。
图2-7:将像素缓冲分区进行并行渲染
crossbeam
包提供了很多有价值的并发工具,包含此处所需的作用域线程工具。需要在Cargo.toml文件中添加如下内容方能使用:
1 |
crossbeam = "0.8" |
之后我们需要拿出调用render
的那一行,替换为如下内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
let threads = 8; let rows_per_band = bounds.1 / threads + 1; { let bands: Vec<&mut [u8]> = pixels.chunks_mut(rows_per_band * bounds.0).collect(); crossbeam::scope(|spawner| { for (i, band) in bands.into_iter().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); }); } }) .unwrap(); |
老规矩逐行分解:
1 2 |
let threads = 8; let rows_per_band = bounds.1 / threads + 1; |
这里我们决定使用8个线程。然后计算每一块有多少行。我们将行数量增加了一点以确保在行高不是threads
的倍数时也能覆盖整个图像。
num_cpus
包提供了一个函数可返回当前系统的可用CPU数。
1 |
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
库:
1 2 3 |
crossbeam::scope(|spawner| { ... }).unwrap(); |
参数|spawner| { ... }
是需要单个spawner
参数的Rust闭包。注意不同于通过fn
所声明的函数,我们不需要声明闭包参数的类型。Rust会根据返回值推导出其类型。本例中crossbeam::scope
调用该闭包,传递的spawner
参数值可供闭包用于新建线程。crossbeam::scope
等待所有这些线程执行完成再返回。这种行为可使Rust保障线程不会访问作用域外的pixels
分块,也保障了我们在crossbeam::scope
返回时图像的运算也完成了。如果不出问题,crossbeam::scope
返回Ok(())
,但如果任一线程panic了,会返回Err
。我们对Result
调用了unwrap
,这样出错后我们的程序也会panic,报告给用户。
1 |
for (i, band) in bands.into_iter().enumerate() { |
这里我们遍历了像素缓冲的分片。into_iter()
迭代器在循环体每次迭代时会授予它某一分片的排他所有权,这样就保证了一次只有一个线程对其写入。我们会在第5章中讲解其原理。接着,enumerate
适配器生成向量元素及其索引的元组对。
1 2 3 4 5 6 7 8 |
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
函数来查看分片在复数平面的左上角和右下角位置。
1 2 3 |
spawner.spawn(move |_| { render(band, band_bounds, band_upper_left, band_lower_right); }); |
最后我们创建了一个线程,运行闭包move |_| { ... }
。最前面的move
关键字表示这个闭包接管所使用变量的所有权,也就是只有闭包能使用这个可变分片band
。参数列表|_|
表示闭包接收一个参数,但不使用(另一个生成内嵌线程的spawner)。
前面也提到了,crossbeam::scope
调用保障在返回前所有线程均已完成,也就是将图像保存到文件中是安全的,这正是下一步操作。
运行Mandelbrot绘图工具
我们在程序中使用了多个外部crate:num
用于复数数值运算,image
用于写PNG文件,crossbeam
用于作用域线程创建原语。最终的Cargo.toml 文件包含所有这些依赖:
1 2 3 4 5 6 7 8 9 |
[package] name = "mandelbrot" version = "0.1.0" edition = "2021" [dependencies] num = "0.4" image = "0.13" crossbeam = "0.8" |
准备好这个之后,就可以构建、运行程序了:
1 2 3 4 5 6 7 8 9 10 |
$ cargo build --release Compiling crossbeam-queue v0.3.5 Compiling crossbeam v0.8.1 Compiling mandelbrot v0.1.0 (/Users/alan/Desktop/demo/Rust/mandelbrot) Finished release [optimized] target(s) in 1.66s $ time target/release/mandelbrot mandel.png 4000x3000 -1.20,0.35 -1,0.20 real 0m1.754s user 0m4.693s sys 0m0.016s |
这里我们再次使用了time
程序运行所花的时间,注意虽然仍花费了差不多5秒的处理器时间,实际用时只有1点几秒。可以注释掉写图像文件的代码并重新度量来验证其耗时。在测试这段代码的笔记本上,并发版本将Mandelbrot的运算时间减少了近4倍。我们会在第19章中展示如何进行大幅改进。
和之前一样,这段程序会创建一个mandel.png文件。使用这个快速版,可以更轻松地修改命令行参数来研究Mandelbrot集。
安全无形
最后的并发程序与其它语言中编写的并没有太大差别:我们将像素缓冲分配到各个处理器中,让每个处理器处理一个分片,然后在都完成时,展示结果。那么Rust对并发的支持有什么特别之处呢?
这里没有展示那么不能用Rust编写的程序。本章所学的代码将缓冲正确地分离到线程中,但还很多不正确的写法(进而导致数据争抢),这些写法都无法通过Rust编译器的静态检查。C或C++编译器很乐于协助你探索存在数据争抢可能的大量程序;Rust在可能出错时一开始就告诉你。
在第4章和第5章中,我们会讲解Rust的内存安全规则。第19章中会讲解这些规则如何保障并发安全。
文件系统和命令行工具
Rust在命令行工具的世界里找到了重要的缺口。作为一款现代、安全且快速的系统编程语言,它为程序员提供了一个工具箱,可用于通过复制或扩展已有工具的功能组装豪华的命令行界面。例如bat
命令提供了语法高亮的cat
替代工具,并内置对分布工具的支持,hyperfine
可自动对可通过命令或管道运行的内容进行benchmark。
虽然那么复杂的实现不在本书讨论范畴,Rust让我们很容易涉足符合人类工程学的命令行应用世界。本节中我们展示如何构建自己的搜索替换工具,带彩色输出和友好的错误消息。
先新建一个Rust项目:
1 2 3 |
$ cargo new quickreplace Created binary (application) `quickreplace` package $ cd quickreplace |
我们的程序会使用到两个crate:用于在终端中创建彩色输出的text-colorizer
以及实现实际搜索替换功能的regex
。和前面一样,我们将这些包放到Cargo.toml中,告诉cargo
我们需要使用:
1 2 3 4 5 6 7 8 9 10 |
[package] name = "quickreplace" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] text-colorizer = "1" regex = "1" |
像上面这样到达1.0
版本的Rust包,都遵循语义化版本规范:在大版本号1
改变之前,所有的新版本应兼容此前的版本。因此如果我们的程序测试过某个包的1.2
版本,那么使用1.3
、1.4
等版本同样应该正常;但2.0
版本可引入一些不兼容的修改。在Cargo.toml 文件中请求包的"1"
版本时,Cargo会使用2.0
版本之前的最新包。
命令行界面
本程序的界面相当简单。它接收4个参数:一个用于搜索的字符串(或正则表达式)、一个用于替换的字符串(或正则表达式)、输入文件名称以及输出文件名称。我们先在main.rs 文件中添加包含如下参数的结构体:
1 2 3 4 5 6 7 |
#[derive(Debug)] struct Arguments { target: String, replacement: String, filename: String, output: String, } |
#[derive(Debug)]
属性告诉编译器生成一些代码让我们可以在println!
中通过{:?}
格式化Arguments
结构体。
在用户输入错误数量的参数时,通常会打印出如何使用程序的简短说明。我们会使用一个简单函数print_usage
来实现,并导入text-colorizer
的所有内容,这样就可以添加一些颜色了:
1 2 3 4 5 6 7 8 9 |
use text_colorizer::*; fn print_usage() { eprintln!( "{} - 将一个字符串替换为另一个字符串", "quickreplace".green() ); eprintln!("Usage: quickreplace <target> <replacement> <INPUT> <OUTPUT>"); } |
只需在字符串字面量结尾添加.green()
就可以生成以相应ANSI转义代码封装的字符串,来在模拟终端中显示为绿色。在打印前该字符串会插入消息中。
下面就可以收集并处理程序的参数了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
use std::env; fn parse_args() -> Arguments { let args: Vec<String> = env::args().skip(1).collect(); if args.len() != 4 { print_usage(); eprintln!( "{} 参数数量不符:需要4个参数,传入了{}个。", "错误:".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
结构体中返回。
然后我们添加main
函数,调用parse_args
并打印输出:
1 2 3 4 |
fn main() { let args = parse_args(); println!("{:?}", args); } |
此时我们可以运行程序查看是否打印正确的错误消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
$ cargo run Compiling lazy_static v1.4.0 Compiling regex-syntax v0.6.26 Compiling libc v0.2.126 Compiling memchr v2.5.0 Compiling atty v0.2.14 Compiling aho-corasick v0.7.18 Compiling colored v2.0.0 Compiling text-colorizer v1.0.0 Compiling regex v1.5.6 Compiling quickreplace v0.1.0 (/Users/alan/Desktop/demo/Rust/quickreplace) Finished dev [unoptimized + debuginfo] target(s) in 6.75s Running `target/debug/quickreplace` quickreplace - 将一个字符串替换为另一个字符串 Usage: quickreplace <target> <replacement> <INPUT> <OUTPUT> 错误: 参数数量不符:需要4个参数,传入了0个。 |
如果给程序传一些参数,则会打印出Arguments
结构体的内容:
1 2 3 4 |
$ 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
结构体中。
读写文件
接着我们需要从文件系统实际获取数据,这样才能对其进行处理,并在完成后回写。Rust有一套健全的输入输出工具集,但标准设计者知道读写文件很常用,所以他们让特意进行了简化。我们只需导入一个模块std::fs
,即可使用read_to_string
和write
函数。
1 |
use std::fs; |
std::fs::read_to_string
返回Result<String, std::io::Error>
。如果函数成功执行,生成一个String
。如果失败,生成std::io::Error
,这是标准库表示I/O问题的类型。类似地,std::fs::write
返回Result<(), std::io::Error>
:成功时不返回,出错时返回同样的错误信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
fn main() { let args = parse_args(); let data = match fs::read_to_string(&args.filename) { Ok(v) => v, Err(e) => { eprintln!( "{} 读取文件'{}'失败: {:?}", "错误:".red().bold(), args.filename, e ); std::process::exit(1); } }; match fs::write(&args.output, &data) { Ok(_) => {} Err(e) => { eprintln!( "{} 读取文件'{}'失败: {:?}", "错误:".red().bold(), args.filename, e ); std::process::exit(1); } } } |
此处,我们使用了此前编写的parse_args()
函数并将结果文件名传递给了read_to_string
和write
。这些函数输出的match
语句优雅地处理了错误,打印文件名,所提供的错误原因,以及添加颜色获取用户的注意。
使用更新的main
函数运行程序查看结果,当然新老文件的内容完全相同:
1 2 3 4 |
$ cargo run "find" "replace" Cargo.toml Copy.toml Compiling quickreplace v0.1.0 (/Users/alan/Desktop/demo/Rust/quickreplace) Finished dev [unoptimized + debuginfo] target(s) in 0.50s Running `target/debug/quickreplace find replace Cargo.toml Copy.toml` |
程序确实读取了输入文件Cargo.toml,,也确实写入了输入文件Copy.toml,,但因为我们还没有写查找替换的代码,输出不会有变化。我们可以通过运行检测不同的diff
命令进行查看
1 |
$ diff Cargo.toml Copy.toml |
查找及替换
对该程序的最后修饰是实现实际的功能:查找和替换。为此需要使用到编译和执行正则表达式的regex
包。它提供一个名为Regex
的结构体,表示编译后的正则表达式。Regex
有一个replace_all
方法,功能和名称一样:搜索字符串中正则表达式的所有匹配项,全部替换为给定的字符串。我们可以可这个逻辑抽到一个函数中:
1 2 3 4 5 |
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
所提供的错误类型。
Regex::new
编译用户所提供的正则表达式,如果是无效字符串则失败。和Mandelbrot程序中一样,我们使用?
简化Regex::new
的失败情况,但本例中函数返回一个regex
包自有的错误类型。编译完正则表达式,replace_all
方法会text
中的所有匹配项替换为给定的替换字符串。
replace_all
若查找到匹配项,它返回一个使用给定文本替换匹配项后的新字符串。否则replace_all
返回原文本的指针,避免不必要的内存分配和拷贝。但在例中,我们总是希望有独立的拷贝,因此使用了to_string
方法来获取一个String
,像其它函数一样对该字符串使用Result::Ok
封装后返回。
下面在main
代码中集成新函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
fn main() { let args = parse_args(); let data = match fs::read_to_string(&args.filename) { Ok(v) => v, Err(e) => { eprintln!( "{} 读取文件'{}'失败: {:?}", "错误:".red().bold(), args.filename, e ); std::process::exit(1); } }; let _replace_data = match replace(&args.target, &args.replacement, &data) { Ok(v) => v, Err(e) => { eprintln!("{} 替换文本失败: {:?}", "错误:".red().bold(), e); std::process::exit(1); } }; match fs::write(&args.output, &data) { Ok(_) => {} Err(e) => { eprintln!( "{} 读取文件'{}'失败: {:?}", "错误:".red().bold(), args.filename, e ); std::process::exit(1); } } } |
完成了最后的修改,就可以测试程序了:
1 2 3 4 5 6 7 |
$ echo "Hello, world" > test.txt $ cargo run "world" "Rust" test.txt test-modified.txt Compiling quickreplace v0.1.0 (/Users/alan/Desktop/demo/Rust/quickreplace) Finished dev [unoptimized + debuginfo] target(s) in 0.62s Running `target/debug/quickreplace world Rust test.txt test-modified.txt` $ cat test-modified.txt Hello, world |
当然错误处理也已就位,优雅地对用户报错:
1 2 3 4 5 6 7 8 9 10 11 |
$ 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 test-modified.txt` 错误: 替换文本失败: Syntax( ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ regex parse error: [[a-z] ^ error: unclosed character class ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ) |
这个简单的演示程序自然还需要很多功能,但已有了基本功能。我们学习了如何读写文件、传递和显示错误以及在终端中为输出添加颜色提升用户体验。
未来的章节会探讨应用开发更高级的技巧,有数据集合、迭代器函数式编程乃至实现极度高效并发的异步编程技术,但首先我们要学习下一章建立Rust基本数据类型的坚实基础。