Документация и 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));}Вывод:
2003В большинстве редакторов, если навести курсор на split_evenly в main, этот текст появится во всплывающей подсказке. Уже ради этого стоит постараться.
Markdown в документирующих комментариях
Заголовок раздела «Markdown в документирующих комментариях»Документирующие комментарии пишутся на 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 doc
Заголовок раздела «Собираем документацию через 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 собирает HTML-сайт в target/doc/, а --open открывает его в браузере. Он выглядит точно как страницы на docs.rs: боковая панель, поле поиска и по странице на каждую функцию и тип. Заодно документируются все крейты, от которых ты зависишь, — это удобно, когда нет интернета.
По умолчанию показываются только публичные элементы (с pub), потому что документация пишется для тех, кто пользуется твоей библиотекой. Чтобы включить всё, добавь --document-private-items.
Doc-тесты: примеры, которые запускаются
Заголовок раздела «Doc-тесты: примеры, которые запускаются»Вот начало 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 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! проверил ответ. Несколько важных моментов:
- Пример вызывает
kitchen::celsius_to_fahrenheit— с именем крейта. Doc-тест компилируется так, будто это чужая программа, которая использует твою библиотеку. Поэтому он видит толькоpub-элементы, и нужен полный путь или строкаuse kitchen::...;. fn mainписать не нужно. Rustdoc сам оборачивает в неё твой пример.- Используй
assert!иassert_eq!(из урока Тестирование), чтобы одновременно показать и проверить результат.
А если пример неправильный? Замени 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В этом и смысл. Неправильный пример ловится сразу, а не сбивает с толку читателя через несколько месяцев. Утверждение 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, вызов с ? и два утверждения. А тест по-прежнему компилирует всё.
ignore, no_run и should_panic
Заголовок раздела «ignore, no_run и should_panic»Не каждый пример можно просто запустить. После открывающих обратных кавычек можно написать слово, которое скажет 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 teststest src/lib.rs - (line 8) ... ignoredtest src/lib.rs - load_list (line 87) - compile ... oktest src/lib.rs - split_evenly (line 34) ... oktest src/lib.rs - split_evenly (line 40) - should panic ... oktest src/lib.rs - parse_grams (line 59) ... oktest src/lib.rs - celsius_to_fahrenheit (line 16) ... oktest 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.
Требуем документацию с missing_docs
Заголовок раздела «Требуем документацию с 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)] | ^^^^^^^^^^^^#! (с восклицательным знаком) означает, что атрибут действует на весь крейт. Он требует ещё и комментарий //! для самого крейта. О приватных элементах 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
Что почитать
Заголовок раздела «Что почитать»- The rustdoc book (на английском) — официальное руководство по инструменту документации Rust.
- Documentation tests — the rustdoc book (на английском) — все настройки doc-тестов, включая скрытые строки и
compile_fail. - Linking to items by name — the rustdoc book (на английском) — все способы писать внутридокументные ссылки.
- Публикация крейта на Crates.io — книга «Язык программирования Rust» (перевод на русский) — как написать полезные документирующие комментарии перед публикацией.
- Documentation — Rust API Guidelines (на английском) — чек-лист того, что должно быть в хорошей документации крейта.