跳转到内容

Rust 文档与文档测试

docs.rs(英文)上的每一页、标准库文档的每一页,都是程序员用一种特殊的注释写在源代码里的。Rust 只用一条命令,就能把这些注释变成一个网站。你自己的代码也可以这样做。

还有第二个理由值得关心:文档里的代码示例是真正的测试。运行 cargo test 时,Rust 会编译并运行它们。如果你改了函数却忘了更新示例,测试就会失败。这样,文档就不会悄悄过时。这在编程语言里很少见,也是 Rust 最贴心的地方之一。

普通注释在 Rust 注释一页已经介绍过了。文档注释(doc comment)看起来差不多,只是多了一个字符,一共三个:

  • /// 为它下面的项编写文档:函数、结构体、枚举、字段或模块。
  • //! 为它所在的项编写文档。写在 src/lib.rs 或 src/main.rs 的开头,就是在描述整个 crate。
//! 一个小小的菜谱程序。
/// 把 `total` 克平均分成 `portions` 份。
fn split_evenly(total: u32, portions: u32) -> u32 {
total / portions
}

文档注释的第一行应该是一句简短的概括。各种工具会在列表和搜索结果里显示这一行,所以要把它写好。

文档注释不会改变程序的行为。下面是一个带文档的函数,在 main 里调用它:

/// 把 `total` 克平均分成 `portions` 份。
///
/// 除不尽的部分直接舍去,所以 10 克分成 3 份,每份是 3 克。
///
/// # Panics
///
/// `portions` 为零时会 panic。
fn split_evenly(total: u32, portions: u32) -> u32 {
if portions == 0 {
panic!("portions 必须大于零");
}
total / portions
}
fn main() {
println!("{}", split_evenly(600, 3));
println!("{}", split_evenly(10, 3));
}

输出:

200
3
▶ 动手试一试

在大多数编辑器里,把鼠标停在 main 中的 split_evenly 上,就会弹出这段说明。光这一点就值得你写文档。

文档注释用 Markdown 编写,和 GitHub 以及很多聊天软件用的格式一样。你可以使用 `code`、**粗体**、*斜体*、列表、链接和标题:

/// 返回某道菜对应的烤箱温度,单位是**摄氏度**。
///
/// 支持的菜:
///
/// - `"bread"`:220 度
/// - `"cake"`:180 度
/// - 其他:200 度
///
/// 每台烤箱都不一样,所以*一定*要用自己的烤箱确认一下。
/// 参见[摄氏温标(维基百科)](https://zh.wikipedia.org/wiki/摄氏温标)。
pub fn oven_temperature(dish: &str) -> u32 {
match dish {
"bread" => 220,
"cake" => 180,
_ => 200,
}
}

段落之间留一个空的 /// 行,就像在普通 Markdown 文件里空一行一样。函数名、类型名和值用反引号包起来,它们就会显示成代码。

Rust 程序员会反复使用几个固定的标题,这样读者就知道该去哪里找信息。上面已经出现过一个了。常见的有这些(即使是中文文档,这些标题通常也保留英文):

  • # Examples:怎么使用这个项。几乎每个公有项都应该有。
  • # Panics:函数在什么情况下会 panic,好让调用者避开。
  • # Errors:返回 Result 的函数在什么情况下返回 Err,以及原因。
  • # Safety:对于 unsafe fn,调用者必须遵守的规则(参见 Unsafe Rust)。

下面是库文件 src/lib.rs 里的 # Errors 和 # Safety:

/// 读取 `"250g"` 这样的分量,返回克数。
///
/// # Errors
///
/// 如果文本不以 `g` 结尾,
/// 或者 `g` 前面的部分不是整数,就返回错误。
pub fn parse_grams(text: &str) -> Result<u32, String> {
let number = text
.strip_suffix('g')
.ok_or(format!("“{text}”应该以 g 结尾"))?;
number
.parse()
.map_err(|_| format!("“{number}”不是整数"))
}
/// 返回第一种食材,不检查清单是否为空。
///
/// # Safety
///
/// 调用者必须确保 `ingredients` 不为空。
/// 对空 slice 调用这个函数是未定义行为。
pub unsafe fn first_unchecked(ingredients: &[String]) -> &String {
unsafe { ingredients.get_unchecked(0) }
}

