【Rust】Crate 和 Module

Crates

Rust 程序是由 crate 组成的,每个 crate 都是一个完整的的单元:单个库或可执行文件的所有源代码,以及任何相关的测试、示例、工具、配置和其他东西。可以使用 cargo build --verbose 查看项目中使用了哪些 crates

通常项目的依赖都是配置在 Cargo.toml 文件中,例如:

1
2
3
4
[dependencies]
num = "0.4"
image = "0.13"
crossbeam = "0.8"

可以通过 cargo buildcargo install 或者 cargo add 下载依赖代码。一旦有了源代码,Cargo 就会编译所有的 crate。它为项目依赖图中的每个 crate 运行一次 rustcRust 编译器)。编译库时,Cargo 使用 --crate-type lib 选项。这告诉 rustc 不要寻找 main() 函数,而是生成一个 .rlib 文件,其中包含可用于创建二进制文件和其他 .rlib 文件的编译代码。例如:

1
rustc --crate-name num --edition=2018 /Users/fudenglong/.cargo/registry/src/mirrors.ustc.edu.cn-61ef6e0cd06fb9b8/num-0.4.0/src/lib.rs --error-format=json --json=diagnostic-rendered-ansi,artifacts,future-incompat --crate-type lib --emit=dep-info,metadata,link -C embed-bitcode=no -C split-debuginfo=unpacked -C debuginfo=2 --cfg 'feature="default"' --cfg 'feature="num-bigint"' --cfg 'feature="std"' -C metadata=b84820de50dc7f78 -C extra-filename=-b84820de50dc7f78 --out-dir /Users/fudenglong/WORKDIR/rust/mandelbrot/target/debug/deps -L dependency=/Users/fudenglong/WORKDIR/rust/mandelbrot/target/debug/deps --extern num_bigint=/Users/fudenglong/WORKDIR/rust/mandelbrot/target/debug/deps/libnum_bigint-bd772250e89d4bb9.rmeta --extern num_complex=/Users/fudenglong/WORKDIR/rust/mandelbrot/target/debug/deps/libnum_complex-d3fd80f953e1ac52.rmeta --extern num_integer=/Users/fudenglong/WORKDIR/rust/mandelbrot/target/debug/deps/libnum_integer-7ff0466209086397.rmeta --extern num_iter=/Users/fudenglong/WORKDIR/rust/mandelbrot/target/debug/deps/libnum_iter-2b149e71dbad2afc.rmeta --extern num_rational=/Users/fudenglong/WORKDIR/rust/mandelbrot/target/debug/deps/libnum_rational-1686ad6eb82c18d4.rmeta --extern num_traits=/Users/fudenglong/WORKDIR/rust/mandelbrot/target/debug/deps/libnum_traits-deaceb32c41a04f1.rmeta --cap-lints allow

对于每个 rustc 命令,Cargo 都会传递 --extern 选项,给出 crate 将使用的每个库的文件名。这样,当 rustc 看到像 use num::bigint::BigInt; 这样的代码行时,它可以确定 num 是另一个 crate 的名称,并且通过 Cargo,可以在磁盘上找到已编译的 crateRust 编译器需要访问这些 .rlib 文件,因为它们包含库的编译代码, Rust 会将该代码静态链接到最终的可执行文件中。 .rlib 还包含类型信息,因此 Rust 可以检查我们在代码中使用的库功能是否确实存在,以及我们是否正确使用它们,它还包含 crate 的公共内联函数、泛型和宏的副本等。

如果编译程序时,Cargo 使用 --crate-type bin,结果将会生成目标平台的二进制可执行文件。

Edition

Rust 具有极强的兼容性保证,在 Rust 1.0 上编译的任何代码都必须在 Rust 1.50 上编译。但有时社区会遇到令人信服的扩展语言的提议,这会导致旧代码不再编译。例如,经过多次讨论,Rust 确定了一种支持异步编程的语法,将标识符 asyncawait 重新用作关键字。但是这种语言更改会破坏任何使用 asyncawait 作为变量名称的现有代码。

为了在不破坏现有代码的情况下发展,Rust 使用 EditionRust 2015 版与 Rust 1.0 兼容。2018asyncawait 更改为关键字,简化了模块系统,并引入了与 2015 不兼容的各种其他语言更改。打开 Cargo.toml 我们会看到如下的 Edition 声明,默认是 2021

