Interface Design
There are four principles in writing idiomatic interfaces in Rust.
Interfaces should be:
- Unsurprising
- Flexible
- Obvious
- Constrained
Unsurprising Interfaces
Interfaces should be intuitive where if a user had to guess how something would work, they usually guess correctly.
Things that share similar names/prefixes should work similarly
- This includes sharing similar names with things in the stdlib.
- Users expect
iter
methods to take&self
. - Users expect
into_inner
methods to takeself
. - Users expect
SomethingError
to implementstd::error::Error
.
Structs are expected to implement some traits just out of the box.
- Nearly every type should implement
Debug
. - Nearly every type should be
Send
andSync
, which are already auto-traits. If your type is NOT, this should be well-documented. - Most types should have
Clone
andDefault
. - Many types should have
PartialEq
,PartialOrd
,Hash
,Eq
, andOrd
. - For many types it makes sense to have
serde::Serialize
andserde::Deserialize
.- Since
serde
is a third-party crate, it is typical to expose aserde
feature in your crate that allows users to conditionally compile your library with or withoutserde
.
- Since
- Few types should implement
Copy
. Think carefully whether a user would expect your type to beCopy
and whether it's even possible/feasible/a good idea.
Use blanket trait implementations for ergonomic usage.
- Even if
SomeStruct: Trait
, this does not automatically follow for&SomeStruct
or&mut SomeStruct
. - You should provide blanket implementations as appropriate for
&T where T: Trait
,&mut T where T: Trait
, andBox<T> where T: Trait
. - For any type that can be iterated over, you should implement
IntoIterator
for both&T
and&mut T
where applicable.
Wrapper types, e.g., NewType
- For wrapper types, it's ergonomic to have
Deref<Target = Inner>
andAsRef
- If your wrapper type has its own methods and uses
Deref
, avoid taking&self
orself
or&mut self
. Instead, define static methods. See, e.g.,Rc::clone(some_rc)
- In narrow cases, you'll also want to implement
Borrow<T>
, but note that this trait has extra semantic requirements regardingHash
,Eq
, andOrd
.
Flexible Interfaces
Interfaces should be "flexible" in the sense that it does not make any unnecessary restrictions on usage and does not promise features that it cannot perform.
Restrictions in Rust are typically function type signatures and trait bounds.
- For example, if you need to access a string, your function could take a
String
, a&str
, or aimpl AsRef<str>
.
Promises in Rust are typically logic/behavior and return types.
- For example if you ened to return a string, your function could return a
String
, aCow<'_, str>
, or aimpl AsRef<str>
.
Generic arguments can be used to relax restrictions
- Instead of requiring a
MyStruct
or&MyStruct
, you could acceptT
, which relaxes the type restriction. You can add some trait bounds toT
to help it be more useful. - You pay for making a function generic with harder to read/understand code.
- Generic code results in bigger binaries and longer compile times.
- You have the option of doing dynamic dispatch with
&dyn
, but doing so prevents users from opting out of the dynamic dispatch, which could be a deal-breaker in performance-sensitive code.
In general, traits should be object-safe
- If a trait is not object-safe, it cannot be made into a trait object using
dyn Trait
. - If a trait method must take a generic object, you can consider adding a
where Self: Sized
bound to the method which requires a concrete instance of the trait (and not throughdyn Trait
). - Object safety is part of your public interface. Adding or removing object safety is a major semantic version change.
Think carefully about whether your functions, traits, and types should own or borrow its data
- If your code needs to call methods that take
self
or move the data to another thread, it must store the owned data.- It should generally make the caller provide owned data. This makes it upfront known what the cost of using the interface since the caller would have to take care of allocation.
- If your code doesn't need to own the data, it should just take references, (except for tiny
Copy
types, like the integral types). - The
Cow
type is useful to operate on references if the data allows, or produce an owned value if you need it.
Custom Drop
implementations have some pitfalls
- If you place clean-up code as part of a
Drop
implementation, any errors it produces may have to be swallowed because the value is already dropped; there is no way to communicate errors to the user without panicking. - See also:
async
code, where by the timeDrop
is called, the executor may also be shutting down. - We can provide an explicit destructor, which is a function that takes ownership of
self
and exposes errors using a return typeResult<_, _>
.- Users can use this explicit destructor to gracefully tear down resources.
- Note that you cannot move resources out of the type inside this destructor, because
Drop::drop
will still be called. - Since
Drop::drop
takes&mut self
, it cannot just call your explicit destructor and ignore its results, sinceDrop::drop
doesn't ownself
. - Workarounds involve
unsafe
, or using some combination of wrapping things inOption
s and usingOption::take
to swap data out.
Obvious Interfaces
Interfaces should make it as easy as possible to understand and as hard as possible to use it incorrectly.
This can be enforced through:
- Good documentation
- Type system
Crash course on documentation
- Document any cases where code will do something unexpected or relies on something beyond what is dictated by the type signature.
- Include end-to-end usage examples on a crate and module level.
- Organize your documentation using modules to group together semantically related items.
- Use intra-documentation to interlink items. Meaning if
A
talks aboutB
, a link toB
should be right there. - Make parts of your interface hidden with
#[doc(hidden)]
in order to not clutter your docs.
- Use intra-documentation to interlink items. Meaning if
- Enrich your docs as much as possible wherever possible, with links to RFCs, blog posts, whitepapers, etc.
- Use
#[doc(cfg(..))]
to specify that items are available under certain configurations. - Use
#[doc(alias = "...")]
to make types and methods discoverable by other names. - In top-level docs, point the user to commonly used modules, features, types, traits and methods.
- Use
The type system is your enforcer
- Type systems are self-documenting and misuse-resistant.
- Use
semantic typing
to add types which represent themeaning
of a value, not just its primitive type. For example use enums instead of aboolean
. Usestruct CreditCardNumber
instead ofu32
. - Use ZSTs (zero-sized types) to indicate a particular fact is true about an instance of a type. For example:
#![allow(unused)] fn main() { struct Grounded; struct Launched; struct Rocket<Stage = Grounded> { stage: std::marker::PhantomData<Stage>, } impl Default for Rocket<Grounded> {} // Methods only acceptable when rocket is still grounded impl Rocket<Grounded> { pub fn launch(self) -> Rocket<Launched> { .. } } // Methods only acceptable when rocket is in the air impl Rocket<Launched> { pub fn accelerate(&mut self) { } pub fn decelerate(&mut self) { } } // Methods acceptable in either case impl <Stage> Rocket<Stage> { pub fn color(&self) -> Color { .. } pub fn weight(&self) -> Kilograms { .. } } }
- If a function you are writing accepts a pointer argument, but only uses it if another Boolean argument is
true
, then it's better to combine them into anenum
, one variant forMyPointer::false
and another variant forMyPointer::true(SomePointer)
. - Make use of the
#[must_use]
annotation. The compiler issues warnings if the user's code receives an element of the annotated type and doesn't handle it.
Constrained Interfaces
Always think carefully before you make user-visible changes. Frequent backward-incompatible changes suck.
Some changes are deceptively backward incompatible, and you might not know it when you make the change.
Type Modifications are not backwards-compatible
- This involves renaming or removing a public type.
- Use visibility modifiers, i.e.
pub(crate)
andpub(in path)
wherever possible to reduce the scope of things that a user can use.
- Use visibility modifiers, i.e.
- Adding private fields is not backwards-compatible.
- Going from zero fields to one private field changes the constructor.
- Going from some public fields to another private field changes the semantics for Rust's exhaustive pattern matches because
rustc
sees the private fields that users cannot see. - Make use of the
#[non_exhaustive]
attribute to mitigate this issue. This unfortunately makes it so that users cannot rely on exhaustive pattern matches, though.
Adding/Removing trait implementations is not backwards-compatible
- Users may have created their own implementations of a trait.
- A new blanket implementation will break Rust's coherence rules.
- A new implementation for an existing local trait may cause a name conflict.
- A new implementation for a foreign trait may also break coherence rules if a user already implemented it.
- Removing a trait implementation is a breaking change for obvious reasons: users may be relying on an implemented method.
- Implementing new traits is never a problem, though, since there is no chance for a user to have a conflicting implementation of the new trait.
- Sealed traits are great because users may only use them but not implement them, which immediately makes many breaking changes non-breaking.
- These are commonly used for derived traits, which are traits that provide blanket implementations that implement particular other traits. Basically:
impl Trait for T where T: SomeOthertrait
. - Only seal traits if it makes sense that users should not implement them.
- Sealed traits are not a feature of the Rust language, but a coding pattern. See above link.
- Sealed traits should always be documented.
- These are commonly used for derived traits, which are traits that provide blanket implementations that implement particular other traits. Basically:
Re-exports can make dependency upgrades a non-breaking change.
- Any foreign types you expose causes any change to those foreign types to be also a change to your interface.
- It's best to wrap foreign types in the NewType pattern, then expose only the parts of the foreign type that you think are useful. Don't just blindly implement
Deref
!!
Auto-Traits add hidden promises to your interface for nearly every type.
- In general, these include
Send
,Sync
,Unpin
,Sized
, andUnwindSafe
. - Implementations for these are automatically generated by the compiler but are also automatically not generated if a change you make makes it no longer apply.
- It is a good practice to include a simple test that checks that your type implements the traits the way you expect.
#![allow(unused)] fn main() { #[cfg(test)] mod tests { fn is_normal<T: Sized + Send + Sync + Unpin>() {} #[test] fn normal_types() { is_normal::<MyType>(); } } }