注意这些标题只用了一个 #。在文档注释里,# 是 Markdown 标题,不是 Rust 的属性。

本页接下来会围绕一个小库展开。先用 cargo new --lib kitchen 创建它,然后在项目里运行:

Terminal window
cargo doc --open
Documenting kitchen v0.1.0 (/path/to/kitchen)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.91s
Generated /path/to/kitchen/target/doc/kitchen/index.html

Cargo 会在 target/doc/ 里生成一个 HTML 网站,--open 会在浏览器里打开它。它看起来和 docs.rs 上的页面一模一样:有侧边栏、搜索框,每个函数和类型都有自己的页面。它还会为你依赖的每个 crate 生成文档,没网的时候特别方便。

默认只显示公有项(标了 pub 的),因为文档是写给使用你这个库的人看的。加上 --document-private-items 就能包含所有项。

下面是 kitchen 库中 src/lib.rs 的开头。这个函数有一个 # Examples 小节,文档注释里放了一个代码块:

//! 厨房里用的小工具函数。
//!
//! `kitchen` 能换算烤箱温度、把食物分成几份,
//! 还能读取 `"250g"` 这样的分量。
/// 把摄氏温度换算成华氏温度。
///
/// # Examples
///
/// ```
/// let f = kitchen::celsius_to_fahrenheit(180.0);
/// assert_eq!(f, 356.0);
/// ```
pub fn celsius_to_fahrenheit(celsius: f64) -> f64 {
celsius * 9.0 / 5.0 + 32.0
}

现在运行 cargo test:

Doc-tests kitchen
running 1 test
test src/lib.rs - celsius_to_fahrenheit (line 10) ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Cargo 找到了第 10 行的代码块,把它变成一个小程序,编译并运行。assert_eq! 检查了结果。有几点值得注意:

  • 示例里调用的是带 crate 名的 kitchen::celsius_to_fahrenheit。文档测试会被当作别人写的程序来编译,这个程序使用你的库,所以它只能看到 pub 项,你需要写完整路径,或者加一行 use kitchen::...;。
  • 不用写 fn main。rustdoc 会自动把示例包进一个 main 里。
  • 用 assert! 和 assert_eq!(Rust 测试一页介绍过)既能展示结果,又能同时检查结果。

如果示例写错了会怎样?把 356.0 改成 350.0,再运行一次测试:

running 1 test
test src/lib.rs - celsius_to_fahrenheit (line 10) ... FAILED
failures:
---- src/lib.rs - celsius_to_fahrenheit (line 10) stdout ----
Test executable failed (exit status: 101).
stderr:
assertion `left == right` failed
left: 356.0
right: 350.0
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

这正是我们想要的。写错的示例会被马上发现,而不是几个月后把读者搞糊涂。只想运行文档测试的话,用 cargo test --doc。

有时示例需要几行会分散读者注意力的代码。文档测试里以 # (井号加空格)开头的行,会被编译,但不会显示在生成的文档里。

最常见的用法:你想演示 ? 运算符,但 ? 只能在返回 Result 的函数里使用。于是你把自己写的 main 藏起来:

/// 读取 `"250g"` 这样的分量,返回克数。
///
/// # Examples
///
/// ```
/// use kitchen::parse_grams;
///
/// # fn main() -> Result<(), String> {
/// let flour = parse_grams("250g")?;
/// assert_eq!(flour, 250);
///
/// assert!(parse_grams("很多").is_err());
/// # Ok(())
/// # }
/// ```
pub fn parse_grams(text: &str) -> Result<u32, String> {
let number = text
.strip_suffix('g')
.ok_or(format!("“{text}”应该以 g 结尾"))?;
number
.parse()
.map_err(|_| format!("“{number}”不是整数"))
}

在浏览器里,读者只会看到有意思的四行:use、用了 ? 的调用,以及两个断言。测试时仍然会编译全部代码。

