The golden rule of any interface in the broad sense, and of traits in Rust in particular, is - don't require more than necessary
In this article, we'll cover:
- What a "minimal interface" means in the context of trait bounds
- What problems arise from excessive bounds
- When it makes sense to break this rule
- How to make APIs cleaner and more comprehensible for users
Example: Iterator printer
Let's imagine we need to implement an iterator type that prints each of its elements. This type doesn't have much practical use, so treat it purely as an illustration
The type wraps an existing iterator, so we'll need a generic parameter for the inner iterator type - let's say something like I: Iterator
. Since we want to print its elements, the iterator's items must implement the Display
trait. As a result, the type could be declared like this:
use std::fmt::Display;
pub struct Printer<I>
where
I: Iterator<Item: Display>,
{
iter: I,
}
We don't want users accessing the iter
field directly, so we'll make it private. Let's also implement the Iterator
trait:
impl<I> Iterator for Printer<I>
where
I: Iterator<Item: Display>,
{
type Item = I::Item;
fn next(&mut self) -> Option<Self::Item> {
let item = self.iter.next()?;
println!("{item}");
Some(item)
}
}
We'll add a constructor and a into_inner
method to get the inner iterator back:
impl<I> Printer<I>
where
I: Iterator<Item: Display>,
{
pub fn new(iter: I) -> Self {
Self { iter }
}
pub fn into_inner(self) -> I {
self.iter
}
}
Let's also implement a couple of useful traits since we're writing something library-like. For example, what if the user wants to clone a value of our type? Let's implement Clone
- but I'll do it explicitly rather than using derive
, purely for illustrative purposes. To be able to clone the contents, we'll require that it implements the Clone
trait
impl<I> Clone for Printer<I>
where
I: Iterator<Item: Display> + Clone,
{
fn clone(&self) -> Self {
Self {
iter: self.iter.clone(),
}
}
}
What's the problem?
Well, at first glance everything seems logical, but even at this stage we can already see problems with this approach. Any impl
that involves our type Printer<I>
inevitably requires I: Iterator<Item: Display>
. If we remove this bound, the code simply won't compile - even code that doesn't use Iterator
at all:
impl<I> Printer<I> {
// ^^^^^^^^^^ `I` is not an iterator
pub fn new(iter: I) -> Self {
Self { iter }
}
pub fn into_inner(self) -> I {
self.iter
}
}
At the very least, this clutters the code with unnecessary details. Why should we require I: Iterator<Item: Display>
just to construct, destructure, or clone a value of our type? Technically, there's nothing preventing us from constructing Printer<I>
with any type. Especially in the implementation of the into_inner
method - we just need to extract the value, nothing more. There's no need to iterate over it, and certainly no need to print its elements.
The code of our library becomes more complex for no good reason. Moreover, all these bounds will show up in the crate's documentation - even if the user doesn't look at the source code, they'll still see the trait bounds and think about them in places where they're not needed. That alone is a good enough reason to remove the bounds from all impl
s - except for the ones that truly need them. In particular: impl<I> Iterator for Printer<I>
, because otherwise we simply wouldn't be able to implement the trait
Do you need bounds on type parameters?
Moreover, there's absolutely no need for any bounds in the declaration of a struct. When defining a struct
or enum
, we're describing only the data - unlike in functions or methods, there's no actual code involved. We can't really make use of the fact that a generic parameter implements Iterator
, or any other trait, in the type definition itself. Only in rather exceptional cases does it make sense - a good example is the Peekable
type from the standard library. Take note: it adds the I: Iterator
bound at the type level, which means the bound applies to all its methods and trait implementations as well
Why is that needed? Let's open the source code and see exactly how Peekable
is declared
pub struct Peekable<I>
where
I: Iterator,
{
iter: I,
/// Remember a peeked value, even if it was None.
peeked: Option<Option<I::Item>>,
}
Here, the purpose of the Peekable
type becomes clear - it's an iterator that allows peeking one item ahead. For that to work, it needs to store an item somewhere. But what type will that item have? Naturally, it will be I::Item
from the Iterator
trait. But in order to refer to Item
, we must require that I
implements Iterator
. Otherwise, we simply wouldn't know the associated type. In this case, the trait bound is completely justified
But what about our hypothetical Printer
type? As we've already seen, applying bounds to the generic parameter is not necessary at all. Let's simplify the type definition accordingly
pub struct Printer<I> {
iter: I,
}
Do you need bounds on impl blocks?
The entire point of our Printer
type is to be an iterator. Without that constraint, the type doesn't make much sense - so maybe it's worth requiring I: Iterator<Item: Display>
at least for clarity? Sure, it might clutter the library's code a bit, but it would make the intention behind the type immediately obvious. After all, if I, as the library author, write a bit more code, it might make it easier for the user to work with my types and traits. For example, what if a user creates a Printer
value with something that's not an iterator inside? Wouldn't it be better for them to get an error right away rather than only when they try to use it as an iterator?
Indeed, there are cases where it's better to require the necessary trait implementation as early as possible. For instance, a user might be composing some complex iterator using many adapters, only to later find out the final type doesn't actually implement Iterator
because one of its parts doesn't. Imagine writing code like this:
let n = (0..6).map("?").count();
It doesn't compile because in order to call the count
method on the final type, self
must be an iterator
But what's the issue here? If the map
method didn't check the necessary bounds and simply constructed its adapter, then when calling count
, I would have gotten the following error message:
error[E0599]: the method `count` exists for struct `Map<Range<{integer}>, &str>`, but its trait bounds were not satisfied
--> src/main.rs:2:29
|
2 | let n = (0..6).map("?").count();
| ^^^^^ method cannot be called on `Map<Range<{integer}>, &str>` due to unsatisfied trait bounds
|
::: /home/nano/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/iter/adapters/map.rs:61:1
|
61 | pub struct Map<I, F> {
| -------------------- doesn't satisfy `Map<std::ops::Range<{integer}>, &str>: Iterator`
|
= note: the following trait bounds were not satisfied:
`<&str as FnOnce<({integer},)>>::Output = _`
which is required by `Map<std::ops::Range<{integer}>, &str>: Iterator`
`&str: FnMut<({integer},)>`
which is required by `Map<std::ops::Range<{integer}>, &str>: Iterator`
`Map<std::ops::Range<{integer}>, &str>: Iterator`
which is required by `&mut Map<std::ops::Range<{integer}>, &str>: Iterator`
Fortunately, this example is simple enough that I can still extract the root cause of the problem - namely, the line which is required by 'Map<std::ops::Range<{integer}>, &str>: Iterator' '&str: FnMut<({integer},)>'
, where I can see that &str
doesn't implement the FnMut
trait
But this is still quite hard to parse, and the more complex the iterator I'm building, the harder it gets. At some point, the error will become completely unreadable, and I won't be able to figure out what's actually wrong
Thankfully, the map
method on iterators enforces the necessary trait bounds ahead of time, and before I hit the error above, I'll first see this:
error[E0277]: expected a `FnMut({integer})` closure, found `&str`
--> src/main.rs:2:24
|
2 | let n = (0..6).map("?").count();
| --- ^^^ expected an `FnMut({integer})` closure, found `&str`
| |
| required by a bound introduced by this call
|
= help: the trait `Fn({integer})` is not implemented for `str`
= note: required for `&str` to implement `FnMut({integer})`
note: required by a bound in `map`
--> /home/nano/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/iter/traits/iterator.rs:748:12
|
745 | fn map<B, F>(self, f: F) -> Map<Self, F>
| --- required by a bound in this associated function
...
748 | F: FnMut(Self::Item) -> B,
| ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Iterator::map`
Much clearer already: &str
does not implement FnMut
, and that's stated right at the beginning of the error log
The map
method itself is implemented like this:
pub trait Iterator {
/**/
fn map<B, F>(self, f: F) -> Map<Self, F>
where
Self: Sized,
F: FnMut(Self::Item) -> B,
{
Map::new(self, f)
}
/**/
}
It is clear here that the method signature requires F: FnMut(Self::Item) -> B
for the function parameter. Although, in fact, a Map
value can be constructed without this constraint. This example clearly shows in which cases it is useful to deviate from the minimal interface rule and require more, even when it is technically not necessary
Let's return to our example. To provide clearer error messages, it would make sense to leave the constraint I: Iterator<Item: Display>
- but only on the constructor
The most optimal solution for our public API would be the following impl
:
impl<I> Printer<I> {
pub fn new(iter: I) -> Self
where
// The restriction is only here
I: Iterator<Item: Display>,
{
Self { iter }
}
pub fn into_inner(self) -> I {
self.iter
}
}
Simplifying method signatures
This is a good compromise: we no longer require too much where it's unnecessary, but we still give the user an early warning that creating a value of this type only makes sense if I
is an iterator - and not just any iterator, but one where Item: Display
. At the same time, once the value is created, nothing prevents calling into_inner
on it. This makes the user's code simpler in some places
For example, what if the user writes a function like this?
fn debug_inner<I>(printer: Printer<I>)
where
I: Debug,
{
println!("{:?}", printer.into_inner());
}
Here, the user's code requires exactly what is necessary - namely, Debug
for the inner value and nothing more. In the earlier version of the library with unnecessary constraints, they would have to write something like this:
fn debug_inner<I>(printer: Printer<I>)
where
I: Iterator<Item: Display> + Debug,
// ^^^^^^^^^^^^^^^^^^^^^^^ this constraint is not necessary for the implementation
{
println!("{:?}", printer.into_inner());
}
Even though neither iteration nor printing of the iterator elements is used in any way here. The extra constraints only add confusion, making the code not just more complex, but also harder to understand. A user, upon seeing such a signature, might naturally assume that the function will perform iteration and printing of elements - but that's not the case
Another use case: what if no methods of the type are called at all? What if the user simply wants to store values of our type inside another structure?
use std::collections::HashMap;
fn check_key_hello<I>(m: &HashMap<&str, Printer<I>>) -> bool {
m.contains_key("hello")
}
If the constraints weren't minimal and were instead applied to the entire struct, this would also require specifying I: Iterator<Item: Display>
, even though that makes no sense here. Again, HashMap
is just an example - there could be a custom user type that contains Printer<I>
as a field, or anything else. In any case, there's a lot of potential code that doesn't use types for their primary purpose but instead works with them indirectly
Now let's look at some user code like this:
fn make_boxed_printer<I>(iter: I) -> Box<Printer<I>>
where
I: Iterator<Item: Display>,
{
Box::new(Printer::new(iter))
}
Is I: Iterator<Item: Display>
needed here? Technically, no - it's not required for the code to compile. However, earlier we decided that requiring the Iterator
implementation in the new
constructor might be helpful
Simplifying trait and type signatures
I hope I managed to demonstrate the questionable nature of such code:
struct Foo<T>(Vec<T>)
where
T: Display;
And especially, if you see something like this:
struct Foo<T>(Vec<T>)
where
T: Clone + Display + Debug + PartialEq + Eq + Send + Sync;
This clearly indicates that something is off with the code and that it can be significantly simplified. Such constraints are never necessary in practice - they only serve to clutter the code
Similarly, if you come across something like this:
trait Foo: Clone + Display + Debug + PartialEq + Eq + Send + Sync {/**/}
It's also worth considering whether all these constraints are truly necessary, even if they are "required" and ultimately used in the implementations. A more robust approach is to start not from the implementations, but from the actual requirements of the trait - that is, how the trait is intended to be used. More specific bounds make sense only in a specific context, rather than in the trait definition itself:
trait Foo {/**/}
fn use_comparable_foo<F>(f: F)
where
F: Foo + Eq,
{/**/}
It's also worth noting that many existing traits already come with constraints. For example, the Ord
trait from the standard library requires Eq
and PartialOrd
as prerequisites. Moreover, Eq
itself requires PartialEq
pub trait Ord: Eq + PartialOrd {/**/}
pub trait Eq: PartialEq {}
Therefore, there's no practical reason to write a signature like this:
fn use_t<T>(t: T)
where
T: PartialEq + Eq + PartialOrd + Ord,
{/**/}
Whereas in reality, only Ord
is strictly required.
It also makes sense to split constraints more granularly:
impl<T> Foo<T>
where
T: Clone + Display + Debug + PartialEq + Eq + Send + Sync,
{
fn foo(&self) {/**/}
fn bar(&self) {/**/}
}
Whenever possible, localize trait bounds and, instead of this, try to express the requirements more precisely.
impl<T> Foo<T> {
fn foo(&self)
where
T: Clone + Display,
{/**/}
fn bar(&self)
where
T: Eq,
{/**/}
}
Conclusion
Still, there are no hard and fast rules for every situation. However, carefully considering how your public API looks is important. This is especially relevant for libraries, though issues like code bloat and complexity can arise even in more localized code. In general, I prefer to keep the code as minimal as possible and the interfaces concise. Also, when changing an implementation, consider revisiting the trait bounds - some of them might no longer be necessary. Try removing a bound and see whether it still compiles
I firmly believe that being mindful of trait bounds is a good practice. I also hope to explore this topic further using a more common real-world example - everyone's favorite: Send + Sync + 'static
. But that will be the subject of another article
To conclude this one - why go through all of this? Here's what we gain:
- Simpler and more concise code and documentation
- We don't mislead users: we don't require traits we don't actually use
- Instead, we hint: this function or method depends on the implementation of this trait
- A more flexible and convenient API for the user
Bonus: Trait aliases
But what if we do need to specify multiple trait bounds - across many places? For example, suppose we're working with the Read
and Write
traits, and we need to repeatedly write I: Read + Write
. Or maybe there are even more traits involved?
In such cases, we can use what's informally known as a "trait alias". Rust doesn't currently support true trait aliases natively, so we use a slightly more verbose pattern - an idiom commonly referred to as a "blanket implementation":
pub trait Io: Read + Write {}
impl<I> Io for I where I: Read + Write {}
Great, now the bound I: Io
is fully interchangeable with I: Read + Write
. It works both ways: any type that implements Read + Write
can be used as Io
and vice versa.
Sometimes, this can genuinely simplify the code. However, this pattern should be used with caution. It's very easy to abuse, to the point where users may struggle to understand what constraints are actually required for their types. It also introduces an extra layer of mental abstraction with no functional gain - after all, it creates a new trait that needs to be described, documented, and integrated into the existing API
So, try to use this pattern only when it's truly justified.