1
2
3
4
[package]
name = "mandelbrot"
version = "0.1.0"
edition = "2021"

Rust 的编译器承诺始终接受该语言的所有现存版本,并且程序可以自由地混合使用不同版本编写的 crate,例如,2015crate 依赖于 2018crate 甚至可以,换句话说,一个 crate 的版本只影响其源代码的解释方式,代码编译完成之后,就没有所谓的 Edition 区分了,在学要使用语言的新功能时,我们只需要修改 Edition即可。

版本不会每年都发布,只有在 Rust 项目决定需要时才会发布,例如,没有 2020 版,将版本设置为 2020 会导致错误。Rust 版本指南 涵盖了每个版本中引入的更改,并提供了版本系统的良好背景,对于新代码,建议总是使用最新的版本,cargo new 默认在最新版本上创建新项目。如果有一个用旧版 Rust 编写的 cratecargo fix 命令会帮助你自动将代码升级到新版本。

Profile

配置文件提供了一种更改编译器设置的方法,影响优化和调试符号等内容。Cargo4 个内置配置文件:devreleasetestbench。如果未在命令行上指定配置文件,则会根据正在运行的命令自动选择配置文件。除了内置配置文件外,还可以指定自定义的用户定义配置文件。

可以使用 [profile]Cargo.toml 中更改配置文件设置,在每个命名配置文件中,可以使用如下键/值对更改各个设置:

1
2
3
[profile.dev]
opt-level = 1 # Use slightly better optimizations.
overflow-checks = false # Disable integer overflow checks.

cargo build 会使用 [profile.dev] 的配置,cargo build --release 会使用 [profile.release] 的配置,cargo test 使用 [profile.test] 中的配置。

更多详细的内容请看 Cargo Profiles

Module

crate 就是第三方模块,用于项目之间的代码共享,而 Module 是项目内的代码组织。它们充当 Rust 的命名空间、构成 Rust 程序或库的函数、类型、常量等的容器。一个模块如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
mod spores {
use cells::{Cell, Gene};
/// A cell made by an adult fern. It disperses on the wind as part of
/// the fern life cycle. A spore grows into a prothallus -- a whole
/// separate organism, up to 5mm across -- which produces the zygote
/// that grows into a new fern. (Plant sex is complicated.)
pub struct Spore {
...
}
/// Simulate the production of a spore by meiosis.
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
...
}
/// Extract the genes in a particular spore.
pub(crate) fn genes(spore: &Spore) -> Vec<Gene> {
...
}
/// Mix genes to prepare for meiosis (part of interphase).
fn recombine(parent: &mut Cell) {
...
}
...
}

本例中,模块是 Spore 结构和两个函数的集合。pub 关键字使得标记的项公开,因此可以从模块外部访问它。

一个函数被标记为 pub(crate),这意味着它在这个 crate 内的任何地方都可用,但不作为外部接口的一部分公开。它不能被其他 crate 使用,也不会出现在这个 crate 的文档中。被标记为 pub 的项目通常称为导出该项目,任何未标记为 pub 的内容都是私有的,只能在定义它的同一模块或任何子模块中使用:

1
2
let s = spores::produce_spore(&mut factory); // ok
spores::recombine(&mut cell); // error: `recombine` is private

模块嵌套

模块可以嵌套,一个模块可能仅仅是子模块的集合:

1
2
3
4
5
6
7
8
9
10
11
mod plant_structures {
pub mod roots {
...
}
pub mod stems {
...
}
pub mod leaves {
...
}
}

如果希望嵌套模块中的项目对其他 crate 可见,必须将到达这个项的所有模块都标记为 pub。也可以指定 pub(super),使项目仅对父模块可见,以及 pub(in <path>),使其在特定父模块及其后代中可见。这对于深度嵌套的模块特别有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
mod plant_structures {
pub mod roots {
pub mod products {
pub(in crate::plant_structures::roots) struct Cytokinin {
...
}
}
use products::Cytokinin; // ok: in `roots` module
}
use roots::products::Cytokinin; // error: `Cytokinin` is private
}

// error: `Cytokinin` is private
use plant_structures::roots::products::Cytokinin;