不是每个示例都能直接运行。你可以在开头的反引号后面写一个词,告诉 rustdoc 该怎么处理:

  • ```no_run:编译示例,但不运行。适合读文件、建立网络连接或者无限循环的代码。
  • ```should_panic:运行它,并且预期它会 panic。如果它没有 panic,测试就失败。
  • ```ignore:完全不编译。用于草稿和伪代码。先试试 no_run,因为被忽略的示例可能在没人注意的情况下过时。
  • ```text:根本不是 Rust 代码,只是普通文本(比如示例输出)。
/// 把 `total` 克平均分成 `portions` 份。
///
/// # Examples
///
/// ```
/// assert_eq!(kitchen::split_evenly(600, 3), 200);
/// ```
///
/// 分成零份没有意义,所以下面的代码会 panic:
///
/// ```should_panic
/// kitchen::split_evenly(600, 0);
/// ```
pub fn split_evenly(total: u32, portions: u32) -> u32 {
if portions == 0 {
panic!("portions 必须大于零");
}
total / portions
}
/// 从文件中读取购物清单,每行一项。
///
/// ```no_run
/// let items = kitchen::load_list("shopping.txt").unwrap();
/// println!("要买 {} 样东西", items.len());
/// ```
pub fn load_list(path: &str) -> std::io::Result<Vec<String>> {
let text = std::fs::read_to_string(path)?;
Ok(text.lines().map(String::from).collect())
}

把这些都放进 kitchen,再在 crate 的 //! 注释里加一个 ignore 草稿,cargo test 会报告:

running 7 tests
test src/lib.rs - (line 8) ... ignored
test src/lib.rs - load_list (line 87) - compile ... ok
test src/lib.rs - celsius_to_fahrenheit (line 16) ... ok
test src/lib.rs - Recipe (line 104) ... ok
test src/lib.rs - parse_grams (line 59) ... ok
test src/lib.rs - split_evenly (line 34) ... ok
test src/lib.rs - split_evenly (line 40) - should panic ... ok
test result: ok. 6 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.08s

no_run 的那个测试后面写着 - compile:它只被构建,没有运行。因为测试是并行运行的,这些行的顺序每次都可能不一样。

在文档注释里,把名字写在反引号和方括号里,比如 [`Vec`],rustdoc 就会把它变成指向那个项页面的链接。这叫作文档内链接(intra-doc link)。它对你自己的项和标准库里的任何东西都有效:

//! 一个小小的菜谱程序。
/// 一份菜谱:一个名字,加上一张写着每种食材克数的清单。
///
/// 用 [`Recipe::new`] 创建,再用 [`Recipe::add`] 往里添加食材。
/// 食材按添加的顺序存放在一个 [`Vec`] 里。
struct Recipe {
/// 显示在菜谱最上方的名字。
name: String,
/// 每种食材和它的克数。
ingredients: Vec<(String, u32)>,
}
impl Recipe {
/// 用给定的名字创建一份空菜谱。
fn new(name: &str) -> Recipe {
Recipe { name: name.to_string(), ingredients: Vec::new() }
}
/// 把一种食材加到清单末尾。
fn add(&mut self, ingredient: &str, grams: u32) {
self.ingredients.push((ingredient.to_string(), grams));
}
/// 把所有食材的重量加起来。
fn total_grams(&self) -> u32 {
self.ingredients.iter().map(|(_, grams)| grams).sum()
}
}
fn main() {
let mut pancakes = Recipe::new("煎饼");
pancakes.add("面粉", 200);
pancakes.add("牛奶", 300);
pancakes.add("鸡蛋", 50);
println!("{}", pancakes.name);
for (ingredient, grams) in &pancakes.ingredients {
println!("- {ingredient}:{grams} 克");
}
println!("总计:{} 克", pancakes.total_grams());
}

输出:

煎饼
- 面粉:200 克
- 牛奶:300 克
- 鸡蛋:50 克
总计:550 克
▶ 动手试一试

字段和方法也可以有自己的 /// 注释,它们都会显示在结构体的页面上。如果链接指向一个不存在的东西,cargo doc 会发出警告。下面的 Recipe::push 是 Recipe::add 的笔误:

warning: unresolved link to `Recipe::push`
--> src/lib.rs:100:31
|
100 | /// 用 [`Recipe::new`] 创建,再用 [`Recipe::push`] 往里添加食材。
| ^^^^^^^^^^^^ the struct `Recipe` has no field or associated item named `push`
|
= note: `#[warn(rustdoc::broken_intra_doc_links)]` on by default

