Перейти к содержимому

Документация и doc-тесты в Rust

Каждую страницу на docs.rs и каждую страницу документации стандартной библиотеки написал программист — прямо в исходном коде, с помощью особых комментариев. Rust превращает эти комментарии в сайт одной командой. Ты можешь сделать то же самое для своего кода.

Есть и вторая причина этим заняться. Примеры кода в твоей документации — это настоящие тесты. Когда ты запускаешь cargo test, Rust компилирует и выполняет их. Если ты изменил функцию и забыл обновить пример, тест упадёт. Документация не может тихо устареть. В языках программирования такое встречается редко, и это одна из самых приятных особенностей Rust.

Обычные комментарии ты уже видел в уроке Комментарии. Документирующие (doc comments) выглядят похоже, но состоят из трёх символов:

  • /// документирует элемент под ним: функцию, структуру, перечисление, поле или модуль.
  • //! документирует элемент, внутри которого он стоит. В начале src/lib.rs или src/main.rs он описывает весь крейт.
//! Крошечная программа для рецептов.
/// Делит `total` граммов поровну на `portions` порций.
fn split_evenly(total: u32, portions: u32) -> u32 {
total / portions
}

Первая строка документирующего комментария — короткое предложение-резюме. Инструменты показывают эту строку в списках и результатах поиска, так что постарайся, чтобы она была точной.

Документирующие комментарии не меняют поведение программы. Вот задокументированная функция, которую вызывает main:

/// Делит `total` граммов поровну на `portions` порций.
///
/// Остаток отбрасывается, поэтому 10 граммов на 3 порции дают 3.
///
/// # Panics
///
/// Паникует, если `portions` равно нулю.
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
▶ Попробуй сам

В большинстве редакторов, если навести курсор на split_evenly в main, этот текст появится во всплывающей подсказке. Уже ради этого стоит постараться.

Документирующие комментарии пишутся на Markdown — в том же формате, что на GitHub и во многих мессенджерах. Можно использовать `код`, **жирный**, *курсив*, списки, ссылки и заголовки:

/// Возвращает температуру духовки для блюда, в **градусах Цельсия**.
///
/// Поддерживаемые блюда:
///
/// - `"bread"`: 220 градусов
/// - `"cake"`: 180 градусов
/// - всё остальное: 200 градусов
///
/// *Всегда* проверяй свою духовку, потому что все духовки разные.
/// Смотри [градус Цельсия в Википедии](https://ru.wikipedia.org/wiki/Градус_Цельсия).
pub fn oven_temperature(dish: &str) -> u32 {
match dish {
"bread" => 220,
"cake" => 180,
_ => 200,
}
}

Оставляй пустую строку /// между абзацами — так же, как пустую строку в обычном Markdown-файле. Имена функций, типов и значений заключай в обратные кавычки, чтобы они отображались как код.

Программисты на Rust снова и снова используют несколько заголовков, чтобы читатель знал, где что искать. Один ты уже видел. Вот самые распространённые:

  • # Examples: как пользоваться элементом. Пример нужен почти каждому публичному элементу.
  • # Panics: когда функция может запаниковать, чтобы вызывающий код мог этого избежать.
  • # Errors: когда функция, возвращающая Result, отдаёт Err и почему.
  • # Safety: для unsafe fn — правила, которые должен соблюдать вызывающий код (см. Небезопасный Rust).

Заголовки принято писать по-английски: так их узнают все программисты на Rust и инструменты вроде Clippy. Вот # Errors и # Safety в файле библиотеки src/lib.rs:

/// Читает количество вроде `"250g"` и возвращает число граммов.
///
/// # Errors
///
/// Возвращает ошибку, если текст не заканчивается на `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` не пуст.
/// Вызов на пустом срезе — неопределённое поведение.
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 собирает HTML-сайт в target/doc/, а --open открывает его в браузере. Он выглядит точно как страницы на docs.rs: боковая панель, поле поиска и по странице на каждую функцию и тип. Заодно документируются все крейты, от которых ты зависишь, — это удобно, когда нет интернета.

По умолчанию показываются только публичные элементы (с pub), потому что документация пишется для тех, кто пользуется твоей библиотекой. Чтобы включить всё, добавь --document-private-items.

Вот начало src/lib.rs в библиотеке kitchen. У функции есть раздел # 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! проверил ответ. Несколько важных моментов:

  • Пример вызывает kitchen::celsius_to_fahrenheit — с именем крейта. Doc-тест компилируется так, будто это чужая программа, которая использует твою библиотеку. Поэтому он видит только pub-элементы, и нужен полный путь или строка use kitchen::...;.
  • fn main писать не нужно. Rustdoc сам оборачивает в неё твой пример.
  • Используй assert! и assert_eq! (из урока Тестирование), чтобы одновременно показать и проверить результат.

