【Rust编程第2版】第2章 Rust编程语言之旅

Coding Alan 2年前 (2022-06-17) 1330次浏览 0个评论 扫描二维码

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个命令可供使用:

以上的$为命令行提示符,在Windows系统中类似C:\>。上面的代码中我们运行了三条刚刚安装的命令,打印出各条命令的版本。这3个命令依次是:

  • cargo是Rust的编译管理器、包管理器和通用工具。可以使用Cargo开启一个新项目、构建和运行程序,以及管理代码所依赖的外部库。
  • rustc是Rust的编译器。通常我们可以让Cargo为我们调用编译器,但有时直接运行也有其用途。
  • rustdoc是Rust的文档工具。如果在源代码中按所需格式在注释中编写文档,rustdoc可以通过注释构建出美观的HTML。类似于rustc,通常我们可以让Cargo调用rustdoc

为方便起见,Cargo可为我们新建Rust包,包含一些编排好的标准元数据:

这条命令会新建一个名为hello的包目录,可用于构建命令执行程序。

查看该包的顶级目录:

可以看到Cargo创建了Cargo.toml 文件,其中存储包的元数据。现在该文件中的内容不多:

如果程序需要依赖其它库,我们可以在这个文件中进行记录,Cargo会处理对这些库的下载、构建和升级。在第8章中会详细讲解Cargo.toml 。

Cargo使用git版本控制系统来构建包,创建一个.git元数据目录和一个.gitignore文件。可以在运行cargo new命令时传递--vcs none来告诉Cargo跳过这一步。

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

Cargo已经代我们编写了一个程序。main.rs中的内容如下:

在Rust中,我们甚至不用写自己的“Hello, World!”程序。这就是Rust基本程序,两个文件,十多行代码。

可以在包内的任意路径执行cargo run命令运行程序:

这里Cargo调用了Rust编译器rustc,然后运行其生成的可执行文件。Cargo将可执行文件放到了包顶级目录的target子目录下:

测试完成后,可通过Cargo清理所生成这些文件:

Rust的函数

Rust刻意没有独创语法。如果读者熟悉C、C++、Java或JavaScript任意一门编程语言,就可以很容易了解Rust程序的基本结构。以下是计算两个整数最大公约数的函数,使用了欧几里得算法。可以在src/main.rs的最后添加以下代码:

fn关键字(读作fun)开启了一个函数。此处我们将函数命名为gcd,它接收两个参数nm,类型均为u64,无符号64位整形。->后接返回类型:这个函数返回一个u64值。Rust的标准样式是4个空格的缩进。

Rust的机器整型名称反映了其大小及符号:i32为有符号32位整型。u8是无符号8位整型(用于byte类型)等等。isizeusize类型存储指针大小的有符号和无符号整理,32位的平台上为32位,64位平台上为64位。Rust还有两个浮点类型:f32f64,是IEEE单精度和双精度浮点类型,和C与C++中一样。

默认变量一旦初始化,其值就无法修改,但在参数nm之前加一个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的类型。在以上的函数中,要匹配mnt只能是u64类型。Rust只在函数体中进行类型推导:必须要对函数参数和返回值加上类型,参见上例。如果想写t的类型,可以这么写:

Rust有return语句,但gcd函数不需要使用它。如果函数体的最后一个表达式不以分号结尾,那就是函数的返回值。使用花括号包裹的任意代码块都可作为表达式。例如,以下是打印一条消息、然后运算x.cos()作为其值的表达式:

在Rust中使用这种形式在函数运行至结尾时形成函数的值很常见,只有在函数中间需要提早返回时才使用return语句。

编写及运行单元测试

Rust语言中集成了测试,使用简单。测试所开发的gcd函数,我们可以在src/main.rs的末尾添加这段代码:

此处定义了一个函数test_gcd,其中调用了gcd并检测返回值是否正确。函数定义上方的#[test]test_gcd标记为测试函数,在正常的编译中会跳过,但在使用cargo test命令执行程序时会自动包含并调用。可将代码函数分散到源码目录中,放在所测试代码的旁边,cargo test会自动将它们汇聚起来并全部执行。

#[test]标记是一种属性。属性是一套使用附加信息标记函数和其它声明的非闭口体系,和C++、C#的属性或Java中的注解类似。用于控制编译器警告和代码样式检查,包含代码条件(类似C和C++的#ifdef),告诉Rust如何与其它语言交互等等。在学习的过程中还会有属性的其它示例。