通过这种方式,我们可以编写一个完整的程序,包含大量代码和整个模块层次结构,以我们想要的任何方式组织起来,而所有这些都在一个源文件中。

模块和文件系统

如果将模块以单个文件形式组织,这种方式工作很痛苦,大型项目中往往需要拆分,将不同的功能的代码以不同的文件区分,使得代码在逻辑上,物理组织上都能很清晰。

单文件模块

之前我们是在 spores 模块中使用括号将模块的内容包裹起来的,现在可以在单个源码文件中开发模块。我们在 main.rs 中声明如下模块:

main.rs
1
mod spores;

然后在 spores.rs 开发该模块的代码:

spores.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// A cell made by an adult fern...
pub struct Spore {
...
}

/// Simulate the production of a spore by meiosis.
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
...
}

/// Extract the genes in a particular spore.
pub(crate) fn genes(spore: &Spore) -> Vec<Gene> {
...
}

/// Mix genes to prepare for meiosis (part of interphase).
fn recombine(parent: &mut Cell) {
...
}

公开和私有的原则和之前讲的是相同的,Rust 从不单独编译模块,即使它们在单独的文件中:当你构建一个 crate 时,会重新编译它的所有模块。当 Rust 看到一个模块时,例如上面的 mod spores; 时,它会检查 spores.rsspores/mod.rs 是否存在,如果都存在或者都不存在,就会报错。

模块目录(一)

但是当我们的模块中包含子模块时,就不能像 spores.rs 那样单独处理了,就像之前的 plant_structures 模块。如果,我们有下面这样的结构:

1
2
3
4
5
6
7
8
9
10
fern_sim/
├── Cargo.toml
└── src/
├── main.rs
├── spores.rs
└── plant_structures/
├── mod.rs
├── leaves.rs
├── roots.rs
└── stems.rs

main.rs,我们声明 plant_structures 模块,这会让 Rust 加载 plant_structures/mod.rs

main.rs
1
pub mod plant_structures;

然后,我们再声明子模块:

plant_structures/mod.rs
1
2
3
pub mod roots;
pub mod stems;
pub mod leaves;

这三个模块的内容存储在名为 leaves.rsroots.rsstems.rs 的单独文件中,和 plant_structures/mod.rs 同级。

模块目录(二)

我们也可以使用文件和目录一起组成模块,如果之前的 stems 需要包含 xylemphloem,我们选择保留 plant_structures/stems.rs,然后再添加一个 stems 目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
fern_sim/
├── Cargo.toml
└── src/
├── main.rs
├── spores.rs
└── plant_structures/
├── mod.rs
├── leaves.rs
├── roots.rs
├── stems/
│ ├── phloem.rs
│ └── xylem.rs
└── stems.rs

然后,我们可以在 stems.rs 中声明两个新模块:

plant_structures/stems.rs
1
2
pub mod xylem;
pub mod phloem;

所以这里有三种模块组织方式:

  • 模块在他们自己的文件中;
  • 模块在他们自己的目录中,带有 mod.rs
  • 模块在他们自己的文件中,带有包含子模块的补充目录;

导入

:: 操作符被用于访问其他模块的功能。我们可以直接使用其他模块中的功能而不实现导入,例如:

1
std::mem::swap(&mut s1, &mut s2);

std 指向标准库的顶层模块,mem 是其中的一个子模块,std::mem::swap 只是其中的一个导出函数。上面的这种使用访问方式有点冗长,另一种方式是将要使用的功能导入当前的模块,使用 usestd::mem 引入到当前模块,并且定义新的别 mem

1
2
3
4
use std::mem;
if s1 > s2 {
mem::swap(&mut s1, &mut s2);
}

我们可以写 use std::mem::swap; 导入 swap 函数本身而不是 mem 模块。但最好的方式是:导入类型、trait和模块(如 std::mem),然后使用相对路径访问其中的函数、常量和其他成员。例如:

1
2
3
use std::collections::{HashMap, HashSet}; // 同时导入
use std::fs::{self, File}; // 导入 `std::fs` 和 `std::fs::File`.
use std::io::prelude::*; // import everything

我们也可以使用 as 对导入的名称进行重命名:

1
2
3
4
use std::io::Result as IOResult;
// This return type is just another way to write `std::io::Result<()>`:
fn save_spore(spore: &Spore) -> IOResult<()>
...

子模块不会自动导入父模块的内容,假设我们的 proteins/mod.rs 中内容是这样子的:

proteins/mod.rs
1
2
pub enum AminoAcid { ... }
pub mod synthesis;

那么模块 proteins/synthesis.rs 中如果不导入 AminoAcid 是不能直接使用的:

proteins/synthesis.rs
1
pub fn synthesize(seq: &[AminoAcid]) // error: can't find type `AminoAcid`

而是,每个模块都是以一个空的状态开始的,必须手动导入它使用的任何内容(也不完全是空的,有预导入):

proteins/synthesis.rs
1
2
3
use super::AminoAcid; // explicitly import from parent

pub fn synthesize(seq: &[AminoAcid]) // error: can't find type `AminoAcid`

默认情况下,是从当前模块开始导入:

proteins/mod.rs
1
2
3
4
5
pub mod synthesis;

pub enum AminoAcid { ... }

use synthesis::synthesize; // 从 synthesis 子模块导入

self 是当模块的昵称,因此在 proteins/mod.rs 中可以这样写:

proteins/mod.rs
1
2
3

// 这样可以直接用 Lys 而不是 `AminoAcid::Lys`
use self::AminoAcid::*;

也可以简化为:

proteins/mod.rs
1
use AminoAcid::*;

除了从顶层模块导入,从 self 当前模块导入或者从当前的子模块导入之外,还可以通过 super 或者 crate 关键字进行导入,其中 super 表示父模块,而 crate 表示当前模块的 crate

使用相对于 crate 根目录的导入而不是相对于当前路径的导入,有助于在项目中移动代码。因为如果当前模块的路径发生更改,所有导入都不会中断。例如:

proteins/synthesis.rs
1
2
use crate::proteins::AminoAcid; // 显示从 crate 的根导入
pub fn synthesize(seq: &[AminoAcid]) // ok

之前看了从父模块导入子模块,但是如果从子模块导入父模块的内容,可以使用 super::

如果你有一个和正在使用的 crate 同名的模块,在导入的时候就要注意了,否则会引起错误,例如,如果引用了 image,自己也有个 image 模块:

1
2
3
4
5
6
7
8
mod image {
pub struct Sampler {
...
}
}

// 错误:引入 image crate 还是 image 模块
use image::Pixels;

解决这个问题的方法是使用绝对导入,导入以 :: 开始,例如,如果要导入第三方 image,可以这样写:

1
use ::image::Pixels; // the `image` crate's `Pixels`

引入内部的 image module 可以这样写:

1
use self::image::Sampler; // the `image` module's `Sampler`

预导入

之前说每个模块都以空的状态开始,但其实也不完全是空的,因为 Rust 有很多常用的类型,为了简化编程,Rust 会帮我们导入,不用我们显示导入。例如:VecResult

自动导入的内容可以看这里,std::prelude

别名导出

use 只是将我们使用的内容从其他模块或者 crate导入并且在本模块起了个没别名,但是它也可以将导入的内容重新导出。例如:

plant_structures/mod.rs
1
2
pub use self::leaves::Leaf;
pub use self::roots::Root;

这意味着 LeafRootplant_structures 模块中的公共内容,它们也仍然是 self::leaves::Leafself::roots::Root 的别名。

结构体导出

module 可以包含用户定义的结构体类型,使用 struct 关键字引入,它和它的字段也可以由 pub 声明是否导出。一个简单的结构如下所示:

1
2
3
4
pub struct Fern {
pub roots: RootSet,
pub stems: StemSet
}

结构体的所有字段,甚至私有字段,都可以在声明结构体的模块及其子模块中访问,在模块之外,只能访问导出字段。

静态变量和常量

const 关键字引入了一个常量。 语法和 let 一样,只是它可能被标记为 pub,并且类型是必需的。此外,大写名称对于常量来说是常规的:

1
pub const ROOM_TEMPERATURE: f64 = 20.0; // degrees Celsius

static 关键字引入了一个静态项,和 const

1
pub static ROOM_TEMPERATURE: f64 = 68.0; // degrees Fahrenheit

