第8章 包和模块
# 第8章 包和模块
这是Rust主题中的一个理念:系统程序员也能拥有美好的东西。 ——罗伯特·奥卡拉汉(Robert O’Callahan),《关于Rust的一些想法:crates.io和IDE》
假设你正在编写一个模拟蕨类植物从单个细胞开始生长过程的程序。和蕨类植物一样,你的程序一开始会非常简单,所有代码可能都写在一个文件里 —— 就像一个萌芽的想法。随着它的发展,它会开始有内部结构,不同部分会有不同的用途,会扩展到多个文件,甚至可能覆盖整个目录树。最终,它可能会成为整个软件生态系统的重要组成部分。对于任何一个规模超过几个数据结构或几百行代码的程序来说,一定的组织是必要的。
本章将介绍Rust中用于帮助组织程序的特性:包(crates)和模块(modules)。我们还会涉及与Rust包的结构和分发相关的其他主题,包括如何对Rust代码进行文档注释和测试,如何消除不需要的编译器警告,如何使用Cargo来管理项目依赖和版本控制,如何在Rust的公共包仓库crates.io上发布开源库,以及Rust如何通过语言版本的演进不断发展等内容。我们将以蕨类植物模拟器为例贯穿讲解。
# 包
Rust程序由包组成。每个包都是一个完整、紧密结合的单元:它包含单个库或可执行文件的所有源代码,以及任何相关的测试代码、示例、工具、配置和其他内容。对于你的蕨类植物模拟器,你可能会使用用于3D图形、生物信息学、并行计算等方面的第三方库。这些库都是以包的形式分发的(见图8-1)。
图8-1 一个包及其依赖项
要了解包是什么以及它们如何协同工作,最简单的方法是使用cargo build --verbose
命令构建一个有依赖项的现有项目。我们以 “一个并发的曼德布洛特程序” 为例进行了操作,结果如下:
$ cd mandelbrot
$ cargo clean # 删除之前编译的代码
$ cargo build --verbose
Updating registry `https://github.com/rust-lang/crates.io-index`
Downloading autocfg v1.0.0
Downloading semver-parser v0.7.0
Downloading gif v0.9.0
Downloading png v0.7.0
... (下载并编译更多的包)
Compiling jpeg-decoder v0.1.18
Running `rustc
--crate-name jpeg_decoder
--crate-type lib
...
--extern byteorder=../libbyteorder-29efdd0b59c6f920.rmeta
...
Compiling image v0.13.0
Running `rustc
--crate-name image
--crate-type lib
...
--extern byteorder=../libbyteorder-29efdd0b59c6f920.rmeta
--extern gif=../libgif-a7006d35f1b58927.rmeta
--extern jpeg_decoder=../libjpeg_decoder-5c10558d0d57d300.rmeta
Compiling mandelbrot v0.1.0 (/tmp/rustbook-test-files/mandelbrot)
Running `rustc
--edition=2018
--crate-name mandelbrot --crate-type bin
...
--extern crossbeam=../libcrossbeam-f87b4b3d3284acc2.rlib
--extern image=../libimage-b5737c12bd641c43.rlib
--extern num=../libnum-1974e9a1dc582ba7.rlib -C link-arg=-fuse-ld=lld`
Finished dev [unoptimized + debuginfo] target(s) in 16.94s
$
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
我们重新格式化了rustc
命令行,使其更易读,并删除了许多与讨论无关的编译器选项,用省略号(...)代替。
你可能还记得,在完成曼德布洛特程序时,main.rs
文件中有几个use
声明,用于引入其他包中的项:
use num::Complex;
// ...
use image::ColorType;
use image::png::PNGEncoder;
2
3
4
我们还在Cargo.toml
文件中指定了每个包所需的版本:
[dependencies]
num = "0.4"
image = "0.13"
crossbeam = "0.8"
2
3
4
这里的“dependencies”(依赖项)指的就是这个项目使用的其他包:我们所依赖的代码。我们在crates.io上找到了这些包,crates.io是Rust社区的开源包网站。例如,我们通过在crates.io上搜索图像库,了解到了image
库。每个包在crates.io上的页面都会显示其README.md
文件、文档和源代码的链接,以及像image = "0.13"
这样的配置行,你可以复制并添加到自己的Cargo.toml
文件中。这里显示的版本号只是我们编写程序时这三个包的最新版本。
Cargo的输出记录展示了这些信息的使用方式。当我们运行cargo build
时,Cargo首先从crates.io下载指定版本的这些包的源代码。然后,它读取这些包的Cargo.toml
文件,下载它们的依赖项,依此类推,递归进行。例如,image
包0.13.0版本的源代码包含一个Cargo.toml
文件,其中有如下内容:
[dependencies]
byteorder = "1.0.0"
num-iter = "0.1.32"
num-rational = "0.1.32"
num-traits = "0.1.32"
enum_primitive = "0.1.0"
2
3
4
5
6
看到这些,Cargo就知道在使用image
包之前,还必须获取这些包。稍后我们会介绍如何让Cargo从Git仓库或本地文件系统而不是crates.io获取源代码。
由于mandelbrot
包通过使用image
包间接依赖这些包,所以我们称它们为mandelbrot
包的传递依赖项(transitive dependencies)。所有这些依赖关系的集合,即告诉Cargo关于要构建哪些包以及按什么顺序构建所需的所有信息,被称为包的依赖图(dependency graph)。
Cargo对依赖图和传递依赖项的自动处理,在节省程序员的时间和精力方面意义重大。
获取到源代码后,Cargo会编译所有包。它会为项目依赖图中的每个包运行一次Rust编译器rustc
。在编译库时,Cargo使用--crate-type lib
选项。这会告诉rustc
不要查找main()
函数,而是生成一个.rlib
文件,其中包含可用于创建二进制文件和其他.rlib
文件的编译代码。
在编译程序时,Cargo使用--crate-type bin
选项,结果是为目标平台生成一个二进制可执行文件:例如在Windows上是mandelbrot.exe
。
在每次运行rustc
命令时,Cargo都会传递--extern
选项,指定包将使用的每个库的文件名。这样,当rustc
看到像use image::png::PNGEncoder
这样的代码行时,它就能知道image
是另一个包的名称,而且多亏了Cargo,它知道在磁盘上哪里可以找到已编译的包。Rust编译器需要访问这些.rlib
文件,因为它们包含库的编译代码。Rust会将这些代码静态链接到最终的可执行文件中。.rlib
文件还包含类型信息,这样Rust就可以检查我们在代码中使用的库特性是否确实存在于包中,以及我们是否正确使用了它们。它还包含包的公共内联函数、泛型和宏的副本,这些特性在Rust看到我们如何使用它们之前,无法完全编译为机器代码。
cargo build
支持各种选项,其中大多数超出了本书的范围,但我们在这里提一个:cargo build --release
会生成一个优化后的构建版本。发布版本运行速度更快,但编译时间更长,它不会检查整数溢出,会跳过debug_assert!()
断言,而且在程序崩溃时生成的栈跟踪信息通常不太可靠。
# 版本
Rust有非常强的兼容性保证。任何在Rust 1.0上能编译的代码,在Rust 1.50上也一定能编译,甚至在未来发布的Rust 1.900上也能编译(如果有的话)。
但有时,社区会遇到一些极具吸引力的语言扩展提案,这些提案可能会导致旧代码无法再编译。例如,经过大量讨论后,Rust确定了支持异步编程的语法,将async
和await
重新用作关键字(见第20章)。但这个语言变化会破坏任何将async
或await
用作变量名的现有代码。
为了在不破坏现有代码的情况下进行演进,Rust使用了版本(Editions)机制。Rust 2015版本与Rust 1.0兼容。Rust 2018版本将async
和await
变成了关键字,简化了模块系统,并引入了其他一些与2015版本不兼容的语言变化。每个包通过在其Cargo.toml
文件顶部的[package]
部分添加如下一行内容,来指明它是用哪个版本的Rust编写的:
edition = "2018"
如果缺少这个关键字,则默认使用2015版本,所以旧的包根本不需要更改。但如果你想使用异步函数或新的模块系统,就需要在Cargo.toml
文件中添加edition = "2018"
(或者可能是更新的版本)。
Rust承诺编译器将始终支持所有现有的语言版本,并且程序可以自由混合使用不同版本编写的包。甚至2015版本的包依赖2018版本的包也是可以的。换句话说,一个包的版本只影响其源代码的解释方式;在代码编译完成后,版本的差异就不存在了。这意味着,没有必要仅仅为了继续参与现代Rust生态系统而更新旧的包。同样,也没有必要为了避免给用户带来不便而让你的包停留在旧版本上。只有当你想在自己的代码中使用新的语言特性时,才需要更改版本。
版本并不是每年都会发布,只有当Rust项目认为有必要时才会发布。例如,并没有Rust 2020版本。将edition
设置为"2020"
会导致错误。《Rust版本指南》介绍了每个版本引入的变化,并提供了关于版本系统的详细背景信息。
使用最新版本通常是个好主意,特别是对于新代码。cargo new
默认会基于最新版本创建新项目。本书通篇使用的是2018版本。
如果你有一个用旧版本Rust编写的包,cargo fix
命令可能能够帮助你自动将代码升级到较新的版本。《Rust版本指南》详细解释了cargo fix
命令。
# 构建配置文件
你可以在Cargo.toml
文件中设置一些配置项,这些配置项会影响Cargo生成的rustc
命令行(表8-1)。
表8-1 Cargo.toml配置设置部分
命令行 | Cargo.toml使用的部分 |
---|---|
cargo build | [profile.dev] |
cargo build --release | [profile.release] |
cargo test | [profile.test] |
默认设置通常就足够了,但我们发现有一个例外情况,就是当你想使用性能分析器(一种用于测量程序CPU时间花费情况的工具)时。为了从性能分析器获得最佳数据,你既需要优化(通常只在发布版本构建中启用),又需要调试符号(通常只在调试版本构建中启用)。要同时启用这两者,可以在Cargo.toml
文件中添加如下内容:
[profile.release]
debug = true # 在发布版本构建中启用调试符号
2
debug
设置控制rustc
的-g
选项。通过这个配置,当你输入cargo build --release
时,会得到一个包含调试符号的二进制文件,而优化设置不受影响。
Cargo文档列出了你可以在Cargo.toml
文件中调整的许多其他设置。
# 模块
包主要用于项目之间的代码共享,而模块则用于项目内部的代码组织。模块相当于Rust中的命名空间,是存放组成Rust程序或库的函数、类型、常量等内容的容器。一个模块看起来像这样:
mod spores {
use cells::{Cell, Gene};
/// 成年蕨类植物产生的细胞。作为蕨类植物生命周期的一部分,它随风传播。
/// 孢子会生长成原叶体,这是一种完全独立的生物体,直径可达5毫米,
/// 原叶体会产生受精卵,进而生长成新的蕨类植物。(植物的有性生殖很复杂。)
pub struct Spore {
...
}
/// 模拟通过减数分裂产生孢子的过程。
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
...
}
/// 提取特定孢子中的基因。
pub(crate) fn genes(spore: &Spore) -> Vec<Gene> {
...
}
/// 混合基因,为减数分裂做准备(这是细胞间期的一部分)。
fn recombine(parent: &mut Cell) {
...
}
...
}
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
一个模块是一系列项的集合,这些项是有名称的特性,比如这个例子中的Spore
结构体和两个函数。pub
关键字用于将项设置为公共的,这样就可以从模块外部访问它。
有一个函数被标记为pub(crate)
,这意味着它在当前包内的任何地方都可用,但不会作为外部接口的一部分暴露出去。其他包不能使用它,它也不会出现在当前包的文档中。
任何未标记为pub
的项都是私有的,只能在定义它的模块或其任何子模块中使用:
let s = spores::produce_spore(&mut factory); // 可以
spores::recombine(&mut cell); // 错误:`recombine`是私有的
2
将一个项标记为pub
通常被称为 “导出” 该项。
本节的其余部分将介绍充分利用模块所需了解的详细内容:
- 我们将展示如何嵌套模块,以及在需要时如何将它们分布在不同的文件和目录中。
- 我们将解释Rust用于引用其他模块中项的路径语法,并展示如何导入项,这样就无需写出完整路径即可使用它们。
- 我们将简要介绍Rust对结构体字段的精细控制。
- 我们将介绍
prelude
模块,它通过收集几乎所有用户都需要的常用导入,减少样板代码。 - 我们将介绍常量(constants)和静态变量(statics),这是定义具名值的两种方式,有助于提高代码的清晰度和一致性。
# 嵌套模块
模块可以嵌套,常见的情况是一个模块只是一系列子模块的集合:
mod plant_structures {
pub mod roots {
...
}
pub mod stems {
...
}
pub mod leaves {
...
}
}
2
3
4
5
6
7
8
9
10
11
如果你希望嵌套模块中的某个项对其他包可见,一定要将该项及其所有包含它的模块都标记为公共的。否则,你可能会看到类似这样的警告:
warning: function is never used: `is_square`
--> src/crates_unused_items.rs:23:9
23 | / pub fn is_square(root: &Root) -> bool {
24 | | root.cross_section_shape().is_square()
| |________________________________________________
25 | | }
| |
2
3
4
5
6
7
也许此刻这个函数真的是无用代码。但如果你打算在其他包中使用它,Rust会提醒你,实际上其他包无法访问它。你应该确保包含它的所有模块也都是pub
的。
也可以指定pub(super)
,这会使一个项仅对父模块可见;还可以指定pub(in <path>)
,这会使该项在特定的父模块及其子模块中可见。这在深度嵌套的模块中特别有用:
mod plant_structures {
pub mod roots {
pub mod products {
pub(in crate::plant_structures::roots) struct Cytokinin {
...
}
}
use products::Cytokinin; // 可以:在`roots`模块内
}
use roots::products::Cytokinin; // 错误:`Cytokinin`是私有的
}
// 错误:`Cytokinin`是私有的
use plant_structures::roots::products::Cytokinin;
2
3
4
5
6
7
8
9
10
11
12
13
通过这种方式,我们可以把一整个程序写在一个源文件中,包含大量代码和一整套模块层级结构,并且可以按照我们想要的任何方式关联它们。
但实际上这样做会很麻烦,所以还有另一种选择。
# 分离文件中的模块
模块也可以这样写:
mod spores;
之前,我们把spores
模块的内容放在花括号里。而这里,我们是在告诉Rust编译器,spores
模块位于一个名为spores.rs
的单独文件中:
// spores.rs
/// 成年蕨类植物产生的细胞...
pub struct Spore {
...
}
/// 模拟通过减数分裂产生孢子的过程。
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
...
}
/// 提取特定孢子中的基因。
pub(crate) fn genes(spore: &Spore) -> Vec<Gene> {
...
}
/// 混合基因,为减数分裂做准备(这是细胞间期的一部分)。
fn recombine(parent: &mut Cell) {
...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
spores.rs
文件只包含构成该模块的项。它不需要任何样板代码来声明自己是一个模块。
这个spores
模块与上一节展示的版本,唯一的区别就是代码的位置。关于哪些是公共的、哪些是私有的规则,两种方式完全相同。而且Rust从不单独编译模块,即使它们在不同的文件中:当你构建一个Rust包时,实际上是在重新编译它的所有模块。
一个模块可以有自己的目录。当Rust看到mod spores;
时,它会同时查找spores.rs
和spores/mod.rs
;如果这两个文件都不存在,或者都存在,就会报错。在这个例子中,我们使用spores.rs
,是因为spores
模块没有任何子模块。但考虑一下我们之前编写的plant_structures
模块。如果我们决定将这个模块及其三个子模块拆分到各自的文件中,最终的项目结构会是这样:
fern_sim/
├── Cargo.toml
└── src/
├── main.rs
├── spores.rs
└── plant_structures/
├── mod.rs
├── leaves.rs
├── roots.rs
└── stems.rs
2
3
4
5
6
7
8
9
10
在main.rs
中,我们声明plant_structures
模块:
pub mod plant_structures;
这会使Rust加载plant_structures/mod.rs
,该文件声明了三个子模块:
// 在plant_structures/mod.rs中
pub mod roots;
pub mod stems;
pub mod leaves;
2
3
4
这三个模块的内容分别存储在plant_structures
目录下与mod.rs
同级的leaves.rs
、roots.rs
和stems.rs
文件中。
也可以使用同名的文件和目录来组成一个模块。例如,如果stems
模块需要包含名为xylem
和phloem
的子模块,我们可以选择将stems
模块的代码放在plant_structures/stems.rs
中,并添加一个stems
目录:
fern_sim/
├── Cargo.toml
└── src/
├── main.rs
├── spores.rs
└── plant_structures/
├── mod.rs
├── leaves.rs
├── roots.rs
├── stems/
│ ├── xylem.rs
│ └── phloem.rs
└── stems.rs
2
3
4
5
6
7
8
9
10
11
12
13
然后,在stems.rs
中,我们声明这两个新的子模块:
// 在plant_structures/stems.rs中
pub mod xylem;
pub mod phloem;
2
3
这三种方式 —— 模块在单独的文件中、模块在包含mod.rs
的单独目录中、模块在单独文件且有一个包含子模块的补充目录,赋予了模块系统足够的灵活性,几乎可以支持任何你想要的项目结构。
# 路径和导入
::
运算符用于访问模块的特性。在项目的任何地方,代码都可以通过写出路径来引用标准库中的任何特性:
if s1 > s2 {
std::mem::swap(&mut s1, &mut s2);
}
2
3
std
是标准库的名称。路径std
指向标准库的顶级模块。std::mem
是标准库中的一个子模块,而std::mem::swap
是该模块中的一个公共函数。你可以一直这样编写代码,每次需要使用圆周率或字典时,都拼写出std::f64::consts::PI
和std::collections::HashMap::new
,但这样输入很繁琐,而且代码也很难读。另一种方法是将特性导入到使用它们的模块中:
use std::mem;
if s1 > s2 {
mem::swap(&mut s1, &mut s2);
}
2
3
4
use
声明会使mem
这个名称在包含它的代码块或模块中成为std::mem
的本地别名。
我们也可以写use std::mem::swap;
来直接导入swap
函数,而不是导入mem
模块。不过,前面的做法通常被认为是最佳风格:导入类型、trait和模块(比如std::mem
),然后使用相对路径来访问其中的函数、常量和其他成员。
可以一次性导入多个名称:
use std::collections::{HashMap, HashSet}; // 同时导入两个
use std::fs::{self, File}; // 同时导入`std::fs`和`std::fs::File`
use std::io::prelude::*; // 导入所有内容
2
3
这只是写出所有单独导入语句的简写形式:
use std::collections::HashMap;
use std::collections::HashSet;
use std::fs;
use std::fs::File;
// 导入std::io::prelude中的所有公共项:
use std::io::prelude::Read;
use std::io::prelude::Write;
use std::io::prelude::BufRead;
use std::io::prelude::Seek;
2
3
4
5
6
7
8
9
你可以使用as
来导入一个项,并在本地给它一个不同的名称:
use std::io::Result as IOResult;
// 这个返回类型只是`std::io::Result<()>`的另一种写法:
fn save_spore(spore: &Spore) -> IOResult<()> {
...
}
2
3
4
5
模块不会自动继承其父模块的名称。例如,假设我们在proteins/mod.rs
中有如下代码:
// proteins/mod.rs
pub enum AminoAcid { ... }
pub mod synthesis;
2
3
那么synthesis.rs
中的代码不会自动识别AminoAcid
类型:
// proteins/synthesis.rs
pub fn synthesize(seq: &[AminoAcid]) // 错误:找不到类型`AminoAcid`
...
2
3
相反,每个模块都以空白状态开始,必须导入它所使用的名称:
// proteins/synthesis.rs
use super::AminoAcid; // 显式从父模块导入
pub fn synthesize(seq: &[AminoAcid]) // 可以
...
2
3
4
默认情况下,路径是相对于当前模块的:
// 在proteins/mod.rs中
// 从子模块导入
use synthesis::synthesize;
2
3
self
也是当前模块的同义词,所以我们可以这样写:
// 在proteins/mod.rs中
// 从枚举导入名称,这样我们就可以用`Lys`表示赖氨酸,而不是`AminoAcid::Lys`
use self::AminoAcid::*;
2
3
或者简单地写成:
// 在proteins/mod.rs中
use AminoAcid::*;
2
(这里的AminoAcid
示例,当然违背了我们前面提到的只导入类型、trait和模块的风格规则。但如果我们的程序包含很长的氨基酸序列,根据奥威尔第六规则 “与其说出完全野蛮的话,不如打破这些规则”,这样做是合理的。)
super
和crate
这两个关键字在路径中有特殊含义:super
指的是父模块,crate
指的是包含当前模块的包。
使用相对于包根而不是当前模块的路径,能让代码在项目中更方便地移动,因为如果当前模块的路径发生变化,所有的导入都不会失效。例如,我们在synthesis.rs
中可以这样使用crate
:
// proteins/synthesis.rs
use crate::proteins::AminoAcid; // 显式相对于包根导入
pub fn synthesize(seq: &[AminoAcid]) // 可以
...
2
3
4
子模块可以使用use super::*
来访问其父模块中的私有项。
如果你有一个与正在使用的包同名的模块,那么在引用它们的内容时需要格外小心。例如,如果你的程序在Cargo.toml
文件中将image
包列为依赖项,但同时又有一个名为image
的模块,那么以image
开头的路径就会有歧义:
mod image {
pub struct Sampler {
...
}
}
// 错误:这里指的是我们的`image`模块,还是`image`包?
use image::Pixels;
2
3
4
5
6
7
即使image
模块中没有Pixels
类型,这种歧义仍然被视为错误:因为如果以后添加这样一个定义,可能会悄无声息地改变程序其他地方路径的指向,这会让人困惑。
为了解决这种歧义,Rust有一种特殊的路径,称为绝对路径,以::
开头,它总是指向外部包。要引用image
包中的Pixels
类型,可以这样写:
use ::image::Pixels; // `image`包中的`Pixels`
要引用自己模块中的Sampler
类型,可以这样写:
use self::image::Sampler; // `image`模块中的`Sampler`
模块和文件不是一回事,但模块与Unix文件系统中的文件和目录有很自然的类比关系。use
关键字创建别名,就像ln
命令创建链接一样。路径和文件名一样,有绝对路径和相对路径两种形式。self
和super
就像.
和..
这两个特殊目录。
# 标准前置模块(Standard Prelude)
我们刚才提到,就导入的名称而言,每个模块都是以 “空白状态” 开始的。但实际上并非完全空白。
一方面,标准库std
会自动与每个项目链接。这意味着你始终可以使用use std::whatever
的方式,或者直接在代码中通过名称引用std
中的项,比如std::mem::swap()
。此外,一些特别实用的名称,比如Vec
和Result
,包含在标准前置模块(standard prelude)中并会自动导入。Rust的行为就好像每个模块(包括根模块)都以如下导入开始:
use std::prelude::v1::*;
标准前置模块包含几十个常用的trait和类型。
在第2章中,我们提到库有时会提供名为prelude
的模块。但std::prelude::v1
是唯一会自动导入的前置模块。将一个模块命名为prelude
只是一种约定,用于告诉用户该模块适合使用*
进行导入。
# 将use声明设为pub
尽管use
声明只是别名,但它们可以是公共的:
// 在plant_structures/mod.rs中
...
pub use self::leaves::Leaf;
pub use self::roots::Root;
2
3
4
这意味着Leaf
和Root
是plant_structures
模块的公共项。它们仍然只是plant_structures::leaves::Leaf
和plant_structures::roots::Root
的简单别名。
标准前置模块就是这样由一系列pub
导入构成的。
# 使结构体字段公开
模块可以包含使用struct
关键字定义的用户自定义结构体类型。我们将在第9章详细介绍这些内容,但现在是提及模块与结构体字段可见性如何交互的好时机。
一个简单的结构体如下所示:
pub struct Fern {
pub roots: RootSet,
pub stems: StemSet
}
2
3
4
结构体的字段,即使是私有字段,在声明该结构体的模块及其子模块中都可以访问。在模块外部,只有公共字段是可访问的。
事实证明,与Java或C++中按类来实施访问控制不同,Rust按模块实施访问控制对软件设计非常有帮助。它减少了样板化的 “getter” 和 “setter” 方法,并且在很大程度上消除了对类似C++中friend
声明的需求。单个模块可以定义几个紧密协作的类型,比如frond::LeafMap
和frond::LeafMapIter
,它们可以根据需要访问彼此的私有字段,同时仍然对程序的其他部分隐藏这些实现细节。
# 静态变量(Statics)和常量(Constants)
除了函数、类型和嵌套模块,模块还可以定义常量和静态变量。
const
关键字用于定义常量。其语法与let
类似,不过常量可以被标记为pub
,并且必须指定类型。此外,常量通常采用大写命名:
pub const ROOM_TEMPERATURE: f64 = 20.0; // 摄氏度
static
关键字用于定义静态项,它与常量几乎相同:
pub static ROOM_TEMPERATURE: f64 = 68.0; // 华氏度
常量有点像C++中的#define
:其值会在每次使用的地方被编译到代码中。静态变量是在程序启动前设置好,并且会一直存在直到程序退出的变量。在代码中,对于魔法数字和字符串,使用常量;对于大量数据,或者任何需要借用对常量值的引用的情况,使用静态变量。
不存在可变常量。静态变量可以被标记为mut
,但如第5章所讨论的,Rust无法对可变静态变量实施独占访问规则。因此,可变静态变量本质上是线程不安全的,安全代码根本不能使用它们:
static mut PACKETS_SERVED: usize = 0;
println!("{} served", PACKETS_SERVED); // 错误:使用了可变静态变量
2
Rust不鼓励使用全局可变状态。关于替代方案的讨论,见 “全局变量”。
# 将程序转换为库
随着你的蕨类植物模拟器项目逐渐发展,你可能会发现需要的不止一个程序。假设你已经有了一个命令行程序,用于运行模拟并将结果保存到文件中。现在,你还想编写其他程序,用于对保存的结果进行科学分析、实时显示生长中植物的3D渲染图、绘制逼真的图片等等。所有这些程序都需要共享基本的蕨类植物模拟代码。这时,你就需要创建一个库。
第一步是将现有项目分为两部分:一个库包(library crate),包含所有共享代码;以及一个可执行文件,包含仅用于现有命令行程序的代码。
为了展示如何操作,我们用一个非常简化的示例程序来说明:
struct Fern {
size: f64,
growth_rate: f64
}
impl Fern {
/// 模拟蕨类植物生长一天。
fn grow(&mut self) {
self.size *= 1.0 + self.growth_rate;
}
}
/// 运行蕨类植物模拟若干天。
fn run_simulation(fern: &mut Fern, days: usize) {
for _ in 0..days {
fern.grow();
}
}
fn main() {
let mut fern = Fern {
size: 1.0,
growth_rate: 0.001
};
run_simulation(&mut fern, 1000);
println!("final fern size: {}", fern.size);
}
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
我们假设这个程序有一个简单的Cargo.toml
文件:
[package]
name = "fern_sim"
version = "0.1.0"
authors = ["You <you@example.com>"]
edition = "2018"
2
3
4
5
将这个程序转换为库很简单,步骤如下:
- 将文件
src/main.rs
重命名为src/lib.rs
。 - 给
src/lib.rs
中作为库的公共特性的项添加pub
关键字。 - 将
main
函数移动到某个临时文件中。我们稍后会再处理它。
修改后的src/lib.rs
文件如下:
pub struct Fern {
pub size: f64,
pub growth_rate: f64
}
impl Fern {
/// 模拟蕨类植物生长一天。
pub fn grow(&mut self) {
self.size *= 1.0 + self.growth_rate;
}
}
/// 运行蕨类植物模拟若干天。
pub fn run_simulation(fern: &mut Fern, days: usize) {
for _ in 0..days {
fern.grow();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
注意,我们无需对Cargo.toml
做任何修改。这是因为我们这个简单的Cargo.toml
文件让Cargo采用默认行为。默认情况下,cargo build
会查看源目录中的文件并确定要构建的内容。当它看到src/lib.rs
文件时,就知道要构建一个库。
src/lib.rs
中的代码构成了库的根模块。使用我们库的其他包只能访问这个根模块的公共项。
# src/bin目录
让原来的命令行fern_sim
程序再次运行也很简单:Cargo对与库位于同一包中的小程序有一些内置支持。
实际上,Cargo自身就是这样编写的。其大部分代码都在一个Rust库中。我们在本书中一直使用的cargo
命令行程序只是一个薄薄的包装程序,它调用库来完成所有繁重的工作。库和命令行程序都位于同一个源存储库中。
我们也可以将程序和库放在同一个包中。将以下代码放入名为src/bin/efern.rs
的文件中:
use fern_sim::{Fern, run_simulation};
fn main() {
let mut fern = Fern {
size: 1.0,
growth_rate: 0.001
};
run_simulation(&mut fern, 1000);
println!("final fern size: {}", fern.size);
}
2
3
4
5
6
7
8
9
10
main
函数就是我们之前移出来的那个。我们添加了一个use
声明,用于从fern_sim
包中导入Fern
和run_simulation
。换句话说,我们将这个包当作库来使用。
因为我们把这个文件放在了src/bin
目录下,下次运行cargo build
时,Cargo会同时编译fern_sim
库和这个程序。我们可以使用cargo run --bin efern
来运行efern
程序。下面是使用--verbose
选项显示Cargo运行的命令的示例:
$ cargo build --verbose
Compiling fern_sim v0.1.0 (file:///../fern_sim)
Running `rustc src/lib.rs --crate-name fern_sim --crate-type lib...`
Running `rustc src/bin/efern.rs --crate-name efern --crate-type bin...`
$ cargo run --bin efern --verbose
Fresh fern_sim v0.1.0 (file:///../fern_sim)
Running `target/debug/efern`
final fern size: 2.7169239322355985
2
3
4
5
6
7
8
我们仍然无需对Cargo.toml
做任何修改,因为同样地,Cargo的默认行为是查看源文件并自行判断。它会自动将src/bin
目录中的.rs
文件视为要构建的额外程序。
我们还可以使用子目录在src/bin
目录中构建更大的程序。假设我们想提供第二个程序,用于在屏幕上绘制蕨类植物,但绘图代码很多且具有模块化特点,所以适合放在单独的文件中。我们可以为第二个程序创建自己的子目录:
fern_sim/
├── Cargo.toml
└── src/
└── bin/
├── efern.rs
└── draw_fern/
├── main.rs
└── draw.rs
2
3
4
5
6
7
8
这样做的好处是,较大的二进制文件可以有自己的子模块,而不会使库代码或src/bin
目录变得杂乱。
当然,既然fern_sim
现在是一个库,我们还有另一种选择。我们可以将这个程序放在自己独立的项目中,位于完全不同的目录,并且在其Cargo.toml
文件中把fern_sim
列为依赖项:
[dependencies]
fern_sim = { path = "./fern_sim" }
2
也许在未来开发其他蕨类植物模拟程序时,你会采用这种方式。src/bin
目录非常适合像efern
和draw_fern
这样的简单程序。
# 属性
Rust程序中的任何项都可以用属性(attributes)进行修饰。属性是Rust中一种通用的语法,用于向编译器编写各种指令和建议。例如,假设你收到这样一条警告:
libgit2.rs: warning: type `git_revspec` should have a camel case name such as `GitRevspec`, #[warn(non_camel_case_types)] on by default
但你使用这个名字是有原因的,希望Rust别再提示这个问题。你可以通过在类型上添加#[allow]
属性来禁用该警告:
#[allow(non_camel_case_types)]
pub struct git_revspec {
...
}
2
3
4
条件编译(Conditional compilation)是另一个使用属性(即#[cfg]
)来实现的特性:
// 只有在为Android构建项目时,才将这个模块包含在项目中。
#[cfg(target_os = "android")]
mod mobile;
2
3
#[cfg]
的完整语法在《Rust参考手册》(Rust Reference)中有详细说明;最常用的选项如表8-2所示。
表8-2 最常用的#[cfg]
选项
#[cfg(...)] 选项 | 启用条件 |
---|---|
test | 启用测试(使用cargo test 或rustc --test 进行编译时) |
debug_assertions | 启用调试断言(通常在非优化构建中) |
unix | 为Unix(包括macOS)进行编译 |
windows | 为Windows进行编译 |
target_pointer_width = "64" | 目标平台为64位。另一个可能的值是"32" |
target_arch = "x86_64" | 目标平台为x86-64架构。其他值:"x86" 、"arm" 、"aarch64" 、"powerpc" 、"powerpc64" 、"mips" |
target_os = "macos" | 为macOS进行编译。其他值:"windows" 、"ios" 、"android" 、"linux" 、"freebsd" 、"openbsd" 、"netbsd" 、"dragonfly" |
feature = "robots" | 启用名为"robots" 的用户自定义特性(使用cargo build --feature robots 或rustc --cfg feature='"robots"' 进行编译时)。特性在Cargo.toml 的[features] 部分声明 |
not(A) | 条件A 不满足。要为一个函数提供两种不同的实现,可以给其中一个加上#[cfg(X)] ,另一个加上#[cfg(not(X))] |
all(A,B) | 条件A 和B 都满足(相当于&& ) |
any(A,B) | 条件A 或B 满足(相当于|| ) |
偶尔,我们需要精细控制函数的内联展开(inline expansion),这通常是我们乐于交给编译器处理的优化操作。我们可以使用#[inline]
属性来实现:
/// 由于两个相邻细胞之间的渗透作用,调整它们的离子等水平。
#[inline]
fn do_osmosis(c1: &mut Cell, c2: &mut Cell) {
...
}
2
3
4
5
有一种情况,没有#[inline]
就不会进行内联。当在一个包中定义的函数或方法在另一个包中被调用时,除非它是泛型的(有类型参数),或者被显式标记为#[inline]
,否则Rust不会将其进行内联。
否则,编译器将#[inline]
视为一种建议。Rust还支持更强制的#[inline(always)]
,用于要求在每个调用点都将函数内联展开,以及#[inline(never)]
,用于要求永远不要内联某个函数。
有些属性,比如#[cfg]
和#[allow]
,可以附加到整个模块上,并应用于模块中的所有内容。其他属性,比如#[test]
和#[inline]
,必须附加到单个项上。正如你对这种通用特性的预期,每个属性都是定制的,有自己支持的参数集。《Rust参考手册》详细记录了所有支持的属性。
要将一个属性附加到整个包上,在main.rs
或lib.rs
文件的顶部,在任何项之前添加属性,并使用#!
而不是#
,如下所示:
// libgit2_sys/lib.rs
#![allow(non_camel_case_types)]
pub struct git_revspec {
...
}
pub struct git_error {
...
}
2
3
4
5
6
7
8
9
10
#!
告诉Rust将属性附加到包含它的项上,而不是下一个出现的项:在这种情况下,#![allow]
属性附加到整个libgit2_sys
包上,而不仅仅是struct git_revspec
。
#!
也可以用在函数、结构体等内部,但它通常只在文件开头使用,用于将属性附加到整个模块或包上。有些属性总是使用#!
语法,因为它们只能应用于整个包。
例如,#![feature]
属性用于启用Rust语言和库的不稳定特性,这些特性是实验性的,因此可能存在漏洞,或者在未来可能会被更改或移除。例如,在撰写本文时,Rust对跟踪assert!
等宏的展开有实验性支持,但由于这是实验性的,你只能通过以下方式使用它:(1)安装Rust的夜间版本;(2)显式声明你的包使用宏跟踪:
#![feature(trace_macros)]
fn main() {
// 我很好奇这个assert_eq!的实际Rust代码会被替换成什么!
trace_macros!(true);
assert_eq!(10*10*10 + 9*9*9, 12*12*12 + 1*1*1);
trace_macros!(false);
}
2
3
4
5
6
7
随着时间的推移,Rust团队有时会将实验性特性稳定化,使其成为语言的标准部分。此时,#![feature]
属性就变得多余了,Rust会生成一条警告,建议你移除它。
# 测试和文档
正如我们在 “编写和运行单元测试” 中看到的,Rust内置了一个简单的单元测试框架。测试是用#[test]
属性标记的普通函数:
#[test]
fn math_works() {
let x: i32 = 1;
assert!(x.is_positive());
assert_eq!(x + 1, 2);
}
2
3
4
5
6
cargo test
会运行项目中的所有测试:
$ cargo test
Compiling math_test v0.1.0 (file:///../math_test)
Running target/release/math_test-e31ed91ae51ebf22
running 1 test
test math_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
2
3
4
5
6
(你还会看到一些关于 “文档测试” 的输出,我们稍后会讲到。)
无论你的包是可执行文件还是库,这种方式都适用。你可以通过给Cargo传递参数来运行特定的测试:cargo test math
会运行名称中包含math
的所有测试。
测试通常会使用Rust标准库中的assert!
和assert_eq!
宏。如果expr
为真,assert!(expr)
就会成功。否则,它会触发panic
,导致测试失败。assert_eq!(v1, v2)
和assert!(v1 == v2)
类似,只不过如果断言失败,错误消息会显示两个值。
你可以在普通代码中使用这些宏来检查不变量,但要注意assert!
和assert_eq!
在发布版本中也会包含。如果想编写仅在调试版本中检查的断言,应该使用debug_assert!
和debug_assert_eq!
。
要测试错误情况,可以在测试函数上添加#[should_panic]
属性:
/// 只有当除以零导致panic时,这个测试才会通过,正如我们在上一章中所述。
#[test]
#[allow(unconditional_panic, unused_must_use)]
#[should_panic(expected="divide by zero")]
fn test_divide_by_zero_error() {
1 / 0; // 应该会触发panic!
}
2
3
4
5
6
7
在这种情况下,我们还需要添加一个allow
属性,告诉编译器允许我们进行一些它能静态判断会导致panic
的操作,以及进行除法运算并丢弃结果,因为通常情况下,编译器会阻止这类不合理的操作。
你也可以从测试函数中返回Result<(), E>
。只要错误变体实现了Debug
(通常是这样),你就可以使用?
丢弃Ok
变体,直接返回Result
:
use std::num::ParseIntError;
/// 如果 "1024" 是一个有效的数字(它确实是),这个测试就会通过。
#[test]
fn main() -> Result<(), ParseIntError> {
i32::from_str_radix("1024", 10)?;
Ok(())
}
2
3
4
5
6
7
8
用#[test]
标记的函数是条件编译的。普通的cargo build
或cargo build --release
会跳过测试代码。但是当你运行cargo test
时,Cargo会构建你的程序两次:一次是正常构建,另一次是启用测试和测试框架进行构建。这意味着你的单元测试可以和被测试的代码放在一起,如果需要的话,测试可以访问内部实现细节,而且不会产生运行时开销。不过,这可能会导致一些警告。例如:
fn roughly_equal(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn trig_works() {
use std::f64::consts::PI;
assert!(roughly_equal(PI.sin(), 0.0));
}
2
3
4
5
6
7
8
9
在省略测试代码的构建中,roughly_equal
函数看起来未被使用,Rust会发出抱怨:
$ cargo build
Compiling math_test v0.1.0 (file:///../math_test)
warning: function is never used: `roughly_equal`
--> src/crates_unused_testing_function.rs:7:1
7 | / fn roughly_equal(a: f64, b: f64) -> bool {
8 | | (a - b).abs() < 1e-6
| |______________________
9 | | }
| |
= note: #[warn(dead_code)] on by default
2
3
4
5
6
7
8
9
10
所以,当你的测试代码变得足够复杂,需要辅助代码时,按照惯例,应该把它们放在一个tests
模块中,并使用#[cfg]
属性声明整个模块仅用于测试:
#[cfg(test)] // 仅在测试时包含这个模块
mod tests {
fn roughly_equal(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-6
}
#[test]
fn trig_works() {
use std::f64::consts::PI;
assert!(roughly_equal(PI.sin(), 0.0));
}
}
2
3
4
5
6
7
8
9
10
11
12
Rust的测试框架会使用多个线程同时运行多个测试,这是Rust代码默认线程安全带来的一个不错的额外好处。要禁用多线程测试,可以运行单个测试,如cargo test testname
,或者运行cargo test -- --test-threads 1
。(第一个--
确保cargo test
将--test-threads
选项传递给测试可执行文件。)从技术上讲,这意味着我们在第2章展示的曼德布洛特程序不是该章中的第二个多线程程序,而是第三个!在 “编写和运行单元测试” 中运行的cargo test
才是第一个。
通常,测试框架只显示失败测试的输出。要显示通过测试的输出,可以运行cargo test -- --no-capture
。
# 集成测试
你的蕨类植物模拟器还在不断发展。你决定把所有主要功能都放在一个库中,以便多个可执行文件使用。要是能有一些像最终用户那样将库作为外部包链接的测试就好了。另外,你有一些测试需要从二进制文件加载保存的模拟数据,把这些大的测试文件放在src
目录下会很不方便。集成测试可以解决这两个问题。
集成测试是位于项目src
目录旁边的tests
目录中的.rs
文件。当你运行cargo test
时,Cargo会将每个集成测试编译成一个单独的、独立的包,并与你的库和Rust测试框架链接。下面是一个例子:
// tests/unfurl.rs - 蕨菜的拳卷叶在阳光下展开
use fern_sim::Terrarium;
use std::time::Duration;
#[test]
fn test_fiddlehead_unfurling() {
let mut world = Terrarium::load("tests/unfurl_files/fiddlehead.tm");
assert!(world.fern(0).is_furled());
let one_hour = Duration::from_secs(60 * 60);
world.apply_sunlight(one_hour);
assert!(world.fern(0).is_fully_unfurled());
}
2
3
4
5
6
7
8
9
10
11
12
集成测试很有价值,部分原因是它们从外部看待你的包,就像用户使用时一样。它们测试包的公共API。
cargo test
会同时运行单元测试和集成测试。要仅运行特定文件(例如tests/unfurl.rs
)中的集成测试,可以使用命令cargo test -- test unfurl
。
# 文档
cargo doc
命令会为你的库生成HTML文档:
$ cargo doc --no-deps --open
Documenting fern_sim v0.1.0 (file:///../fern_sim)
2
--no-deps
选项让Cargo仅为fern_sim
自身生成文档,而不为它所依赖的所有包生成文档。
--open
选项让Cargo在生成文档后,在你的浏览器中打开该文档。
你可以在图8-2中看到生成的结果。Cargo将新生成的文档文件保存在target/doc
目录,起始页面是target/doc/fern_sim/index.html
。
图8-2 rustdoc生成的文档示例
文档是根据库的公共特性,以及你附加到这些特性上的文档注释生成的。在本章中,我们已经见过一些文档注释。它们看起来像普通注释:
/// 模拟通过减数分裂产生孢子的过程。
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
...
}
2
3
4
但是,当Rust看到以三个斜杠开头的注释时,它会将其视为#[doc]
属性。Rust对前面的示例和下面这段代码的处理方式完全相同:
#[doc = "Simulate the production of a spore by meiosis ."]
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
...
}
2
3
4
当你编译一个库或二进制文件时,这些属性不会改变任何东西,但当你生成文档时,公共特性上的文档注释会包含在输出中。
同样,以//!
开头的注释会被视为#![doc]
属性,并附加到包含它的特性上,通常是一个模块或包。例如,你的fern_sim/src/lib.rs
文件可能会这样开头:
//! 模拟蕨类植物从单个细胞开始的生长过程。
文档注释的内容会被当作Markdown处理,Markdown是一种用于简单HTML格式化的简写标记语言。星号用于表示斜体和加粗,空行被视为段落分隔,以此类推。你也可以包含HTML标签,这些标签会原封不动地复制到格式化后的文档中。
Rust文档注释的一个特殊功能是,Markdown链接可以使用Rust项路径(如leaves::Leaf
),而不是相对URL,来指示链接所指向的内容。Cargo会查找该路径所指向的内容,并在相应的文档页面中替换为正确的链接。例如,根据这段代码生成的文档会链接到VascularPath
、Leaf
和Root
的文档页面:
/// 创建并返回一个[`VascularPath`],它表示从给定的[`Root`][r]到给定的[`Leaf`](`leaves::Leaf`)的营养物质路径。
///
/// [r]: roots::Root
pub fn trace_path(leaf: &leaves::Leaf, root: &roots::Root) -> VascularPath {
...
}
2
3
4
5
6
你还可以添加搜索别名,以便使用内置搜索功能更轻松地查找内容。在这个包的文档中搜索“path”或“route”,都会找到VascularPath
:
#[doc(alias = "route")]
pub struct VascularPath {
...
}
2
3
4
你可以在文本中使用反引号(`)来突出显示代码片段。在输出中,这些代码片段会以等宽字体显示。较大的代码示例可以通过缩进四个空格来添加:
/// 文档注释中的代码块:
///
/// if samples::everything().works() {
/// println!("ok");
/// }
2
3
4
5
你也可以使用Markdown代码块(用三个反引号包裹),这与上述方式效果完全相同:
/// 另一个代码片段,同样的代码,但写法不同:
///
/// ```
/// if samples::everything().works() {
/// println!("ok");
/// }
/// ```
2
3
4
5
6
7
无论你使用哪种格式,当在文档注释中包含代码块时,都会发生一件有趣的事情:Rust会自动将其转换为测试。
# 文档测试
当你在Rust库包中运行测试时,Rust会检查文档中出现的所有代码是否真的能运行且正常工作。它会将文档注释中出现的每个代码块,编译成一个单独的可执行包,与你的库链接并运行它。
下面是一个文档测试的独立示例。运行cargo new --lib ranges
(--lib
标志告诉Cargo我们正在创建一个库包,而不是可执行包)创建一个新项目,并将以下代码放入ranges/src/lib.rs
中:
use std::ops::Range;
/// 如果两个范围重叠,则返回true。
///
/// assert_eq!(ranges::overlap(0..7, 3..10), true);
/// assert_eq!(ranges::overlap(1..5, 101..105), false);
///
/// 如果任一范围为空,则不视为重叠。
///
/// assert_eq!(ranges::overlap(0..0, 0..10), false);
///
pub fn overlap(r1: Range<usize>, r2: Range<usize>) -> bool {
r1.start < r1.end && r2.start < r2.end && r1.start < r2.end && r2.start < r1.end
}
2
3
4
5
6
7
8
9
10
11
12
13
14
文档注释中的两个小代码块会出现在cargo doc
生成的文档中,如图8-3所示。
图8-3 显示一些文档测试的文档
它们也会变成两个单独的测试:
$ cargo test
Compiling ranges v0.1.0 (file:///../ranges)
...
Doc-tests ranges
running 2 tests
test overlap_0 ... ok
test overlap_1 ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
2
3
4
5
6
7
8
如果你给Cargo传递--verbose
标志,你会看到它使用rustdoc --test
来运行这两个测试。rustdoc
会将每个代码示例存储在一个单独的文件中,并添加几行样板代码,生成两个程序。第一个程序如下:
use ranges;
fn main() {
assert_eq!(ranges::overlap(0..7, 3..10), true);
assert_eq!(ranges::overlap(1..5, 101..105), false);
}
2
3
4
5
6
第二个程序如下:
use ranges;
fn main() {
assert_eq!(ranges::overlap(0..0, 0..10), false);
}
2
3
4
5
如果这些程序编译并成功运行,测试就会通过。
这两个代码示例包含断言,但这只是因为在这种情况下,断言能很好地说明文档内容。文档测试的目的不是把所有测试都放在注释里。相反,你应该编写尽可能好的文档,Rust会确保文档中的代码示例能实际编译并运行。
很多时候,一个最小可行示例会包含一些细节,比如导入语句或设置代码,这些对于代码编译是必要的,但在文档中展示又不太重要。要隐藏代码示例中的某一行,可以在该行开头加上#
和一个空格:
/// 让阳光照射进来,并运行模拟给定的时间。
///
/// # use fern_sim::Terrarium;
/// # use std::time::Duration;
/// # let mut tm = Terrarium::new();
/// tm.apply_sunlight(Duration::from_secs(60));
///
pub fn apply_sunlight(&mut self, time: Duration) {
...
}
2
3
4
5
6
7
8
9
10
有时在文档中展示一个完整的示例程序,包括main
函数,会很有帮助。显然,如果这些代码片段已经在你的代码示例中,你就不希望rustdoc
再自动添加它们,否则结果将无法编译。因此,rustdoc
会将任何包含fn main
确切字符串的代码块视为一个完整的程序,不再添加任何内容。
也可以针对特定的代码块禁用测试。要告诉Rust编译你的示例,但不实际运行它,可以使用带有no_run
注释的代码块:
/// 将所有本地的生态箱上传到在线图库。
///
/// ```no_run
/// let mut session = fern_sim::connect();
/// session.upload_all();
/// ```
pub fn upload_all(&mut self) {
...
}
2
3
4
5
6
7
8
9
如果代码甚至不期望能编译通过,那就使用ignore
而不是no_run
。标记为ignore
的代码块不会出现在cargo run
的输出中,但标记为no_run
的测试如果编译通过,会显示为已通过。如果代码块根本不是Rust代码,可以使用语言名称,如c++
或sh
,如果是纯文本则使用text
。rustdoc
并不认识数百种编程语言的名称;相反,它会将任何它不识别的注释视为表示该代码块不是Rust代码,这样既会禁用代码高亮显示,也会禁用文档测试。
# 指定依赖项
我们已经了解了一种告诉Cargo从哪里获取项目所依赖包的源代码的方法:通过版本号,例如:
image = "0.6.1"
指定依赖项的方式有多种,而且对于使用哪些版本,你可能有一些细致的要求,所以值得花几页篇幅来讨论这个问题。
首先,你可能想要使用根本没有在crates.io上发布的依赖项。一种方法是指定Git仓库的URL和修订版本:
image = { git = "https://github.com/Piston/image.git", rev = "528f19c" }
这个特定的包是开源的,托管在GitHub上,但你也可以同样轻松地指向托管在公司网络上的私有Git仓库。如这里所示,你可以指定要使用的具体rev
(修订版本)、tag
(标签)或branch
(分支)。(这些都是告诉Git检出源代码的哪个修订版本的方式。)
另一种选择是指定包含包源代码的目录:
image = { path = "vendor/image" }
当你的团队有一个单一的版本控制仓库,其中包含几个包甚至整个依赖图的源代码时,这种方式很方便。每个包都可以使用相对路径指定其依赖项。
对依赖项有这样的控制权非常强大。如果你觉得使用的任何开源包不完全符合你的要求,你可以轻松地派生它:只需在GitHub上点击“Fork”按钮,然后在Cargo.toml
文件中更改一行代码。下次cargo build
时,就会无缝使用你派生的包,而不是官方版本。
# 版本
当你在Cargo.toml
文件中写下类似image = "0.13.0"
这样的内容时,Cargo的解释相当宽松。它会使用被认为与0.13.0版本兼容的image
的最新版本。
兼容性规则改编自语义化版本控制(Semantic Versioning)。
以0.0开头的版本号表示非常原始的版本,Cargo从不认为它与任何其他版本兼容。
以0.x开头(其中x不为零)的版本号,被认为与0.x系列中的其他点发布版本兼容。我们指定了
image
版本为0.6.1,但如果有0.6.3版本,Cargo会使用它。(这与语义化版本控制标准对0.x版本号的规定不同,但这条规则非常有用,所以被保留了下来。)一旦项目达到1.0版本,只有新的主版本才会破坏兼容性。所以如果你要求使用2.0.1版本,Cargo可能会使用2.17.99版本,但不会使用3.0版本。
版本号默认具有灵活性,否则选择使用哪个版本的问题很快就会变得限制过多。假设一个库libA
使用num = "0.1.31"
,而另一个库libB
使用num = "0.1.29"
。如果版本号要求完全匹配,那么没有项目能够同时使用这两个库。允许Cargo使用任何兼容版本是一个更实际的默认设置。
不过,不同的项目在依赖项和版本控制方面有不同的需求。你可以使用运算符指定确切的版本或版本范围,如表8-3所示。
表8-3 在Cargo.toml文件中指定版本
Cargo.toml中的行 | 含义 |
---|---|
image = "=0.10.0" | 仅使用确切版本0.10.0 |
image = ">=1.0.5" | 使用1.0.5或任何更高版本(如果有2.9版本,也可以使用) |
image = ">1.0.5 <1.9" | 使用大于1.0.5且小于1.9的版本 |
image = "<=2.7.10" | 使用最高到2.7.10的任何版本 |
你偶尔还会看到另一种版本指定方式——通配符*
。这告诉Cargo任何版本都可以。除非其他Cargo.toml
文件包含更具体的约束,否则Cargo会使用最新的可用版本。doc.crates.io
上的Cargo文档对版本指定的介绍更详细。
请注意,兼容性规则意味着版本号不能仅仅出于营销目的来选择,它们实际上是有意义的。它们是包的维护者和用户之间的一种契约。如果你维护一个版本为1.7的包,并且决定删除一个函数或进行任何其他不完全向后兼容的更改,你必须将版本号提升到2.0。如果你将其称为1.8,就相当于声称新版本与1.7版本兼容,而你的用户可能会发现他们的项目构建失败。
# Cargo.lock
Cargo.toml
中的版本号故意设置得比较灵活,但我们不希望每次构建时Cargo都将我们的库升级到最新版本。想象一下,你正在深入调试时,cargo build
突然将你使用的库升级到了新版本,这可能会极具破坏性。在调试过程中任何变动都不是好事。实际上,对于库来说,任何意外的更改都不是好事。
因此,Cargo有一个内置机制来防止这种情况发生。你第一次构建项目时,Cargo会输出一个Cargo.lock
文件,记录它使用的每个包的确切版本。后续构建会参考这个文件,并继续使用相同的版本。只有当你手动在Cargo.toml
文件中提高版本号,或者运行cargo update
时,Cargo才会升级到较新的版本:
$ cargo update
Updating registry `https://github.com/rust-lang/crates.io-index`
Updating libc v0.2.7 -> v0.2.11
Updating png v0.4.2 -> v0.4.3
2
3
4
cargo update
只会升级到与你在Cargo.toml
中指定的版本兼容的最新版本。如果你指定了image = "0.6.1"
,并且想要升级到0.10.0版本,你必须在Cargo.toml
中更改这个设置。下次构建时,Cargo会将image
库更新到新版本,并将新版本号存储在Cargo.lock
中。
前面的示例展示了Cargo更新托管在crates.io上的两个包。对于存储在Git中的依赖项,情况也非常类似。假设我们的Cargo.toml
文件包含以下内容:
image = { git = "https://github.com/Piston/image.git", branch = "master" }
如果cargo build
看到有Cargo.lock
文件,它就不会从Git仓库拉取新的更改。相反,它会读取Cargo.lock
文件,并使用与上次相同的修订版本。但是cargo update
会从master
分支拉取更改,这样我们下次构建时就会使用最新的修订版本。
Cargo.lock
是自动为你生成的,通常你不需要手动编辑它。不过,如果你的项目是一个可执行文件,你应该将Cargo.lock
提交到版本控制中。这样,每个构建你项目的人都能始终获得相同的版本。Cargo.lock
文件的历史记录会记录你的依赖项更新情况。
如果你的项目是一个普通的Rust库,无需提交Cargo.lock
。你库的下游用户会有包含他们整个依赖图版本信息的Cargo.lock
文件,他们会忽略你库的Cargo.lock
文件。在极少数情况下,如果你的项目是一个共享库(即输出是.dll
、.dylib
或.so
文件),没有下游的Cargo用户,这种情况下你应该提交Cargo.lock
。
Cargo.toml
灵活的版本指定方式让你在项目中使用Rust库变得很容易,并且最大限度地提高了库之间的兼容性。Cargo.lock
的记录功能支持在不同机器上进行一致的、可重现的构建。它们共同帮助你避免陷入依赖地狱。
# 向crates.io发布包
你决定将自己的蕨类植物模拟库作为开源软件发布。恭喜!这一步其实很简单。
首先,确保Cargo能够为你打包这个包。
$ cargo package
warning: manifest has no description, license, license-file, documentation,
homepage or repository. See
http://doc.crates.io/manifest.html#package-metadata
for more info.
Packaging fern_sim v0.1.0 (file:///../fern_sim)
Verifying fern_sim v0.1.0 (file:///../fern_sim)
Compiling fern_sim v0.1.0
(file:///../fern_sim/target/package/fern_sim-0.1.0)
2
3
4
5
6
7
8
9
cargo package
命令会创建一个文件(在这个例子中是target/package/fern_sim-0.1.0.crate
),其中包含你库的所有源文件,包括Cargo.toml
。这个文件就是你要上传到crates.io与他人分享的文件。(你可以使用cargo package --list
查看包含哪些文件。)然后,Cargo会像你未来的用户一样,从这个.crate
文件构建你的库,再次检查是否正确。
Cargo会发出警告,提示Cargo.toml
的[package]
部分缺少一些对下游用户很重要的信息,比如你分发代码所遵循的许可证。警告中的URL是一个很好的参考资源,所以我们这里就不详细解释所有字段了。简而言之,你可以通过在Cargo.toml
中添加几行内容来解决这个警告:
[package]
name = "fern_sim"
version = "0.1.0"
edition = "2018"
authors = ["You <you@example.com>"]
license = "MIT"
homepage = "https://fernsim.example.com/"
repository = "https://gitlair.com/sporeador/fern_sim"
documentation = "http://fernsim.example.com/docs"
description = """
Fern simulation, from the cellular level up.
"""
2
3
4
5
6
7
8
9
10
11
12
注意:一旦你在crates.io上发布这个包,任何下载你包的人都能看到Cargo.toml
文件。所以,如果authors
字段包含你想保密的电子邮件地址,现在是时候更改它了。
在这个阶段有时还会出现另一个问题,即你的Cargo.toml
文件可能通过路径指定了其他包的位置,如“指定依赖项”中所示:
image = { path = "vendor/image" }
对于你和你的团队来说,这可能没问题。但很自然地,当其他人下载fern_sim
库时,他们计算机上不会有和你一样的文件和目录。因此,Cargo会忽略自动下载的库中的path
键,这可能会导致构建错误。不过,解决方法很简单:如果你的库要发布到crates.io上,它的依赖项也应该在crates.io上。指定版本号而不是路径:
image = "0.13.0"
如果你愿意,也可以同时指定路径(在本地构建时优先使用)和版本号(供其他所有用户使用):
image = { path = "vendor/image", version = "0.13.0" }
当然,在这种情况下,你有责任确保两者保持同步。
最后,在发布包之前,你需要登录crates.io并获取一个API密钥。这个步骤很简单:一旦你在crates.io上有了账户,你的“账户设置”页面会显示一个cargo login
命令,如下所示:
$ cargo login 5j0dV54BjlXBpUUbfIj7G9DvNl1vsWW1
Cargo会将密钥保存在一个配置文件中,API密钥应该像密码一样保密。所以,只在你能控制的计算机上运行这个命令。
完成上述操作后,最后一步是运行cargo publish
:
$ cargo publish
Updating registry `https://github.com/rust-lang/crates.io-index`
Uploading fern_sim v0.1.0 (file:///../fern_sim)
2
3
这样,你的库就和crates.io上的数千个其他库一起发布了。
# 工作区(Workspaces)
随着项目的不断发展,你最终会编写许多包。它们一起存放在一个源存储库中:
fernsoft/
├── .git/...
├── fern_sim/
│ ├── Cargo.toml
│ ├── Cargo.lock
│ ├── src/...
│ └── target/...
├── fern_img/
│ ├── Cargo.toml
│ ├── Cargo.lock
│ ├── src/...
│ └── target/...
└── fern_video/
├── Cargo.toml
├── Cargo.lock
├── src/...
└── target/...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
按照Cargo的工作方式,每个包都有自己的构建目录target
,其中包含该包所有依赖项的独立构建结果。这些构建目录是完全独立的。即使两个包有一个共同的依赖项,它们也不能共享任何编译后的代码。这很浪费资源。
你可以使用Cargo工作区来节省编译时间和磁盘空间。Cargo工作区是一组共享同一个构建目录和Cargo.lock
文件的包。
你只需要在存储库的根目录创建一个Cargo.toml
文件,并在其中添加以下内容:
[workspace]
members = ["fern_sim", "fern_img", "fern_video"]
2
这里的fern_sim
等是包含你包的子目录名称。删除这些子目录中残留的Cargo.lock
文件和target
目录。
完成这些操作后,在任何一个包中运行cargo build
,都会自动在根目录下创建并使用一个共享的构建目录(在这个例子中是fernsoft/target
)。cargo build --workspace
命令会构建当前工作区中的所有包。cargo test
和cargo doc
也接受--workspace
选项。
# 更多实用功能
如果你还没有被Rust惊艳到,Rust社区还有一些零碎但实用的功能:
- 当你在crates.io上发布一个开源包时,多亏了Onur Aslan,你的文档会自动渲染并托管在docs.rs上。
- 如果你的项目在GitHub上,Travis CI可以在每次推送时构建和测试你的代码。设置起来出奇地简单,详情见travis-ci.org。如果你已经熟悉Travis,这个
.travis.yml
文件可以帮助你开始:
language: rust
rust:
- stable
2
3
- 你可以从包的顶级文档注释生成
README.md
文件。Livio Ribeiro提供了一个第三方Cargo插件来实现这个功能。运行cargo install cargo-readme
安装该插件,然后运行cargo readme --help
了解如何使用它。
我们还可以继续列举。
Rust是一门新语言,但它旨在支持大型、有挑战性的项目。它有很棒的工具和活跃的社区。系统程序员也能拥有优质的开发体验。