在本章开篇创建的hello包中加入了gcdtest_gcd定义后,当前目录为包的子目录,我们可以这样运行测试:

处理命令行参数

我们可以将src/main.rs中的main函数替换为如下的代码,来让程序接受一系列数字作为命令行参数,打印出最大公约数:

这是一大段代码,让我们逐一分解:

第一个use声明将标准库traitFromStr引入作用域中。trait是一个各种类型可实现的方法集合。任意实现FromStr trait 的类型,都具有一个将该类型的值解析为字符串的from_str方法。u64类型实现了FromStr,所以我们调用u64::from_str来解析命令行参数。虽然在程序其它地方没有用到FromStr,但必须将trait加入到作用域中才能使用其方法。这部分在第11章中进行详解。

第二个use声明引入了std::env模块,它提供了一些有用的方法和类型用于与执行环境交互,其中就有访问程序命令行参数的args函数。

下面走进程序的main函数:

main函数不返回值,此处可以略去一般跟在参数列表之后的->和返回类型。

我们声明了一个本地可变变量numbers并将其初始化为一个空向量。Vec是Rust可增长向量类型,可类比C++的std::vector、Python的列表以及JavaScript的数组。虽然向量设计为动态伸缩,我们仍需将变量标记为mutRust才能让我们在其尾部追加数字。

numbers的类型为Vec<u64>,一个u64值向量,但像前央一样,我们不需要写出来。Rust可代我们推导,一部分是因为我们在向量中追加了u64类型值,另一部分是国为我们将向量传递给了gcd,它仍接收u64类型值。

这里我们使用一个for循环来处理命令行参数,依次使用每个参数设置arg变量,在循环体中运行。

std::env模块的args函数返回一个迭代器-按需生成每个参数的值,以及标记何时完成。Rust中迭代器无处不在,标准库中包含其它迭代器,生成的有向量元素、文件行、通讯通道中接收的消息以及其它可以循环的所有东西。Rust迭代器极其高效:编译器通常可将它们转换成手写循环同样的代码。我们会在第15章中学习其原理并给出示例。

除了使用for循环,迭代器还包含一堆可以直接使用的方法。例如,由args返回的第一个由迭代器生成的值总是所运行的程序名,我们希望跳过它,所以调用了迭代器的skip方法生成一个忽略首个值的新迭代器。

这里我们调用了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章中进行了讲解。

Resultexpect方法检测解析是否成功。如果结果为Err(e)expect打印包含e描述的消息并立即退出程序。但如果结果为Ok(v)expect仅返回v本身,我们最终会将其推入数字向量的尾部。

空数据集是没有最大公约数的,因此检测向量是否至少有一个元素,如无则程序报错退出。我们使用eprintln!宏来对标准错误输出流写消息。

以上循环使用d作为其运行值,使用最新处理的所有数字的最大公约数对其进行更新。和前面一样,我们必须将d标记为可变变量才能在循环中对其赋值。

for循环中有两个小彩蛋。首先我们编写了for m in &numbers[1..]&运算符的作用是什么?其次我们编写了gcd(d, *m)*m*的作用是什么?这两个小细节互为补充。

至此,我们的代码操作都是具有固定内存大小的整型这样的普通值。但我们要遍历的是向量,尺寸可以任意大,也许是非常大。Rust在处理这种值是非常谨慎,它希望由程序员控制内存消耗,让值的生命周期很清晰,同时也确保在不再需要它时立即释放内存。

因此在遍历时,会们希望告诉Rust向量的所有权仍归numbers,我们只是借用其元素进行遍历。&numbers[1..]中的&运算符依次借助每个元素。*m中的*解引用m,生成其指向的值,正是下一个传递给gcdu64。最后,因为numbers持有向量,在main的最后numbers不在作用域中时Rust会自动释放它。

Rust的所有权规则和指针是其内存管理和并发安全的关键,我们会在第4章第5章中详细讨论。读者需要适应这些规则才能适应Rust,但对于入门之旅,读者只需要知道&x借用了对x的引用,以及*r是指针r所指向的值。

继续回到程序:

numbers的元素进行完遍历,程序将结果打印到标准输出流中。println!宏接收一个模板字符串,将{...}形式剩余参数的格式化版本按模板字符串的位置替换,并将结果写至标准输出流中。