常量有点像 C 中的 #define,值会被编译到使用的地方。静态变量是在程序开始运行之前设置并持续到退出的变量。在代码中使用常量作为 magic number 和字符串,对大量数据或任何需要借用常量值的引用的时候使用静态。

没有 mut 类型常量,静态变量可以标记为 mutRust 无法强制执行其关于对 mut 静态变量的独占访问的规则。因此,它们本质上是非线程安全的,安全代码根本不能使用它们,Rust 不鼓励全局可变状态:

1
2
static mut PACKETS_SERVED: usize = 0;
println!("{} served", PACKETS_SERVED); // error: use of mutable static

开发 library 项目

如果想要将项目编译成一个 lib 而不是一个可执行文件,我们只需要三步:

  • src/main.rs 重命名成 src/lib.js

  • 添加 pub 关键字给 src/lib.js 中的导出内容;

  • 移除 src/main.rs 中的 main 函数;

我们不需要更改 Cargo.toml 中的任何内容 默认情况下,cargo build 查看源目录中的文件并确定要构建的内容。当它看到文件 src/lib.rs 时,就知道需要去构建一个 libsrc/lib.rs 中的代码构成了库的根模块,使用我们的其他 crate 只能访问此根模块的公共项目。

src/bin 目录

我们可以让一个项目是 lib,也可以让它同时编译成一个可执行程序,只是需要将 main 函数移到目录 sr/bin 中,而且可以有多个 main 函数,例如我的项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
~/WORKDIR/rust/mandelbrot 20:15:24
$ tree --help -l 4

/Users/fudenglong/WORKDIR/rust/mandelbrot
├── Cargo.lock
├── Cargo.toml
├── package-lock.json
├── package.json
└── src
├── bin
| ├── mandelbrot.rs
| └── mandelbrot_v2
| └── main.rs
└── lib.rs
~/WORKDIR/rust/mandelbrot 20:15:28

各个文件的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use num::bigint::BigInt;
use num::rational::{BigRational, Ratio};
use num::FromPrimitive;

pub fn approx_sqrt(number: u64, iterations: usize) -> BigRational {
let start: Ratio<BigInt> = Ratio::from_integer(FromPrimitive::from_u64(number).unwrap());
let mut approx = start.clone();

for _ in 0..iterations {
approx = (&approx + (&start / &approx))
/ Ratio::from_integer(FromPrimitive::from_u64(2).unwrap());
}
approx
}
1
2
3
4
5
use mandelbrot::approx_sqrt;

fn main() {
println!("{}", approx_sqrt(10, 4)); // prints 4057691201/1283082416
}
1
2
3
4
5
use mandelbrot::approx_sqrt;

fn main() {
println!("{}", approx_sqrt(10, 4)); // prints 4057691201/1283082416
}
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"

我们可以指定运行不同的 main 函数:

~/WORKDIR/rust/mandelbrot 20:25:13
$ cargo run --bin mandelbrot
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
    Running `target/debug/mandelbrot`
4057691201/1283082416

~/WORKDIR/rust/mandelbrot 20:25:15
$ cargo run --bin mandelbrot_v2
  Compiling mandelbrot v0.1.0 (/Users/fudenglong/WORKDIR/rust/mandelbrot)
    Finished dev [unoptimized + debuginfo] target(s) in 0.15s
    Running `target/debug/mandelbrot_v2`
4057691201/1283082416

属性

Rust 程序中的任何项目都可以用属性进行修饰。属性是 Rust 用于向编译器编写杂项指令和建议的语法。例如,假设您收到以下警告:

libgit2.rs: warning: type `git_revspec` should have a camel case name
such as `GitRevspec`, #[warn(non_camel_case_types)] on by default

但是你选择这个名字是有原因的,你希望屏蔽这个告警,可以通过在类型上添加 #[allow] 完成:

1
2
3
4
#[allow(non_camel_case_types)]
pub struct git_revspec {
...
}

条件编译是使用属性编写的另一个功能,即 #[cfg]

1
2
3
// 只有在编译安卓平台的项目时包含这个模块
#[cfg(target_os = "android")]
mod mobile;

#[cfg] 常用的属性可以看 条件编译

给函数添加 #[inline] 属性可以建议编译器根据情况决定是否内联函数。

