[Advanced Rust] 2.9. API Design Principles of Obviousness - Documentation and Type System, Semantic Types, and Zero-Sized Types
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 the InternalStruct struct 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(path: P) -> Result { // ... } // ... } 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 the Foo struct and its bar method be included in the final build artifact #[doc(cfg(feature = "foo"))]: marks the struct and method in the API docs as depending on the foo feature, 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 Rocket struct with a launch method 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: std::marker::PhantomData, } // Implement `Default` for `Rocket` impl Default for Rocket { fn default() -> Self { Self { stage: Default::default(), } } } // Implement methods for `Rocket` impl Rocket { pub fn launch(self) -> Rocket { Rocket { stage: Default::default(), } } } // Implement methods for `Rocket` impl Rocket { pub fn accelerate(&mut self) {} pub fn decelerate(&mut self) {} } // Implement common methods for rockets in all states impl Rocket { pub fn color(&self) -> Color { Color::White } pub fn weight(&self) -> Kilograms { Kilograms(0) } } Grounded and Launched have no fields, so their size is zero, and the Rust compiler does not allocate memory for them. They are used only to mark which state Rocket is in, without extra storage cost We define a Rocket struct with a generic parameter Stage, which defaults to Grounded. In the definition we also use std::marker::PhantomData, which is a zero-sized type (ZST, Zero-Sized Type). It affects the type system at compile time but does not occupy memory at runtime The launch method is only available on Rocket After launch() is called, it returns a Rocket, indicating that the rocket has entered the launched state. Rocket no longer has a launch() method, ensuring that it cannot be launched twice The accelerate method represents acceleration and decelerate represents deceleration. These methods apply only to Rocket, preventing acceleration or deceleration while in the Grounded state Some methods can be used in any state, and we place them in the impl Rocket 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 { // ... Ok(()) } We use the #[must_use] attribute to mark process_data as a function whose return value must be used If the user does not explicitly handle the returned Result after 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
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to