不同于C和C++要求main在程序成功执行时返回零,或是在失败时返回非零退出状态,Rust假定只要main进行了返回程序就是成功执行。仅在显式调用expectstd::process::exit这样的函数时我们才能让程序带错误状态码终止。

cargo run命令允许我们对程序传递参数,所以我们可以在命令中进行如下运行:

本节中我们使用了Rust标准库中的一些功能。如果你还想知道有什么其它功能,强烈推荐阅读Rust的在线文档。文档可在线实时搜索方便查看,甚至还包含了源代码链接。rustup命令在安装Rust时自动在电脑上安装了一份副本。可在Rust官网或通过以下命令在本地浏览器中查看标准库文档:

提供网页服务

Rust优势之一是在crates.io网站上免费发布库包集。cargo命令让我们很容易地在代码中使用crates.io包:它会按要求下载正版本的包、构建包及升级包。Rust包,不论是库还是可执行文件,都称为crate,Cargo和crates.io的名称皆源于此。

为进行演示,我们使用web框架crateactix-web、序列化crateserde和一些其它的依赖包开发了一个简单的web服务端。如图2-1所示,我们的网站会让用户填写两个数字并计算它们的最大公约数。

【Rust编程第2版】第2章 Rust编程语言之旅

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

首先我们使用Cargo为我们新建一个包,命名为actix-gcd

然后编辑项目的Cargo.toml 来添加希望使用的包,内容如下:

Cargo.toml 文件[dependencies]内的每一行放crates.io上crate的名称,以及希望使用的版本。本例中,actix-web我们使用的版本是1.0.8serde的版本是1.0。在crates.io上这些包可能还有更新的版本,但通过指定测试代码使用的版本,我们可以保障即使发布了新版本的包代码仍可编译通过。我们会在第8章中讨论版本管理。

crate还有一些可选特性:一些接口或实现并非所有用户会使用,但放在包中也毫无违和感。serde包提供了处理网页表单数据的简便方式,但按照serde的文档,仅在选取了derive特性时才能使用,因此Cargo.toml 文件中进行了相应的请求。

注意我们只需写下直接使用的crate名称,cargo会处理它们的依赖。

在第一版中,我们会让web服务端尽量简单:仅有供用户输入待计算数字的页面。在actix-gcd/src/main.rs中加入如下代码:

我们先通过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_typebody方法来填充响应的详情,每次调用带上所做修改返回其所应用的HttpResponse。最后body的返回值即为get_index的返回值。