1
2
3
4
#[inline]
fn do_osmosis(c1: &mut Cell, c2: &mut Cell) {
...
}

当一个 crate 中定义的函数或方法在另一个 crate 中被调用时,Rust 不会内联它,除非它是泛型的或显式标记为 #[inline]Rust 还支持更直接的 #[inline(always)],要求在每个调用点内联扩展函数,并支持 #[inline(never)],要求永远不要内联函数。

一些属性,如 #[cfg]#[allow],可以附加到整个模块并应用于其中的所有内容。其他的,如 #[test]#[inline],必须附加到单个项目,每个属性都是可以指定参数进行定制,这里看完整的属性列表

要将属性附加到整个 crate,请将其添加到 main.rslib.rs 文件的顶部,在任何内容之前,然后写 #! 而不是#,像这样:

libgit2_sys/lib.rs
1
2
3
4
5
6
7
8
9
#![allow(non_camel_case_types)]

pub struct git_revspec {
...
}

pub struct git_error {
...
}

#! 通常只用于文件的开头,用于将属性附加到整个模块或 crate。某些属性总是使用 #! 语法,因为它们只能应用于整个 crate。 例如,#![feature] 属性用于打开 Rust 语言和库的不稳定特性,这些特性是实验性的,因此可能存在错误,或者将来可能会被更改或删除。

例如,Rust 为跟踪宏的扩展提供了实验性支持,比如 assert!,但是由于这种支持是实验性的,你只能通过

  • (1) 安装 nightly 版本的 Rust
  • (2) 来使用#![feature(trace_macros)] 明确使用 trace_macros
1
2
3
4
5
6
7
8
#![feature(trace_macros)]
fn main() {
// I wonder what actual Rust code this use of assert_eq!
// gets replaced with!
trace_macros!(true);
assert_eq!(10*10*10 + 9*9*9, 12*12*12 + 1*1*1);
trace_macros!(false);
}

单元测试

Rust 内置了一个单元测试框架,任何由 #[test] 标记的普通函数是测试函数:

1
2
3
4
5
6
#[test]
fn math_works() {
let x: i32 = 1;
assert!(x.is_positive());
assert_eq!(x + 1, 2);
}

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

可以通过 cargo test TESTNAME 指定要运行的测试函数需要包含的关键字,以运行某些测试。

通常使用 asset!asset_eq! 在测试中对结果进行断言,如果断言失败,程序就会 panicasset!asset_eq! 没有包含在 release 版本中,如果仅仅是构建 debug 版本,可以使用 debug_assert!debug_assert_eq!

为了测试错误情况,可以添加 #[should_panic] 给测试函数,这个例子中我们也使用了 #allow 允许我们无条件 panic

1
2
3
4
5
6
7
8
/// This test passes only if division by zero causes a panic,
/// as we claimed in the previous chapter.
#[test]
#[allow(unconditional_panic, unused_must_use)]
#[should_panic(expected="divide by zero")]
fn test_divide_by_zero_error() {
1 / 0; // should panic!
}

还可以从测试中返回 Result<(), E>

1
2
3
4
5
6
7
/// if 1024 是有效的数字,这个测试会通过

#[test]
fn main() -> Result<(), std::num::ParseIntError> {
i32::from_str_radix("x1024xx", 10)?;
Ok(())
}

标有#[test] 的函数是会条件编译的。普通的 cargo buildcargo build --release 会跳过测试代码。但运行 cargo test 时,Cargo 会构建程序两次:一次以普通方式,一次来运行测试。这意味着单元测试可以与他们测试的代码一起使用,如果需要,可以访问内部实现细节,而且没有运行时成本。但是,它可能会导致一些警告。 例如:

1
2
3
4
5
6
7
8
9
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));
}

在省略了测试代码的构建中,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

按照约定,当测试代码需要一些代码支持时,需要放进 tests 模块,并且使用 #[cfg(test)] 标记整个模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#[cfg(test)] // include this module only when testing
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));
}

}

集成测试

我们可以在 src 目录同级创建一个 tests 目录,用于放我们的集成测试代码,集成测试将我们的库代码当做外部 crate 链接然后进行测试,因此集成测试也只能测试公共 API

tests/unfurl.rs
1
2
3
4
5
6
7
8
9
10
11
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());
}