А если пример неправильный? Замени 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

В этом и смысл. Неправильный пример ловится сразу, а не сбивает с толку читателя через несколько месяцев. Утверждение left == right не выполнилось: слева 356.0, справа 350.0. Чтобы запустить только doc-тесты, используй cargo test --doc.

Иногда примеру нужны строки, которые только отвлекают читателя. Строка 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: запустить и ждать панику. Тест падает, если паники не было.
  • ```ignore: вообще не компилировать. Для набросков и псевдокода. Сначала попробуй no_run, потому что игнорируемый пример может устареть, и никто этого не заметит.
  • ```text: вообще не код на Rust, просто текст (например, пример вывода).
/// Делит `total` граммов поровну на `portions` порций.
///
/// # Examples
///
/// ```
/// assert_eq!(kitchen::split_evenly(600, 3), 200);
/// ```
///
/// Ноль порций не имеет смысла, поэтому здесь будет паника:
///
/// ```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, плюс набросок с 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 - split_evenly (line 34) ... ok
test src/lib.rs - split_evenly (line 40) - should panic ... ok
test src/lib.rs - parse_grams (line 59) ... ok
test src/lib.rs - celsius_to_fahrenheit (line 16) ... ok
test src/lib.rs - Recipe (line 104) ... ok
test result: ok. 6 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s

У теста с 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:56
|
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

Предупреждение говорит: ссылку на Recipe::push не удалось найти, потому что у структуры 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)]
| ^^^^^^^^^^^^

#! (с восклицательным знаком) означает, что атрибут действует на весь крейт. Он требует ещё и комментарий //! для самого крейта. О приватных элементах lint никогда не сообщает, так что внутренним вспомогательным функциям он не мешает.

docs.rs: бесплатный хостинг для документации

Заголовок раздела «docs.rs: бесплатный хостинг для документации»

Когда ты публикуешь крейт на crates.io (см. Cargo и крейты), docs.rs автоматически собирает его документацию и бесплатно размещает её — для каждой версии. Тебе ничего не нужно делать. Поэтому почти у каждого крейта на Rust документация выглядит одинаково и по ней удобно искать.

Ещё два совета:

  • Выполни rustup doc --std, чтобы открыть документацию стандартной библиотеки в браузере без интернета.
  • На любой странице docs.rs или std нажми ссылку Source, чтобы прочитать код функции. Это отличный способ учиться.

Забыть имя крейта в doc-тесте. Doc-тест находится вне твоего крейта, поэтому так не получится:

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 так и говорит: документирующий комментарий должен стоять перед тем, что он описывает, а для обычного комментария нужен //.

Ждать, что doc-тесты в бинарном крейте запустятся. Doc-тесты работают для библиотечных крейтов (src/lib.rs). Примеры в документирующих комментариях src/main.rs не тестируются. Когда программа вырастет, перенеси логику в src/lib.rs, а main.rs оставь маленьким.

Документация, которая повторяет код. «Складывает два числа» над fn add(a, b) никому не помогает. Объясняй то, чего читатель не видит: единицы измерения (граммы? секунды?), крайние случаи и когда функция возвращает ошибку.

  • /// документирует следующий элемент, а //! — крейт или модуль, внутри которого стоит. Оба используют Markdown.
  • Используй стандартные разделы: # Examples, # Panics, # Errors и # Safety.
  • cargo doc --open собирает HTML-сайт для твоего крейта и его зависимостей.
  • Блоки кода в документирующих комментариях — это doc-тесты: cargo test компилирует и запускает их. Служебные строки прячь через # , а при необходимости используй no_run, should_panic или ignore.
  • [`Name`] создаёт внутридокументную ссылку, а #![warn(missing_docs)] напоминает о публичных элементах без документации.
  • У крейтов, опубликованных на crates.io, документация автоматически размещается на docs.rs.

Проверь себя

1. Какой комментарий документирует весь крейт, если стоит в начале src/lib.rs?

2. Что cargo test делает с блоком кода внутри комментария /// в библиотеке?

3. Что делает строка doc-теста, которая начинается с # (решётка и пробел)?

4. Пример открывает сетевое соединение. Ты хочешь, чтобы он компилировался, но не запускался. Какую пометку использовать?

Результат: 0 / 4