因为请求文本中包含了大量的双引号,我们使用Rust原生字符串语法进行编写:字母r加零个或多个井号(即#),一个双引号,然后接字符串内容,再以双引号和同等数量的井号结尾。原生字符串内的任意字符都不会转义,包括双引号,事实上也不识别\"这样的转义。我们在双引号两边使用比文本更多的井号来标识字符串的终止。

编写完了main.rs,我们可以运行cargo run命令来完成运行所需要的任务:获取所需crate、编译包、构建程序、链接包及启动服务:

此时,我们就可以在浏览器中访问所给定的URL,查看到图2-1中所显示的页面。

不过点击Compute GCD还没效果,只是会跳转到一个空页面。下面就来修改这个问题,对App添加一个路由处理来自表单的POST请求。

终于要使用Cargo.toml文件中所列出的serde包了:它提供了一个方便的工作协助我们处理表单数据。首先,我们需要在src/main.rs的顶部添加如下use指令:

Rust程序员通常将use声明放在文件顶部,但并没有严格的要求:Rust允许声明使用任意的排序,只要嵌套层级正确即可。

接下来,我们定义表示获取表单值的Rust结构体类型:

以上定义了一个名为GcdParameters的新类型,包含两个字段nm,均为u64,即gcd函数所需的参数类型。

struct上方的注解为属性,类似前面标记测试函数的#[test]属性。在类型定义上方加#[derive(Deserialize)]属性告知serde包在程序编译时检查该类型,自动生成解析POST请求HTML表单格式数据值的代码。其实这个属性可从各种数据结构中解析出GcdParameters的值:JSON、YAML、TOML或其它任意文本和二进制格式。serde包还提供了Serialize属性可生成反向的代码,将Rust值写入到结构化格式中。

定义好结构体,我们就可以轻松编写处理函数了:

要让函数成为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函数如下:

这里唯一的变化是添加了一个route调用,为"/gcd"路径建立了一个web::post().to(post_gcd处理方法。

最后只差之前编写的gcd函数了,将其放到actix-gcd/src/main.rs文件中。加好后可以中断此前运行的服务,重新构建、重启程序:

此时访问http://localhost:3000,输入一些数字,再点击Compute GCD按钮,就可查看结果了(图2-2)。

【Rust编程第2版】第2章 Rust编程语言之旅

图2-2:显示计算最大公约数结果的网页

并发

Rust的一大优势是对于并发编程的支持。Rust的免内存错误保障确保了线程的内存共享不会产生数据抢占。例如:

  • 如果使用互斥体来协调对共享数据结构做出修改的线程,Rust确保只有在持有锁的时候才能访问数据,在使用完成后自动释放锁。在C和C++中互斥体及其保护数据之间的关系留待评论吧。
  • 如果希望在线程间共享只读数据,Rust会确保数据不会被误修改。在C和C++中,类型系统会有所作用,但很容易出错。
  • 如果将数据结构的所有权由一个线程传递给另一个线程,Rust会确保真的清除了对其的所有访问。在C和C++中,检测发送线程不再碰数据需由程序员完成。如果稍有不慎,结果会取决于处理器缓存的处理以及最近对内存写入了多少次。苦不堪言。

本节中我们一起编写第二个多线程程序。

我们已经写了第一个程序:使用Actix web框架实现最大公约数服务端,它使用了线程池来运行请求处理函数。如果服务端接收到并发请求,可能会在多个线程中同时运行get_formpost_gcd函数。你能会惊讶于在编写这些函数时我们并没有考虑到并发问题。但Rust保障了安全性,不管服务端有多复杂:如果程序完成编译,就没有数据抢占的问题。Rust的所有函数都是线程安全的。

本节的程序绘制曼德勃罗(Mandelbrot)集,一个对复数简单函数进行迭代生成的几何图形。绘制Mandelbrot集通常称为易并行计算embarrassingly parallel ),因为其线程间的通讯模式很简单。我们会在第19章中讨论复杂模式,但此处只演示一些基本用法。

首先我们创建一个新Rust项目:

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

在实现并发Mandelbrot集前,我们需要描述下将要执行的运算。

Mandelbrot集究竟是什么

在阅读代码时,最好能对所实现的内容有深入了解,所以我们来浅述一下纯数学知识。先从简单的例子开始然后不断添加复杂的细节直至触及Mandelbrot集运算的核心。

这里有一个无限循环,使用Rust的语法编写,一个loop语句:

在真实场景中,Rust会知道从未使用过x,因此不会计算其值。在现在假定代码运行了。x的值会发生什么变化?对小于1的数字取平方值会越来越小,因此无限接近于0;对1取平方值仍为1;对大于1的数据取平方值会越来越大,无限接近正无穷;对负数取平方值会变成正值,结果与前面的情况一致(图2-3)。

【Rust编程第2版】第2章 Rust编程语言之旅

图2-3:反复对数字取平方值的效果

因此根据对square_loop所传递的值,x会保持为0或1、无限接近0亦或无限接近正无穷。

接下来对循环做些许调整:

此时,x从0开始,增长略有变化,在每次迭代中取平方再加c。这让我们很难预测x的演进,但实验表明如果c大于0.25或是小于–2.0的话,x会变得无限大,否则会在0的周边徘徊。

再来一波:我们不使用f64值,考虑对同一循环使用复数。crates.io的num包提供了复数类型的支持,因此必须在程序的Cargo.toml 文件[dependencies]下添加一行num。该文件当前的完整内容如下(我们会不断新增):

现在我们编写倒数第二个版本的循环:

使用z表示复数是一种传统,所以我们重命名了循环变量。Complex { re: 0.0, im: 0.0 }表达式是我们使用num包的Complex类型编写复数零值的方式。Complex是Rust的结构体类型(或struct),定义如下:

以上代码定义了结构体Complex,有两个字段,reimComplex是一种泛型结构体,可以把类型名称后的<T>看成是“任意类型T”。例如,Complex<f64>是一个reim都是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.rec.im,我们将其看成笛卡尔平面某个点的xy坐标,如c位于Mandelbrot集中点为黑色,否则为浅色。因此对图像中的每个像素,我们都需要对复数平面的相应点执行前述循环,看它是逃逸到无穷大还是围绕着原点,并相应地上色。

无限循环需要花很久运行,但对于耐心有限的人有两个小技巧。首先,我们不进行无限循环而是进行限定数量的迭代,我们仍然可得到很好的集合估值。要迭代多少次取决于我们希望绘制边界的精确度。另外据发现,如果z离开以原点为中心半径为2的圆之外,最终一定会离原点无限远。因此以下是循环的最终版本,以及程序的核心代码:

函数接收的复数c我们希望测试是否为Mandelbrot集成员,接收的limit为在放弃迭代并声明c可能为其成员的上限迭代次数。

函数的返回值为Option<usize>。Rust标准库定义Option类型如下:

Option为枚举类型,通常称为enum,因其定义枚举此类型可能值的多个变体:对于任意类型TOption<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

前面的例子展示过for循环对命令行参数和向量元素进行迭代,此处的for循环仅对从0limit(不含)的整数区间进行迭代。

z.norm_sqr()方法调用方法返回从原点到z距离的平方。要决定z是否离开半径为2的圆,我们没有计算平方根,只是拿平方距离与4.0进行比对,这样更快。

读者可能注意到我们使用///来标记函数定义上方的注释行,Complex结构体成员上方的注释也以///开头。它们是文档注释rustdoc工具可同时解析它们及所描述的代码,生成在线文档。Rust标准库的文档就是这么写的。我们会在第8章中讲解文档注释。

剩余的代码用于决定以什么分辨率绘制点集的哪一部分,并将任务分布到多个线程中来加速运算。

解析坐标对命令行参数

程序接收多个命令行参数控制所写图像的分辨率以及图像显示哪部分Mandelbrot集。因为这些命令行参数都遵循统一的格式,解析函数如下:

parse_pair是一个泛型函数:

<T: FromStr>语句应读为:对于任意实现了FromStr接口的类型T。这让我们可以一次性定义整个函数组:parse_pair::<i32>是解析i32值对的函数,parse_pair::<f64>是解析浮点值对的函数等等。这很像是C++里的函数模板。Rust程序员会称Tparse_pair类型参数。在使用泛型函数时,Rust通常能自己推导出类型参数,无需像测试代码中那样写出来。

返回类型为Option<(T, T)>:为NoneSome((v1, v2)),其中(v1, v2)是包含两个值的元组,类型都是Tparse_pair函数没有使用显式的返回语句,所以其返回值是函数体中的最后一个(也是唯一一个)表达式:

String类型的find方法在字符串中搜索匹配separator的字符。如果find返回None,表示字符串中没有分隔字符,整个match表达式的结果为None,表示解析失败。否则我们拿到分隔符在字符串中的索引下标。

这里展示了match表示式的强大。匹配的参数为以下元组表达式:

表达式&s[..index]&s[index + 1..]字符串的切片,分列分隔符前后。类型参数T的关联函数from_str分别接收这两个切片并尝试将它们解析为类型T的值。所进行的匹配如下:

这一模式仅在元组元素为Result类型的Ok变体时才进行匹配,表明两者的解析都成功了。如果解析成功,匹配表达式的值为Some((l, r)),因此也是函数的返回值。

通配模式_匹配任意内容,忽略其值。如果运行到这里,parse_pair就失败了,因此结果为None,同样也是函数的返回值。

既然已经有了parse_pair,就可以很容易地编写解析浮点坐标对的函数并作为Complex<f64>返回值:

parse_complex函数调用parse_pair,如果坐标解确正常则构造一个Complex值,并将错误返回给调用者。

仔细阅读代码会发现我们使用了短标记来构造Complex值。通常使用同名变量来初始化结构体字段,Rust不强制写成Complex { re: re, im: im },而是可简写为Complex { re, im }。这取自JavaScript和Haskell的标记法。

将像素与复数进行映射

程序需要在两个关联坐标空间中运行:输出图像的每个像素对应复数平面中的一个点。这两个空间的关联与我们所要绘制的Mandelbrot集的区间以及命令行参数所决定的图像像素相关。以下函数将图片空间转化为复数空间:

图2-4中描述pixel_to_point所执行的运算。

pixel_to_point的代码为简单运算,这里不再详述。但是有一些点需要指出。这种形式的表达式指向元组的元素:

它指向的是元组pixel的第一个元素。

这是Rust类型转换语法:将pixel.0转换为f64值。不同于C和C++,Rust通常禁止数值类型之间的隐式转换,必须写出所要做的转换。可能很麻烦,但显式描述转换的类型和时机非常有帮助。隐式整型转换看似没毛病,但从历史看它们经常导致C和C++真实代码的bug和安全漏洞。

【Rust编程第2版】第2章 Rust编程语言之旅

图2-4:复杂平面与图像像素之间的关系

绘制点集

绘制Mandelbrot集,对图像中的每个相应我们只需对复数平面中的每个点应用escape_time,像素颜色由其结果决定:

下面的代码读者应该很熟悉了。

escape_time如判断point属于点集,render就会将像素上为黑色(0)。否则,render会对逃离圆圈用时更久的数赋更深的颜色。

写图像文件

image包提供了读取和写大量格式图像的函数,以及一些基本图像操作函数。具体来说,它包含有PNG图像文件格式的编码器,本例程序使用它来保存运算的结果。要使用image包,需要在Cargo.toml文件的[dependencies]下加入如下代码:

添加完之后,可以写如下代码:

这一函数的操作非常明了:打开一个文件然后尝试写入图像。我们对编码器传递了pixels中的实际像素数据,以及bounds中的宽和高,以及用于解析pixels中字节的最后那个参数:ColorType::Gray(8)值表示每个字节是一个8比特的灰度值。

这都很容易理解。要关注的是函数处理错误的方式。在遇到错误时,我们需要向调用者报错。前面提到过,Rust中会出错的函数要返回一个Result值,成功时为Ok(s),其中s为成功值,或是在失败是返回Err(e)e为错误码。那么write_image的成功值和错误类型是什么呢?

一切顺利的话,write_image函数没有什么要返回的值,它将数据写入到文件中。因此其成功类型为单元类型(),这么叫是因为它仅一个值,也写作()。单元类型与CC++中的void相似。

在出错时,原因可能是File::create无法创建文件或是encoder.encode无法在文件中写入图像,I/O操作返回一个错误码。File::create的返回类型是Result<std::fs::File, std::io::Error>,而encoder.encode的返回类型是Result<(), std::io::Error>,所以两者共享同一错误类型std::io::Errorwrite_image函数同样返回该类型合情合理。在这两种情况下,应立即返回错误,传递描述产生错误的std::io::Error值。

所以要正常处理File::create的结果,我们匹配其返回值,如下:

成功时output为携带Ok值的File。出错时,将错误传递给调用方。

Rust中此类match语句很常见,语言提供了一个?运算符作为整个内容的简写。因此在可能出错时我们不需要每次显式地写出这段逻辑,可以使用同等效果、可读性更强的语句:

如果File::create失败,?运算符从write_image中返回,传递错误。否则,output持有成功打开的File

:初学者常见的错误是在main函数中使用?。但因为main本身并不返回值,它不会生效;应当使用match语句,或是像unwrapexpect这样的简写方法。还有一种选择,将main修改为返回Result,在稍后会讲到。

Mandelbrot的并发程序

所有的准备都已完成,我们可以开始编写main函数了,我们会在其中用到并发。首先是一个简单的非并发版本:

在将命令行参数汇总至String向量后,我们逐一进行解析然后开始运算。

宏调用vec![v; n]创建一个元素长度为n的向量,元素初始化到v中,所以以上代码创建一个由零组成的向量,长度为bounds.0 * bounds.1bounds是从命令行中解析的图像尺寸。我们将这个向量用作单字节灰度像素值的矩形数组,参见图2-5

下一行要讨论的代码是:

它调用render函数来实际计算图像。&mut pixels表达式从像素缓冲借用一个可变指针,允许render对其填充运算后的灰度值,虽然此时pixels仍是向量的所有者。剩下的参数传递图像的尺寸以及我们选择绘制的复数平面矩形区。

【Rust编程第2版】第2章 Rust编程语言之旅

图2-5:将向量用作像素矩形数组

最后,我们将像素缓冲以PNG文件写入至磁盘。本例中我们将共享(不可变)指针传递给缓冲,因为write_image无需修改缓冲中的内容。

至此,我们可以发布模式构建、运行程序,这种模式启用很多强大的编译器优化,数秒后,就会在mandel.png文件中写入一张漂亮的图片:

该命令创建一个名为mandel.png的文件,通过系统的图片浏览程序或浏览器可进行查看。如果一切顺利,可看到图2-6这样的图片。

【Rust编程第2版】第2章 Rust编程语言之旅

图2-6:并行Mandelbrot程序运行结果

在上面的命令中,我们使用了Unix的time程序来分析程序运行时长:整体大约花了5秒钟来运行对图像每个像素的Mandelbrot运算。但所有的现代机器都有多处理器,这个程序只用到了单核。我们可以将任务分布到机器提供的所有计算资源上,就能更快地生成图片。

为此,我们对图像分区,每个处理器一块,并让一个处理器为像素着色。为进行简化,我们将其分成横向区块,参见图2-7。在所有处理器都完成之后,我们可以将像素写入磁盘。

【Rust编程第2版】第2章 Rust编程语言之旅

图2-7:将像素缓冲分区进行并行渲染

crossbeam包提供了很多有价值的并发工具,包含此处所需的作用域线程工具。需要在Cargo.toml文件中添加如下内容方能使用:

之后我们需要拿出调用render的那一行,替换为如下内容:

老规矩逐行分解:

这里我们决定使用8个线程。然后计算每一块有多少行。我们将行数量增加了一点以确保在行高不是threads的倍数时也能覆盖整个图像。

num_cpus 包提供了一个函数可返回当前系统的可用CPU数。

这将我们对像素缓冲分块。缓冲的chunks_mut方法返回生成可变不重叠缓冲切片的迭代器,每个都包含rows_per_band * bounds.0个像素,换句话说,rows_per_band为完整像素行。chunks_mut生成的最后一个切片行数可能较少,但每行的像素数相同。最后,迭代器的collect方法构造一个存储这些可变不重叠切片的向量。

下面开始使用crossbeam库:

参数|spawner| { ... }是需要单个spawner参数的Rust闭包。注意不同于通过fn所声明的函数,我们不需要声明闭包参数的类型。Rust会根据返回值推导出其类型。本例中crossbeam::scope调用该闭包,传递的spawner参数值可供闭包用于新建线程。crossbeam::scope等待所有这些线程执行完成再返回。这种行为可使Rust保障线程不会访问作用域外的pixels分块,也保障了我们在crossbeam::scope返回时图像的运算也完成了。如果不出问题,crossbeam::scope返回Ok(()),但如果任一线程panic了,会返回Err。我们对Result调用了unwrap,这样出错后我们的程序也会panic,报告给用户。

这里我们遍历了像素缓冲的分片。into_iter()迭代器在循环体每次迭代时会授予它某一分片的排他所有权,这样就保证了一次只有一个线程对其写入。我们会在第5章中讲解其原理。接着,enumerate适配器生成向量元素及其索引的元组对。

获取到分片的索引和实际大小(还记得最后一片可能会其它的短吧),我们就可以生成render所需的边界盒子了,但指向的仅是分片的缓冲,而非整个图像的。类似地,我们可以调整渲染器的pixel_to_point函数来查看分片在复数平面的左上角和右下角位置。

最后我们创建了一个线程,运行闭包move |_| { ... }。最前面的move关键字表示这个闭包接管所使用变量的所有权,也就是只有闭包能使用这个可变分片band。参数列表|_|表示闭包接收一个参数,但不使用(另一个生成内嵌线程的spawner)。

前面也提到了,crossbeam::scope调用保障在返回前所有线程均已完成,也就是将图像保存到文件中是安全的,这正是下一步操作。

运行Mandelbrot绘图工具

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

准备好这个之后,就可以构建、运行程序了:

这里我们再次使用了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项目:

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

像上面这样到达1.0版本的Rust包,都遵循语义化版本规范:在大版本号1改变之前,所有的新版本应兼容此前的版本。因此如果我们的程序测试过某个包的1.2版本,那么使用1.31.4等版本同样应该正常;但2.0版本可引入一些不兼容的修改。在Cargo.toml 文件中请求包的"1"版本时,Cargo会使用2.0版本之前的最新包。

命令行界面

本程序的界面相当简单。它接收4个参数:一个用于搜索的字符串(或正则表达式)、一个用于替换的字符串(或正则表达式)、输入文件名称以及输出文件名称。我们先在main.rs 文件中添加包含如下参数的结构体:

#[derive(Debug)]属性告诉编译器生成一些代码让我们可以在println!中通过{:?}格式化Arguments结构体。

在用户输入错误数量的参数时,通常会打印出如何使用程序的简短说明。我们会使用一个简单函数print_usage来实现,并导入text-colorizer的所有内容,这样就可以添加一些颜色了:

只需在字符串字面量结尾添加.green()就可以生成以相应ANSI转义代码封装的字符串,来在模拟终端中显示为绿色。在打印前该字符串会插入消息中。

下面就可以收集并处理程序的参数了:

为获取用户输入的参数,我们使用和前面例子中同样的args迭代器。.skip(1)跳过迭代器的首个值(所运行的程序名)这样结果只包含命令行参数。

collect()方法生成一个参数的Vec,然后检测数量是不正确,如不正确,打印消息并使用错误码退出。我们再次对消息添加了颜色 ,还使用.bold()让文本加粗。如果参数的数量正确,我们将其放到Arguments结构体中返回。

然后我们添加main函数,调用parse_args并打印输出:

此时我们可以运行程序查看是否打印正确的错误消息:

如果给程序传一些参数,则会打印出Arguments结构体的内容:

这开了个好头!参数提取正常,并正确地放到了Arguments结构体中。

读写文件

接着我们需要从文件系统实际获取数据,这样才能对其进行处理,并在完成后回写。Rust有一套健全的输入输出工具集,但标准设计者知道读写文件很常用,所以他们让特意进行了简化。我们只需导入一个模块std::fs,即可使用read_to_stringwrite函数。

std::fs::read_to_string返回Result<String, std::io::Error>。如果函数成功执行,生成一个String。如果失败,生成std::io::Error,这是标准库表示I/O问题的类型。类似地,std::fs::write返回Result<(), std::io::Error>:成功时不返回,出错时返回同样的错误信息:

此处,我们使用了此前编写的parse_args()函数并将结果文件名传递给了read_to_stringwrite。这些函数输出的match语句优雅地处理了错误,打印文件名,所提供的错误原因,以及添加颜色获取用户的注意。

使用更新的main函数运行程序查看结果,当然新老文件的内容完全相同:

程序确实读取了输入文件Cargo.toml,,也确实写入了输入文件Copy.toml,,但因为我们还没有写查找替换的代码,输出不会有变化。我们可以通过运行检测不同的diff命令进行查看

查找及替换

对该程序的最后修饰是实现实际的功能:查找和替换。为此需要使用到编译和执行正则表达式的regex包。它提供一个名为Regex的结构体,表示编译后的正则表达式。Regex有一个replace_all方法,功能和名称一样:搜索字符串中正则表达式的所有匹配项,全部替换为给定的字符串。我们可以可这个逻辑抽到一个函数中:

注意函数的返回类型。和前面我们使用的标准库函数一样,replace返回一个Result,这次是由regex所提供的错误类型。

Regex::new编译用户所提供的正则表达式,如果是无效字符串则失败。和Mandelbrot程序中一样,我们使用?简化Regex::new的失败情况,但本例中函数返回一个regex包自有的错误类型。编译完正则表达式,replace_all方法会text中的所有匹配项替换为给定的替换字符串。

replace_all若查找到匹配项,它返回一个使用给定文本替换匹配项后的新字符串。否则replace_all返回原文本的指针,避免不必要的内存分配和拷贝。但在例中,我们总是希望有独立的拷贝,因此使用了to_string方法来获取一个String,像其它函数一样对该字符串使用Result::Ok封装后返回。

下面在main代码中集成新函数:

完成了最后的修改,就可以测试程序了:

当然错误处理也已就位,优雅地对用户报错:

这个简单的演示程序自然还需要很多功能,但已有了基本功能。我们学习了如何读写文件、传递和显示错误以及在终端中为输出添加颜色提升用户体验。

未来的章节会探讨应用开发更高级的技巧,有数据集合、迭代器函数式编程乃至实现极度高效并发的异步编程技术,但首先我们要学习下一章建立Rust基本数据类型的坚实基础。

喜欢 (1)
[]
分享 (0)
发表我的评论
取消评论

表情 贴图 加粗 删除线 居中 斜体 签到

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址