cargo test 命令会同时运行集成测试和单元测试,如果只想运行 tests/unfurl.rs 中的集成测试代码,可以使用 cargo test --test unfurl

文档

cargo doc 命令可以生成 crate 的文档,文档中会包含库中 pub 内容的文档,文档就是添加到它们上面的注释。例如:

1
2
3
4
/// Simulate the production of a spore by meiosis.
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
...
}

Rust 看到三个斜线开头的注释时,就相当于使用 #[doc],上面的例子等价于:

1
2
3
4
#[doc = "Simulate the production of a spore by meiosis."]
pub fn produce_spore(factory: &mut Sporangium) -> Spore {
...
}

除了 /// 样式的注释之外,还有 //!,被当做 #![doc],经常用于模块或者 crate,例如 src/lib.rs 可能像下面这样:

1
2
//! Simulate the growth of ferns, from the level of
//! individual cells on up.

注释的内容会被当做 Markdown 格式,还可以包含 Html 标记。注释有个特色功能就是可以用 Rust 的模块组织路径去引用 Rust 定义的函数,结构体等。例如:

1
2
3
4
5
6
7
/// Create and return a [`VascularPath`] which represents the path of
/// nutrients from the given [`Root`][r] to the given [`Leaf`](leaves::Leaf).
///
/// [r]: roots::Root
pub fn trace_path(leaf: &leaves::Leaf, root: &roots::Root) -> VascularPath {
...
}

还可以添加搜索别名,以便使用内置搜索功能更轻松地查找内容。在这个 crate 的文档中搜索 pathroute 都能找到 VascularPath

1
2
3
4
#[doc(alias = "route")]
pub struct VascularPath {
...
}

还可以使用 backticks 在运行文本的中间设置一些代码,在输出中,这些片段将被格式化为固定宽度的字体。可以通过缩进4个空格来添加更大的代码示例:

1
2
3
4
5
/// A block of code in a doc comment:
///
/// if samples::everything().works() {
/// println!("ok");
/// }

也可以使用 Markdown 格式的代码风格,有同样效果:

1
2
3
4
5
6
7
/// Another snippet, the same code, but written differently:
///
/// ```
/// if samples::everything().works() {
/// println!("ok");
/// }
/// ```

还有很多注释类型,可以参考这里 注释

文档测试

当运行测试的时候,Rust 会检查所有出现在文档中的代码块,编译成一个独立的 crate,然后和我们的库链接并执行。下面是一个用 cargo new --lib ranges 创建的例子,把下面的代码的放入 ranges/src/lib.rs 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use std::ops::Range;

/// Return true if two ranges overlap.
///
/// assert_eq!(ranges::overlap(0..7, 3..10), true);
/// assert_eq!(ranges::overlap(1..5, 101..105), false);
///
/// If either range is empty, they don't count as overlapping.
///
/// 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
}

使用 cargo doc --no-deps --open 应该能看到下面的文档:

如果运行 cargo test 会看到如下的信息:

~/WORKDIR/rust/ranges ⌚ 9:57:08
$  cargo test               
    Finished test [unoptimized + debuginfo] target(s) in 0.00s
    Running unittests src/lib.rs (target/debug/deps/ranges-13060b2daf473d43)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

  Doc-tests ranges

running 2 tests
test src/lib.rs - overlap (line 5) ... ok
test src/lib.rs - overlap (line 10) ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.63s

如果你将 cargo test --verbose,你会看到它使用 rustdoc --test 来运行这两个文档中的测试。rustdoc 将每个代码示例存储在一个单独的文件中,添加几行样例代码,以生成两个程序。 例如:

1
2
3
4
5
use ranges;
fn main() {
assert_eq!(ranges::overlap(0..7, 3..10), true);
assert_eq!(ranges::overlap(1..5, 101..105), false);
}

doc test 背后的想法不是把所有的测试都放在文档中,而是确保文档中的代码能正常编译运行。一个代码示例将包含一些诸如导入类的东西,但是如果想在代码中隐藏,可以以 # 开头:

1
2
3
4
5
6
7
8
/// Let the sun shine in and run the simulation for a given
/// amount of time.
///
/// # use fern_sim::Terrarium;
/// # use std::time::Duration;
/// # let mut tm = Terrarium::new();
/// tm.apply_sunlight(Duration::from_secs(60));
///

