Full title: [Advanced Rust] 2.1. API Design Principles of Unsurprising - Naming Tips, Implementing Common Traits (Debug, Send, Sync, and Unpin)
2.1.1. What Is the Unsurprising Principle?
The unsurprising principle is also called the least-surprise principle. It means that the APIs you write should be as intuitive as possible.
Users should be able to guess what an interface does just by looking at it. At the very least, your interface should not surprise them. Its core idea is to stay close to what users already know, so they do not need to relearn concepts. For example, if an interface name contains error, users will probably guess that it is used for error handling.
In other words, we need our interfaces to be predictable, which requires attention to the following:
- Naming
- Implementing common traits
- Ergonomic traits
- Wrapper types
2.1.2. Naming Tips
Interface names should follow conventions so their behavior is easy to infer. Here, conventions means the conventions commonly used in the Rust standard library and Rust community.
Examples:
- A method named
iter(or ending withiter) will most likely take&selfas an argument and return an iterator - A method named
into_innerwill most likely takeselfas an argument and return the wrapped type - A type named
SomethingErrorshould implementstd::error::Errorand appear in variousResulttypes
Using the same common names for the same purposes helps users understand the API. This leads to another conclusion: things with the same name should behave in the same way, otherwise users will probably write incorrect code.
2.1.3. Implementing Common Traits
Users usually assume that everything in an interface works “as expected,” for example:
- You can print any type with
{:?} - You can send anything to another thread
- Every type is
Clone
So when writing code, actively implement most standard traits, even if you do not need them immediately.
From another angle, users cannot implement foreign traits for foreign types themselves because that would violate the orphan rule. That makes it hard for them to add the traits they want to your types. So you should actively implement most standard traits, so your types can satisfy the traits most users expect.
2.1.4. It Is Recommended to Implement the Debug Trait
Almost all types can and should implement the Debug trait.
The simplest and best way is to use #[derive(Debug)]. Note that a derived trait will add the same bound to any generic parameter.
An example makes this clear:
use std::fmt::Debug;
#[derive(Debug)]
struct Pair<T> {
a: T,
b: T,
}
fn main() {
let pair = Pair { a: 5, b: 10 };
println!("{:?}", pair);
}
- The
Pairstruct implements theDebugtrait through derive, so it automatically adds the boundT: Debugto the generic parameterT - The type of the
Pairfields inmainisi32, which implementsDebug, so it can be printed
Output:
Pair { a: 5, b: 10 }
What if I change the field type to something that does not implement Debug?
use std::fmt::Debug;
struct Person {
name: String,
}
#[derive(Debug)]
struct Pair<T> {
a: T,
b: T,
}
fn main() {
let pair = Pair {
a: Person { name: "Dave".to_string() },
b: Person { name: "Nick".to_string() },
};
println!("{:?}", pair);
}
Output:
error[E0277]: `Person` doesn't implement `Debug`
--> src/main.rs:18:22
|
18 | println!("{:?}", pair);
| ---- ^^^^ `Person` cannot be formatted using `{:?}` because it doesn't implement `Debug`
| |
| required by this formatting parameter
|
= help: the trait `Debug` is not implemented for `Person`
= note: add `#[derive(Debug)]` to `Person` or manually `impl Debug for Person`
help: the trait `Debug` is implemented for `Pair<T>`
--> src/main.rs:7:10
|
7 | #[derive(Debug)]
| ^^^^^
note: required for `Pair<Person>` to implement `Debug`
--> src/main.rs:8:8
|
7 | #[derive(Debug)]
| ----- in this derive macro expansion
8 | struct Pair<T> {
| ^^^^ - type parameter would need to implement `Debug`
= help: consider manually implementing `Debug` to avoid undesired bounds
help: consider annotating `Person` with `#[derive(Debug)]`
|
3 + #[derive(Debug)]
4 | struct Person {
|
We can also manually implement Debug by using the various debug_xxx helper methods provided by fmt::Formatter in the standard library:
debug_structdebug_tupledebug_listdebug_setdebug_map
Example:
use std::fmt;
struct Pair<T> {
a: T,
b: T,
}
impl<T: fmt::Debug> fmt::Debug for Pair<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Pair")
.field("a", &self.a)
.field("b", &self.b)
.finish()
}
}
fn main() {
let pair = Pair { a: 1, b: 2 };
println!("{:?}", pair);
}
- We manually implement the
Debugtrait instead of using#[derive(Debug)] -
fmtis the method that must be defined when implementingfmt::Debug -
f: &mut fmt::Formatter<'_>provides the formatting context and tools -
f.debug_struct("Pair")declares that the value should be formatted as a debug struct with fields, and sets the struct name to"Pair" -
.field("a", &self.a)and.field("b", &self.b)add theaandbfields toPairand associate each field with its value -
.finish()completes the formatting builder and returns the result to be printed
Output:
Pair { a: 1, b: 2 }
2.1.5. It Is Recommended to Implement the Send, Unpin, and Sync Traits
If your type does not implement Send, it cannot be moved to another thread (for example, thread::spawn requires T: Send). Wrapping a !Send value in Mutex<T> does not help either: Mutex<T> is only Send/Sync when T: Send, so it still cannot be shared across threads.
Example:
use std::rc::Rc;
fn main() {
let x = Rc::new(42);
std::thread::spawn(move || {
println!("{:?}", x);
});
}
-
Rc<T>does not implementSend, so it cannot be used across threads
We can write a simple tuple struct ourselves to implement it manually (of course, it will not have Rc<T>'s reference counting feature):
#[derive(Debug)]
struct MyBox(*mut u8);
unsafe impl Send for MyBox {}
fn main() {
let mb = MyBox(Box::into_raw(Box::new(42)));
std::thread::spawn(move || {
println!("{:?}", mb);
});
}
-
MyBoximplementsSend, so it can be used across threads - Traits such as
Sendthat act only as markers and do not provide concrete behavior are called marker traits. Marker traits provide compile-time information but do not add behavior. So implementingSendforMyBoxdoes not require any method body - Manually implementing
Sendis unsafe, so we must add theunsafemarker before theimplblock. Rust's type system normally infersSendautomatically to ensure thread safety, while a manualSendimplementation may bypass Rust's safety checks
Types that do not implement Sync cannot be shared across threads through Arc<T> (the atomic reference-counted pointer, the multithreaded version of Rc<T>), and they also cannot be stored in static items that require Sync.
Example:
use std::cell::RefCell;
use std::sync::Arc;
fn main() {
let x = Arc::new(RefCell::new(42));
std::thread::spawn(move || {
let mut x = x.borrow_mut();
*x += 1;
});
}
-
RefCell<T>does not implementSync, so it cannot be shared across threads withArc<T>
Unpin means “can be unpinned.” It is a marker trait used to indicate whether a type can be safely moved out of a Pin, that is, whether it can bypass the restrictions of Pin<P>.
Most types are Unpin by default. Self-referential types are usually made !Unpin by embedding a marker such as std::marker::PhantomPinned (or another !Unpin field). Rust does not automatically treat “has a self-reference” as !Unpin; without such a marker, the type would still be Unpin, and moving it could invalidate internal pointers.
If your type does not implement any of the above traits, it is recommended that you state that in the documentation.
Top comments (0)