警告是 Rust 自己输出的,所以是英文。意思是:“结构体 Recipe 没有叫 push 的字段或关联项。”

如果你的库是给别人用的,可以让编译器提醒你每一个没写文档的公有项。在 src/lib.rs 的最顶部加上这一行:

#![warn(missing_docs)]

这样,忘写文档注释就会收到警告:

warning: missing documentation for a function
--> src/lib.rs:138:1
|
138 | pub fn grams_to_ounces(grams: f64) -> f64 {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
note: the lint level is defined here
--> src/lib.rs:1:9
|
1 | #![warn(missing_docs)]
| ^^^^^^^^^^^^

#!(带感叹号)表示这个属性作用于整个 crate。它还会要求 crate 本身有一段 //! 注释。私有项永远不会被报告,所以内部的辅助函数不会给你添负担。

把 crate 发布到 crates.io 之后(参见 Rust Cargo 与 crate),docs.rs(英文)会自动为它生成文档并免费托管,每个版本都有。你什么都不用做。这就是为什么几乎每个 Rust crate 的文档都长得一样,而且都很好搜索。

再送你两个小技巧:

  • 运行 rustup doc --std,就能在浏览器里离线打开标准库文档。
  • 在 docs.rs 或标准库的任何页面上,点击 Source 链接,就能读到函数背后的代码。这是很好的学习方法。

文档测试里忘了写 crate 名。 文档测试在你的 crate 外部,所以下面这样会失败:

error[E0425]: cannot find function `celsius_to_fahrenheit` in this scope
--> src/lib.rs:17:9
|
17 | let f = celsius_to_fahrenheit(180.0);
| ^^^^^^^^^^^^^^^^^^^^^ not found in this scope

意思是:“在当前作用域里找不到函数 celsius_to_fahrenheit。”修复方法是写成 kitchen::celsius_to_fahrenheit(180.0),或者在示例开头加一行 use kitchen::celsius_to_fahrenheit;(愿意的话可以用 # 隐藏)。

文档注释没有为任何东西编写文档。 /// 必须紧挨在某个项的前面。在函数体里面,请用 //:

error[E0585]: found a documentation comment that doesn't document anything
--> src/main.rs:3:5
|
3 | /// 把食物分成三份。
| ^^^^^^^^^^^^^^^^^^^^
|
= help: doc comments must come before what they document, if a comment was intended use `//`

意思是:“找到了一段没有为任何东西编写文档的文档注释。”help 一行提醒你:文档注释必须写在它要说明的东西前面,如果只是普通注释,就用 //。

以为二进制 crate 里的文档测试也会运行。 文档测试只对库 crate(src/lib.rs)运行。src/main.rs 的文档注释里的示例不会被测试。程序变大以后,把逻辑移到 src/lib.rs,让 main.rs 保持精简。

写的文档只是在重复代码。 在 fn add(a, b) 上面写“把两个数相加”没什么帮助。要说明读者从代码里看不出来的东西:单位(克?秒?)、边界情况,以及什么时候会失败。

  • /// 为下一个项编写文档,//! 为它所在的 crate 或模块编写文档。两者都用 Markdown。
  • 使用标准小节:# Examples、# Panics、# Errors 和 # Safety。
  • cargo doc --open 会为你的 crate 和它的依赖生成一个 HTML 网站。
  • 文档注释里的代码块就是文档测试:cargo test 会编译并运行它们。用 # 隐藏准备代码,需要时使用 no_run、should_panic 或 ignore。
  • [`Name`] 会生成文档内链接,#![warn(missing_docs)] 会提醒你哪些公有项还没写文档。
  • 发布到 crates.io 的 crate,文档会自动托管在 docs.rs 上。

小测验

1. 放在 src/lib.rs 开头时,哪种注释是在为整个 crate 编写文档?

2. 对于库里 /// 注释中的代码块,cargo test 会怎么处理?

3. 在文档测试里,以 # (井号加空格)开头的行会怎样?

4. 有个示例会建立网络连接。你希望它被编译但不被运行,应该用哪个标注?

得分: 0 / 4