生成的文档如下图所示,省略了 # 开头的导入:

有时在文档中展示一个完整的示例程序会很有帮助,包括一个 main 函数。 rustdoc 将包含确切字符串 fn main 的任何代码块视为一个完整的程序,并且不会向其中添加任何内容。

如果我们想要实际编译代码块,但又不行让 rust 运行它,可以添加 no_run 标记:

1
2
3
4
5
6
7
8
9
/// Upload all local terrariums to the online gallery.
///
/// ```no_run
/// let mut session = fern_sim::connect();
/// session.upload_all();
/// ```
pub fn upload_all(&mut self) {
...
}

但是如果也不想编译,那么使用 ignore,而不是 no_run。如果代码块根本不是 Rust 代码,请使用语言的名称,如 csh,或 text(表示纯文本),它将任何它无法识别的注释视为表明代码块不是 Rust,这会禁用代码高亮和文档测试。

依赖声明

关于依赖声明,详细请看 cargo 依赖声明

  • image = "0.6.1":指定版本号,从 crates.io 下载依赖:

  • image = { git = "https://github.com/Piston/image.git", rev = "528f19c" }:指定版本号和原本下载路径;

  • image = { path = "vendor/image" } 指定包含依赖源代码的相对目录;

依赖版本号

当我们在 Cargo.toml 文件中写入类似 image = "0.13.0" 的内容时,Cargo 对此的解释相当松散,它使用被认为与 0.13.0 版本兼容的最新版本的 image。兼容性规格改编自 语义化版本 2.0.0

  • 0.0 开头的版本号非常原始,以至于 Cargo 从不认为它与任何其他版本兼容;

  • 0.x 开头的版本号(其中 x 非零)被认为与 0.x 系列中的其他点版本兼容。 我们指定了 image 版本 0.6.1,但如果可用,Cargo 将使用 0.6.3

  • 一旦项目达到 1.0,由于新的主版本会破坏兼容性。因此,如果要求版本 2.0.1Cargo 可能会使用 2.17.99,而不是 3.0

默认情况下,版本号是灵活的,否则使用哪个版本的问题很快就会变得过分约束。假设一个库 libA 使用 num = "0.1.31" 而另一个库 libB 使用 num = "0.1.29"。如果版本号需要完全匹配,则没有项目能够同时使用这两个库,允许 Cargo 使用任何兼容版本是一个更实用的默认设置。

尽管如此,不同的项目在依赖关系和版本控制方面有不同的需求。可以使用运算符指定确切的版本或版本范围:

Cargo.toml 解释
image = "=0.10.0" 完全匹配 0.10.0 版本
image = ">=1.0.5" 使用 1.0.5 或者更高的版本,甚至 2.9
image = ">1.0.5 <1.1.9" 使用大于 1.0.5 但小于 1.1.9 的版本
image = "<=2.7.10" 小于 2.7.10 的任何版本
* 任何版本

Cargo.lock

Cargo.toml 中的版本号很灵活,但我们不希望 Cargo 每次构建时都将我们升级到最新的库版本。因此,Cargo 有一个内置机制来防止这种情况。第一次构建项目时,Cargo 会输出一个 Cargo.lock 文件,该文件记录了它使用的每个 crate 的确切版本。以后的构建将参考这个文件并继续使用相同的版本。Cargo 仅在你告诉它时才升级到较新的版本,通过手动增加 Cargo.toml 文件中的版本号或运行 cargo update

$ 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

cargo update 仅升级到与在 Cargo.toml 中指定的内容兼容的最新版本。如果你指定了 image = "0.6.1",并且你想升级到 0.10.0 版本,你必须在 Cargo.toml 中改变它。下次构建时,Cargo 会更新到新版本的镜像库,并将新版本号存储在 Cargo.lock 中。前面的示例显示 Cargo 更新了 crates.io 上托管的两个 crate,存储在 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.lock

Cargo.toml 灵活的版本说明符使我们可以轻松地解决依赖之间的兼容性。 Cargo.lock 保证了构建一致性。

发布到 crates.io

详情请看 发布到 crates.io

工作区间

详情请看 Cargo 工作空间