Designing Interfaces
As the codebase grows in size and complexity, it worthwhile to invest some thought and care into how you design even the internal APIs to make the experience of using and maintaining the code overtime as pleasant as possible.
1. Design Interface Principle
Interfaces should be unsurprising
, flexible
, obvious
, and constrained
.
Please have a look at the Rust API Guidelines.
2. Unsurprising
The core idea of unsurprising is to stick close to things the user is likely to already know so that they don't have to relearn concepts in a differnet way than they're used to.
2.1 Naming Practices
The interface should reuse names for things such as methods and types from other interfaces so that users can know they make certain assumptions about your methods and types.
For example:
- iter probably takes &self, and returns an *iterator.
- into_inner probably takes self and likely returns some kind of wrapped type.
- SomethingError probably implements std::error::Error and appears in various Results.
2.2 Common Traits for Types
Users in Rust will also expect to be able to print any type with {:?} and send anything and everything to another thread, and they expect that every type is Clone. Where possible, we should avoid surprising thhe user and eagerly implement most of the standard traits even if we do not need them immediately.
Common Traits: Clone
, Debug
, Send
, Sync
, Unpin
, PartialEq
, PartitalOrd
, Hash
, Eq
, Ord
, Serialize
, Deserialize
If a type does not implement one of these traits, it should be for a very good reason.
2.3 Ergonomic Trait Implementations
Rust does not automatically implement traits for references to types that implement traits. It means that you cannot generally call fn foo<T: Trait>(t: T) with &Bar, even if Bar: Trait.
For this reason, when you define a new trait, you will usually want to provide blanket implementations as appropriate for that trait for &T where T: Trait, &mut T where T: Trait and Box<T> where T: Trait.
2.4 Wrapper Types
Rust does not have object inheritance in the classical sense. However, the Deref trait and its cousin AsRef both provide something a little like inheritance.
These traits allow you to have a value of type T and call methods on some type U by calling them directly on the T-typed value if T: Deref<Target = U>.
For most wrapper types, you will also want to implement From<InnerType> and Into<Innertype> where possible so that users can easily add or remove your wrapping.
3. Flexible
3.1 Generic Arguments
Argument fully generic
with no bounds, and then just follow the compiler errors to discover what bounds you need to add.
For example: impl AsRef<str>
is a better option for &str
.
3.2 Object Safety
If the trait must have a generic method, consider whether its generic parameters can be on the trait itself or if its generic arguments can also use dynamic dispatch to preserve the object safety of the trait.
3.3 Borrowed vs. Owned
If the code needs ownership of the data, such as to call methods that take self
or to move the data to another thread, it must store the owned data.
On the other hand, if the code doesn't need to own the data, it should operate on references instead.
Common exception: small types like i32
, bool
, or f64
.
3.4 Fallible and Blocking Destructors
Problem: Once a value is dropped, we no longer have a way to communicate errors to the user except by panicking.
4. Obvious
4.1 Documentation
Clearly document any cases where your code may do something unexpected, or where it relies on the users doing something beyond what's dictated by the type signature.
Include end-to-end usage examples for your code on a crate and module level.
Organize your documentation.
Enrich documentation wherever possible.