Rust 文档与文档测试
docs.rs(英文)上的每一页、标准库文档的每一页,都是程序员用一种特殊的注释写在源代码里的。Rust 只用一条命令,就能把这些注释变成一个网站。你自己的代码也可以这样做。
还有第二个理由值得关心:文档里的代码示例是真正的测试。运行 cargo test 时,Rust 会编译并运行它们。如果你改了函数却忘了更新示例,测试就会失败。这样,文档就不会悄悄过时。这在编程语言里很少见,也是 Rust 最贴心的地方之一。
文档注释:/// 和 //!
Section titled “文档注释:/// 和 //!”普通注释在 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));}输出:
2003在大多数编辑器里,把鼠标停在 main 中的 split_evenly 上,就会弹出这段说明。光这一点就值得你写文档。
文档注释里的 Markdown
Section titled “文档注释里的 Markdown”文档注释用 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 文件里空一行一样。函数名、类型名和值用反引号包起来,它们就会显示成代码。
常用的标准小节
Section titled “常用的标准小节”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 doc 生成文档
Section titled “用 cargo doc 生成文档”本页接下来会围绕一个小库展开。先用 cargo new --lib kitchen 创建它,然后在项目里运行:
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.htmlCargo 会在 target/doc/ 里生成一个 HTML 网站,--open 会在浏览器里打开它。它看起来和 docs.rs 上的页面一模一样:有侧边栏、搜索框,每个函数和类型都有自己的页面。它还会为你依赖的每个 crate 生成文档,没网的时候特别方便。
默认只显示公有项(标了 pub 的),因为文档是写给使用你这个库的人看的。加上 --document-private-items 就能包含所有项。
文档测试:会运行的示例
Section titled “文档测试:会运行的示例”下面是 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 testtest 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.00sCargo 找到了第 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 testtest 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。
用 # 隐藏准备代码
Section titled “用 # 隐藏准备代码”有时示例需要几行会分散读者注意力的代码。文档测试里以 # (井号加空格)开头的行,会被编译,但不会显示在生成的文档里。
最常见的用法:你想演示 ? 运算符,但 ? 只能在返回 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、用了 ? 的调用,以及两个断言。测试时仍然会编译全部代码。
ignore、no_run 和 should_panic
Section titled “ignore、no_run 和 should_panic”不是每个示例都能直接运行。你可以在开头的反引号后面写一个词,告诉 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 teststest src/lib.rs - (line 8) ... ignoredtest src/lib.rs - load_list (line 87) - compile ... oktest src/lib.rs - celsius_to_fahrenheit (line 16) ... oktest src/lib.rs - Recipe (line 104) ... oktest src/lib.rs - parse_grams (line 59) ... oktest src/lib.rs - split_evenly (line 34) ... oktest 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.08sno_run 的那个测试后面写着 - compile:它只被构建,没有运行。因为测试是并行运行的,这些行的顺序每次都可能不一样。
链接到其他项
Section titled “链接到其他项”在文档注释里,把名字写在反引号和方括号里,比如 [`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 的字段或关联项。”
用 missing_docs 要求必须写文档
Section titled “用 missing_docs 要求必须写文档”如果你的库是给别人用的,可以让编译器提醒你每一个没写文档的公有项。在 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 本身有一段 //! 注释。私有项永远不会被报告,所以内部的辅助函数不会给你添负担。
docs.rs:免费托管你的文档
Section titled “docs.rs:免费托管你的文档”把 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
- The rustdoc book(英文):Rust 文档工具的官方指南。
- Documentation tests — the rustdoc book(英文):文档测试的所有选项,包括隐藏行和
compile_fail。 - Linking to items by name — the rustdoc book(英文):文档内链接的各种写法。
- 将 crate 发布到 Crates.io — Rust 程序设计语言:发布之前怎样写出有用的文档注释。
- Documentation — Rust API Guidelines(英文):一份清单,列出好的 crate 文档应该包含哪些内容。