Viết tài liệu và doc test trong Rust
Mọi trang trên docs.rs và mọi trang tài liệu của thư viện chuẩn đều do một lập trình viên viết, ngay bên trong mã nguồn, bằng những chú thích đặc biệt. Rust biến các chú thích đó thành một website chỉ bằng một lệnh. Bạn cũng làm được y hệt cho code của chính mình.
Còn một lý do thứ hai để quan tâm. Các ví dụ code trong tài liệu của bạn là test thật sự. Khi bạn chạy cargo test, Rust biên dịch và chạy chúng. Nếu bạn sửa hàm mà quên cập nhật ví dụ, test sẽ thất bại. Tài liệu của bạn không thể lặng lẽ trở nên lỗi thời. Điều này hiếm thấy ở các ngôn ngữ lập trình, và là một trong những điểm dễ thương nhất của Rust.
Chú thích tài liệu: /// và //!
Phần tiêu đề “Chú thích tài liệu: /// và //!”Bạn đã gặp chú thích bình thường ở trang Chú thích. Chú thích tài liệu (doc comment) trông tương tự nhưng có ba ký tự:
///mô tả thành phần nằm bên dưới nó: một hàm, struct, enum, trường hoặc module.//!mô tả thành phần mà nó nằm bên trong. Ở đầusrc/lib.rshoặcsrc/main.rs, nó mô tả cả crate.
//! Một chương trình công thức nấu ăn nho nhỏ.
/// Chia đều `total` gam thành `portions` phần.fn split_evenly(total: u32, portions: u32) -> u32 { total / portions}Dòng đầu tiên của chú thích tài liệu nên là một câu tóm tắt ngắn. Các công cụ hiển thị dòng đó trong danh sách và kết quả tìm kiếm, nên hãy viết nó cho thật đắt giá.
Chú thích tài liệu không thay đổi những gì chương trình làm. Đây là một hàm có tài liệu, được dùng từ main:
/// Chia đều `total` gam thành `portions` phần.////// Số gam dư bị bỏ qua, nên chia 10 gam cho 3 phần sẽ được 3.////// # Panics////// Panic nếu `portions` bằng 0.fn split_evenly(total: u32, portions: u32) -> u32 { if portions == 0 { panic!("số phần phải lớn hơn 0"); } total / portions}
fn main() { println!("{}", split_evenly(600, 3)); println!("{}", split_evenly(10, 3));}Kết quả:
2003Trong hầu hết trình soạn thảo, giờ đây khi bạn rê chuột lên split_evenly trong main, đoạn văn bản này sẽ hiện ra trong một cửa sổ nhỏ. Chỉ riêng điều đó đã đáng công rồi.
Markdown trong chú thích tài liệu
Phần tiêu đề “Markdown trong chú thích tài liệu”Chú thích tài liệu được viết bằng Markdown, cùng định dạng dùng trên GitHub và trong nhiều ứng dụng chat. Bạn có thể dùng `code`, **in đậm**, *in nghiêng*, danh sách, liên kết và tiêu đề:
/// Trả về nhiệt độ lò nướng cho một món, tính bằng **độ C**.////// Các món được hỗ trợ:////// - `"bread"`: 220 độ/// - `"cake"`: 180 độ/// - món khác: 200 độ////// *Luôn luôn* kiểm tra lò của chính bạn, vì mỗi lò một khác./// Xem [Celsius trên Wikipedia](https://en.wikipedia.org/wiki/Celsius).pub fn oven_temperature(dish: &str) -> u32 { match dish { "bread" => 220, "cake" => 180, _ => 200, }}Hãy để một dòng /// trống giữa các đoạn, giống như bạn để một dòng trống trong file Markdown bình thường. Bọc tên hàm, kiểu và giá trị trong dấu backtick để chúng hiện ra dưới dạng code.
Các mục chuẩn
Phần tiêu đề “Các mục chuẩn”Lập trình viên Rust dùng đi dùng lại một vài tiêu đề, để người đọc biết nên nhìn vào đâu. Bạn đã thấy một cái rồi. Đây là những tiêu đề thường gặp (chúng được giữ bằng tiếng Anh, vì đó là quy ước chung của cả hệ sinh thái):
# Examples: cách dùng thành phần đó. Gần như mọi thành phần public đều nên có.# Panics: khi nào hàm có thể panic, để bên gọi tránh được.# Errors: khi nào một hàm trả vềResultsẽ trả vềErr, và vì sao.# Safety: với mộtunsafe fn, các quy tắc mà bên gọi phải tuân theo (xem Unsafe Rust).
Đây là # Errors và # Safety trong một file thư viện, src/lib.rs:
/// Đọc một lượng như `"250g"` và trả về số gam.////// # Errors////// Trả về lỗi nếu văn bản không kết thúc bằng `g`,/// hoặc nếu phần đứng trước nó không phải số nguyên.pub fn parse_grams(text: &str) -> Result<u32, String> { let number = text .strip_suffix('g') .ok_or(format!("'{text}' phải kết thúc bằng g"))?; number .parse() .map_err(|_| format!("'{number}' không phải số nguyên"))}
/// Trả về nguyên liệu đầu tiên mà không kiểm tra danh sách có rỗng hay không.////// # Safety////// Bên gọi phải đảm bảo `ingredients` không rỗng./// Gọi hàm này với một slice rỗng là hành vi không xác định (undefined behavior).pub unsafe fn first_unchecked(ingredients: &[String]) -> &String { unsafe { ingredients.get_unchecked(0) }}Hãy để ý rằng các tiêu đề dùng một dấu #. Bên trong chú thích tài liệu, # là tiêu đề Markdown, không phải thuộc tính (attribute) của Rust.
Tạo tài liệu bằng cargo doc
Phần tiêu đề “Tạo tài liệu bằng cargo doc”Trong phần còn lại của trang này, chúng ta sẽ làm việc với một thư viện nhỏ. Hãy tạo nó bằng cargo new --lib kitchen. Sau đó, bên trong dự án, chạy:
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 tạo một website HTML trong target/doc/ và --open mở nó trong trình duyệt của bạn. Nó trông y hệt các trang trên docs.rs: một thanh bên, một ô tìm kiếm, và mỗi hàm, mỗi kiểu có một trang riêng. Nó cũng tạo tài liệu cho mọi crate mà bạn phụ thuộc vào, rất tiện khi bạn không có mạng.
Mặc định chỉ những thành phần public (được đánh dấu pub) mới hiện ra, vì tài liệu dành cho người dùng thư viện của bạn. Thêm --document-private-items để đưa mọi thứ vào.
Doc test: những ví dụ chạy được
Phần tiêu đề “Doc test: những ví dụ chạy được”Đây là phần đầu của src/lib.rs trong thư viện kitchen. Hàm có một mục # Examples với một khối code bên trong chú thích tài liệu:
//! Các hàm trợ giúp nho nhỏ cho nhà bếp.//!//! `kitchen` chuyển đổi nhiệt độ lò nướng, chia thức ăn thành phần,//! và đọc các lượng như `"250g"`.
/// Chuyển nhiệt độ từ độ C sang độ F.////// # 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}Giờ hãy chạy 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 tìm thấy khối code ở dòng 10, biến nó thành một chương trình nhỏ xíu, biên dịch rồi chạy nó. assert_eq! đã kiểm tra đáp án. Vài điều cần để ý:
- Ví dụ gọi
kitchen::celsius_to_fahrenheit, có kèm tên crate. Doc test được biên dịch như thể nó là chương trình của người khác đang dùng thư viện của bạn, nên nó chỉ thấy các thành phầnpub, và bạn cần đường dẫn đầy đủ hoặc một dònguse kitchen::...;. - Bạn không viết
fn main. Rustdoc tự bọc ví dụ của bạn trong một hàmmain. - Hãy dùng
assert!vàassert_eq!(ở trang Kiểm thử) để vừa cho thấy vừa kiểm tra kết quả cùng lúc.
Nếu ví dụ sai thì sao? Hãy đổi 356.0 thành 350.0 rồi chạy lại test:
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Đó chính là mục đích. Một ví dụ sai bị phát hiện ngay lập tức, thay vì làm người đọc bối rối nhiều tháng sau. Để chỉ chạy doc test, hãy dùng cargo test --doc.
Ẩn các dòng chuẩn bị bằng #
Phần tiêu đề “Ẩn các dòng chuẩn bị bằng #”Đôi khi một ví dụ cần những dòng có thể làm người đọc phân tâm. Một dòng trong doc test bắt đầu bằng # (dấu thăng và một khoảng trắng) sẽ được biên dịch nhưng không hiện ra trong tài liệu được tạo.
Cách dùng phổ biến nhất: bạn muốn minh họa toán tử ?, nhưng ? chỉ dùng được bên trong một hàm trả về Result. Vì vậy bạn ẩn hàm main của riêng mình:
/// Đọc một lượng như `"250g"` và trả về số gam.////// # Examples////// ```/// use kitchen::parse_grams;////// # fn main() -> Result<(), String> {/// let flour = parse_grams("250g")?;/// assert_eq!(flour, 250);////// assert!(parse_grams("a lot").is_err());/// # Ok(())/// # }/// ```pub fn parse_grams(text: &str) -> Result<u32, String> { let number = text .strip_suffix('g') .ok_or(format!("'{text}' phải kết thúc bằng g"))?; number .parse() .map_err(|_| format!("'{number}' không phải số nguyên"))}Trong trình duyệt, người đọc chỉ thấy bốn dòng đáng chú ý: dòng use, lời gọi có ?, và hai phép assert. Test vẫn biên dịch toàn bộ.
ignore, no_run và should_panic
Phần tiêu đề “ignore, no_run và should_panic”Không phải ví dụ nào cũng chạy được ngay. Bạn có thể đặt một từ sau dấu backtick mở để bảo rustdoc phải làm gì:
```no_run: biên dịch ví dụ nhưng không chạy. Hợp với code đọc file, mở kết nối mạng hoặc lặp vô tận.```should_panic: chạy và mong đợi một panic. Test thất bại nếu nó không panic.```ignore: hoàn toàn không biên dịch. Dùng cho bản phác thảo và mã giả. Hãy thửno_runtrước, vì một ví dụ bị bỏ qua có thể lỗi thời mà chẳng ai nhận ra.```text: hoàn toàn không phải code Rust, chỉ là văn bản (ví dụ kết quả mẫu).
/// Chia đều `total` gam thành `portions` phần.////// # Examples////// ```/// assert_eq!(kitchen::split_evenly(600, 3), 200);/// ```////// Chia thành 0 phần là vô nghĩa, nên đoạn này panic:////// ```should_panic/// kitchen::split_evenly(600, 0);/// ```pub fn split_evenly(total: u32, portions: u32) -> u32 { if portions == 0 { panic!("số phần phải lớn hơn 0"); } total / portions}
/// Nạp danh sách đi chợ từ một file, mỗi dòng một món.////// ```no_run/// let items = kitchen::load_list("shopping.txt").unwrap();/// println!("Cần mua {} món", 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())}Với tất cả những thứ này trong kitchen, cộng thêm một bản phác thảo ignore trong chú thích //! của crate, cargo test báo:
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.01sTest no_run ghi - compile: nó chỉ được build. Thứ tự các dòng có thể thay đổi giữa các lần chạy, vì test chạy song song.
Liên kết tới các thành phần khác
Phần tiêu đề “Liên kết tới các thành phần khác”Bên trong chú thích tài liệu, hãy viết một cái tên trong dấu backtick và dấu ngoặc vuông, như [`Vec`], và rustdoc sẽ biến nó thành liên kết tới trang của thành phần đó. Đây gọi là intra-doc link (liên kết nội bộ trong tài liệu). Nó dùng được cho thành phần của chính bạn và cho mọi thứ trong thư viện chuẩn:
//! Một chương trình công thức nấu ăn nho nhỏ.
/// Một công thức: tên món và danh sách nguyên liệu kèm khối lượng tính bằng gam.////// Tạo công thức bằng [`Recipe::new`] và thêm nguyên liệu bằng [`Recipe::add`]./// Các nguyên liệu được giữ trong một [`Vec`], theo thứ tự bạn thêm vào.struct Recipe { /// Tên hiện ở đầu công thức. name: String, /// Từng nguyên liệu và khối lượng của nó tính bằng gam. ingredients: Vec<(String, u32)>,}
impl Recipe { /// Tạo một công thức rỗng với tên cho trước. fn new(name: &str) -> Recipe { Recipe { name: name.to_string(), ingredients: Vec::new() } }
/// Thêm một nguyên liệu vào cuối danh sách. fn add(&mut self, ingredient: &str, grams: u32) { self.ingredients.push((ingredient.to_string(), grams)); }
/// Cộng khối lượng của mọi nguyên liệu. fn total_grams(&self) -> u32 { self.ingredients.iter().map(|(_, grams)| grams).sum() }}
fn main() { let mut pancakes = Recipe::new("Bánh kếp"); pancakes.add("bột mì", 200); pancakes.add("sữa", 300); pancakes.add("trứng", 50);
println!("{}", pancakes.name); for (ingredient, grams) in &pancakes.ingredients { println!("- {ingredient}: {grams} g"); } println!("Tổng: {} g", pancakes.total_grams());}Kết quả:
Bánh kếp- bột mì: 200 g- sữa: 300 g- trứng: 50 gTổng: 550 gTrường và phương thức cũng có thể có chú thích /// riêng, và mỗi chú thích sẽ hiện ra trên trang của struct. Nếu một liên kết trỏ tới thứ không tồn tại, cargo doc sẽ cảnh báo bạn. Ở đây Recipe::push là lỗi gõ nhầm của Recipe::add (kết quả dưới đây lấy từ phiên bản tiếng Anh của file, và cảnh báo nói rằng struct Recipe không có trường hay thành phần liên kết nào tên push):
warning: unresolved link to `Recipe::push` --> src/lib.rs:100:56 |100 | /// Create one with [`Recipe::new`] and fill it with [`Recipe::push`]. | ^^^^^^^^^^^^ the struct `Recipe` has no field or associated item named `push` | = note: `#[warn(rustdoc::broken_intra_doc_links)]` on by defaultBắt buộc có tài liệu với missing_docs
Phần tiêu đề “Bắt buộc có tài liệu với missing_docs”Với một thư viện mà người khác sẽ dùng, bạn có thể nhờ trình biên dịch nhắc bạn về mọi thành phần public chưa có tài liệu. Hãy đặt dòng này ở ngay đầu src/lib.rs:
#![warn(missing_docs)]Giờ thì quên một chú thích tài liệu sẽ gây ra cảnh báo (“missing documentation for a function”, thiếu tài liệu cho một hàm):
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)] | ^^^^^^^^^^^^#! (có dấu chấm than) nghĩa là thuộc tính áp dụng cho cả crate. Nó cũng đòi một chú thích //! cho chính crate đó. Các thành phần private không bao giờ bị báo, nên điều này không phải gánh nặng với các hàm trợ giúp nội bộ.
docs.rs: nơi lưu trữ tài liệu miễn phí
Phần tiêu đề “docs.rs: nơi lưu trữ tài liệu miễn phí”Khi bạn phát hành một crate lên crates.io (xem Cargo và crate), docs.rs tự động tạo tài liệu cho nó và lưu trữ miễn phí, cho mọi phiên bản. Bạn không phải làm gì cả. Đó là lý do gần như crate Rust nào cũng có tài liệu trông giống nhau và dễ tìm kiếm.
Thêm hai mẹo:
- Chạy
rustup doc --stdđể mở tài liệu thư viện chuẩn không cần mạng ngay trong trình duyệt. - Trên bất kỳ trang docs.rs hay trang std nào, hãy bấm liên kết Source để đọc code đằng sau một hàm. Đó là một cách học tuyệt vời.
Lỗi thường gặp
Phần tiêu đề “Lỗi thường gặp”Quên tên crate trong doc test. Doc test nằm bên ngoài crate của bạn, nên đoạn này lỗi:
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 scopeThông báo nói rằng không tìm thấy hàm celsius_to_fahrenheit trong phạm vi này. Cách sửa là kitchen::celsius_to_fahrenheit(180.0), hoặc một dòng use kitchen::celsius_to_fahrenheit; ở đầu ví dụ (ẩn bằng # nếu bạn muốn).
Chú thích tài liệu không mô tả gì cả. /// phải đứng ngay trước một thành phần. Bên trong thân hàm, hãy dùng //:
error[E0585]: found a documentation comment that doesn't document anything --> src/main.rs:3:5 |3 | /// Split the food into three portions. | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = help: doc comments must come before what they document, if a comment was intended use `//`Phần help nói: chú thích tài liệu phải đứng trước thứ mà nó mô tả; nếu bạn chỉ muốn chú thích thường thì hãy dùng //.
Mong doc test trong chương trình binary sẽ chạy. Doc test chạy cho crate thư viện (src/lib.rs). Ví dụ trong chú thích tài liệu của src/main.rs không được kiểm thử. Nếu chương trình của bạn lớn dần, hãy chuyển logic sang src/lib.rs và giữ main.rs thật gọn.
Viết tài liệu lặp lại code. “Cộng hai số” phía trên fn add(a, b) chẳng giúp ích gì. Hãy giải thích những gì người đọc không nhìn thấy: đơn vị (gam? giây?), trường hợp biên, và khi nào hàm thất bại.
Tóm tắt
Phần tiêu đề “Tóm tắt”///mô tả thành phần tiếp theo, còn//!mô tả crate hoặc module mà nó nằm bên trong. Cả hai đều dùng Markdown.- Dùng các mục chuẩn:
# Examples,# Panics,# Errorsvà# Safety. cargo doc --opentạo một trang HTML cho crate của bạn và các thư viện phụ thuộc.- Khối code trong chú thích tài liệu là doc test:
cargo testbiên dịch và chạy chúng. Ẩn dòng chuẩn bị bằng#, và dùngno_run,should_panichoặcignorekhi cần. [`Name`]tạo một intra-doc link, còn#![warn(missing_docs)]nhắc bạn về các thành phần public chưa có tài liệu.- Crate phát hành lên crates.io được docs.rs tự động lưu trữ tài liệu.
Kiểm tra nhanh
Phần tiêu đề “Kiểm tra nhanh”Kiểm tra nhanh
1. Chú thích nào mô tả cả crate khi đặt ở đầu src/lib.rs?
2. cargo test làm gì với một khối code bên trong chú thích /// của một thư viện?
3. Trong doc test, một dòng bắt đầu bằng # (dấu thăng và khoảng trắng) có tác dụng gì?
4. Một ví dụ mở kết nối mạng. Bạn muốn nó được biên dịch nhưng không chạy. Bạn dùng chú thích nào?
Điểm: 0 / 4
Đọc thêm
Phần tiêu đề “Đọc thêm”- The rustdoc book (tiếng Anh): hướng dẫn chính thức về công cụ tạo tài liệu của Rust.
- Documentation tests — the rustdoc book (tiếng Anh): mọi tùy chọn của doc test, kể cả dòng ẩn và
compile_fail. - Linking to items by name — the rustdoc book (tiếng Anh): mọi cách viết intra-doc link.
- Publishing a crate — The Rust Book (tiếng Anh): viết chú thích tài liệu hữu ích trước khi phát hành.
- Documentation — Rust API Guidelines (tiếng Anh): danh sách kiểm tra những gì một bộ tài liệu crate tốt cần có.