2.9.1. Documentation and the Type System
Users may not fully understand all of an API's rules and restrictions. So your API should be easy for users to understand and hard to misuse.
With Rust's documentation and type system, we can try to achieve that.
2.9.2. Documentation
The first step toward making an API transparent is to write good documentation.
Writing good documentation has several requirements:
1. Clearly Document Things
Clearly document any unexpected situations that may occur, or any behavior that depends on the user doing something beyond the type signature.
For example: when panic can happen, when an error is returned. If you use an unsafe function, you must explain the conditions under which the user can safely call it.
Example:
/// Division operation, returning the result of two numbers
///
/// # Panics
///
/// This function will panic if the divisor is 0.
///
/// # Example
///
/// ```
{% endraw %}
/// let result = divide(10, 2);
/// assert_eq!(result, 5);
///
{% raw %}
pub fn divide(dividend: i32, divisor: i32) -> i32 {
// ...omitted here
}
- Here we documented the cases in which a panic may occur
---
### 2. Include End-to-End Examples
At the crate or module level, include end-to-end examples rather than examples for a specific type or method.
The benefit of doing this is that users can see how the pieces fit together and get a relatively clear understanding of the API's overall structure, which helps developers quickly understand what each method and type does and where to use them.
Once you provide an end-to-end example, users can copy and paste that code into their own project, effectively giving them a customized starting point.
For example:
>Suppose we have a `math_utils` crate that provides some mathematical operations, including basic addition, subtraction, and a complex calculation function. I will only write the function descriptions briefly in the doc comments here, but when you write your own code, you must document each function properly.
```rust
// lib.rs (crate root module)
pub mod math_utils {
/// Calculate the sum of two numbers
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Calculate the difference between two numbers
pub fn subtract(a: i32, b: i32) -> i32 {
a - b
}
/// Perform a complex mathematical operation (such as a * b + (a - b))
pub fn complex_calculation(a: i32, b: i32) -> i32 {
(a * b) + subtract(a, b)
}
}
// --- End-to-end example (crate-level doc test) ---
/// ```
/// use my_crate::math_utils;
///
/// fn main() {
/// let sum = math_utils::add(10, 5);
/// let difference = math_utils::subtract(10, 5);
/// let result = math_utils::complex_calculation(10, 5);
///
/// println!("Sum: {}", sum); // 15
/// println!("Difference: {}", difference); // 5
/// println!("Complex Calculation Result: {}", result); // 55
/// }
///
---
### 3. Organize the Documentation Well
Use modules to group semantically related items, and then connect them with internal documentation links.
Sometimes you may want to use `#[doc(hidden)]` to mark interfaces that are not meant to be public but must remain for legacy reasons, so they do not clutter the documentation.
Example:
```rust
/// A simple module containing some functions and structs for internal use.
pub mod internal {
/// A helper function used only internally.
#[doc(hidden)]
pub fn internal_helper() {
// The concrete implementation of the internal calculation...
}
/// A struct used only internally.
#[doc(hidden)]
pub struct InternalStruct {
// The struct's fields and methods...
}
}
- The
internal_helper()function and theInternalStructstruct are both for internal use only - By marking them with
#[doc(hidden)], their documentation comments will not appear in the generated docs
4. Enrich the Documentation as Much as Possible
Sometimes you need to explain content and concepts, and you can add links to external resources, such as RFCs, blogs, and white papers.
At the top-level documentation, you should guide users to common modules, traits, types, and methods.
Some notes about documentation features:
- Use
#[doc(cfg(..))]to highlight items that are available only under specific configurations, so users can quickly understand why a method shown in the docs is unavailable - Use
#[doc(alias = "...")]to let users search for a type or method under alternative names
Example 1:
//! This is a library for image processing.
//!
//! This library provides some common image processing features, such as:
//! - Reading and saving image files in different formats [`Image::load`] [`Image::save`]
//! - Resizing, rotating, and cropping images [`Image::resize`] [`Image::rotate`] [`Image::crop`]
//! - Applying different filters and effects [`Filter`] [`Effect`]
//!
//! If you want to learn more about the principles and algorithms of image processing, you can refer to the following resources:
//! - [Digital Image Processing](https://book.douban.com/subject/5345798/), a classic textbook that introduces the basic concepts and methods of image processing.
//! - [Learn OpenCV](https://learnopencv.com/), a website with many tutorials and sample code for implementing image processing with OpenCV.
//! - [Awesome Computer Vision](https://github.com/jbhuang0604/awesome-computer-vision), a GitHub repository collecting many computer vision resources and projects.
/// A struct representing an image
#[derive(Debug, Clone)]
pub struct Image {
// ...
}
// ...
- Here we used external links. You can see that the link format is
[text to display in the docs](https://raw.githubusercontent.com/SomeB1oody/AdvancedRust/main/en/src/Chapter-02/2.9/link), which is standard Markdown and should be familiar to anyone who has written a README before
Example 2:
impl Image {
// ...
// ...
#[doc(alias = "read")]
#[doc(alias = "open")]
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
// ...
}
// ...
}
- We used
#[doc(alias = "read")]and#[doc(alias = "open")], so searching for “read” and “open” in the docs will find this function
Example 3:
/// A struct that is only available when the `foo` feature is enabled.
#[cfg(feature = "foo")]
#[doc(cfg(feature = "foo"))]
pub struct Foo;
impl Foo {
/// A method that is only available when the `foo` feature is enabled.
#[cfg(feature = "foo")]
#[doc(cfg(feature = "foo"))]
pub fn bar(&self) {
// ...
}
}
fn main() {
println!("Hello, world!");
}
-
#[cfg(feature = "foo")]: only when the"foo"feature is enabled will theFoostruct and itsbarmethod be included in the final build artifact -
#[doc(cfg(feature = "foo"))]: marks the struct and method in the API docs as depending on thefoofeature, so users know they are not available by default
2.9.3. The Type System
Using Rust's type system can ensure that APIs are:
- Obvious
- Self-describing
- Hard to misuse
Semantic Types
Some values have meaning beyond their surface form. For example, 1 and 0 can represent male and female. In that case, we can add types to represent the meaning of the value.
Example:
fn processData(dryRun: bool, overwrite: bool, validate: bool) {
// data processing logic
}
- The three parameters of this function are all booleans, so they are easy to confuse, and users are very likely to use them incorrectly
To solve this, we can create three types and make the parameters have three different types:
enum DryRun {
Yes,
No,
}
enum Overwrite {
Yes,
No,
}
enum Validate {
Yes,
No,
}
fn processData(dryRun: DryRun, overwrite: Overwrite, validate: Validate) {
// data processing logic
}
- Turn the three booleans into three enum types
When users call the function, they will write:
processData(DryRun::Yes, Overwrite::No, Validate::Yes)
That is much clearer.
Using Zero-Sized Types to Represent Facts About a Type Instance
For example:
Suppose we have a
Rocketstruct with alaunchmethod for launching it. If the rocket is not already launched, calling this method is perfectly fine. But if the rocket is already launched, you should not be able to launch it again. Likewise, after launch we can control acceleration and deceleration, but not while on the ground.
// Define different rocket states
struct Grounded;
struct Launched;
// Color enum
enum Color {
White,
Black,
}
// Mass type, using the newtype pattern to wrap `u32`
struct Kilograms(u32);
// Generic rocket struct with a default state of `Grounded`
struct Rocket<Stage = Grounded> {
stage: std::marker::PhantomData<Stage>,
}
// Implement `Default` for `Rocket<Grounded>`
impl Default for Rocket<Grounded> {
fn default() -> Self {
Self {
stage: Default::default(),
}
}
}
// Implement methods for `Rocket<Grounded>`
impl Rocket<Grounded> {
pub fn launch(self) -> Rocket<Launched> {
Rocket {
stage: Default::default(),
}
}
}
// Implement methods for `Rocket<Launched>`
impl Rocket<Launched> {
pub fn accelerate(&mut self) {}
pub fn decelerate(&mut self) {}
}
// Implement common methods for rockets in all states
impl<Stage> Rocket<Stage> {
pub fn color(&self) -> Color {
Color::White
}
pub fn weight(&self) -> Kilograms {
Kilograms(0)
}
}
GroundedandLaunchedhave no fields, so their size is zero, and the Rust compiler does not allocate memory for them. They are used only to mark which stateRocketis in, without extra storage costWe define a
Rocketstruct with a generic parameterStage, which defaults toGrounded. In the definition we also usestd::marker::PhantomData<T>, which is a zero-sized type (ZST, Zero-Sized Type). It affects the type system at compile time but does not occupy memory at runtimeThe
launchmethod is only available onRocket<Grounded>After
launch()is called, it returns aRocket<Launched>, indicating that the rocket has entered the launched state.Rocket<Launched>no longer has alaunch()method, ensuring that it cannot be launched twiceThe
acceleratemethod represents acceleration anddeceleraterepresents deceleration. These methods apply only toRocket<Launched>, preventing acceleration or deceleration while in theGroundedstateSome methods can be used in any state, and we place them in the
impl<Stage> Rocket<Stage>block
#[must_use] Attribute
After you add the #[must_use] attribute to a type, trait, or function, if user code receives a value of that type or trait, or calls that function, and does not explicitly handle it, the compiler will emit a warning.
Example:
#[must_use]
fn process_data(data: Data) -> Result<(), Error> {
// ...
Ok(())
}
- We use the
#[must_use]attribute to markprocess_dataas a function whose return value must be used - If the user does not explicitly handle the returned
Resultafter calling the function, the compiler will issue a warning - This helps remind users to be careful when dealing with potential error cases and reduces the chance of mistakes
Top comments (0)