Bỏ qua để đến nội dung

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.

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. Ở đầu src/lib.rs hoặc src/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ả:

200
3
▶ Tự chạy thử

Trong 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.

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.

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ề Result sẽ trả về Err, và vì sao.
  • # Safety: với một unsafe 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.

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:

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 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.

Đâ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 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 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ần pub, và bạn cần đường dẫn đầy đủ hoặc một dòng use kitchen::...;.
  • Bạn không viết fn main. Rustdoc tự bọc ví dụ của bạn trong một hàm main.
  • 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 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

Đó 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.

Đô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ộ.

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_run trướ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 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

Test 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.

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 g
Tổng: 550 g
▶ Tự chạy thử

Trườ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 default

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ộ.

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.

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 scope

Thô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.

  • /// 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, # Errors và # Safety.
  • cargo doc --open tạ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 test biên dịch và chạy chúng. Ẩn dòng chuẩn bị bằng # , và dùng no_run, should_panic hoặc ignore khi 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

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