We use an mdbook preprocessor to automatically generate links to the relevant exercise for each section. We remove all existing manual links and refactor the deploy process to push the rendered book to a branch.
4.5 KiB
Generics and associated types
Let’s re-examine the definition for two of the traits we studied so
far, From and Deref:
pub trait From<T> {
fn from(value: T) -> Self;
}
pub trait Deref {
type Target;
fn deref(&self) -> &Self::Target;
}They both feature type parameters.
In the case of From, it’s a generic parameter,
T.
In the case of Deref, it’s an associated type,
Target.
What’s the difference? Why use one over the other?
At most one implementation
Due to how deref coercion works, there can only be one “target” type
for a given type. E.g. String can only deref to
str. It’s about avoiding ambiguity: if you could implement
Deref multiple times for a type, which Target
type should the compiler choose when you call a &self
method?
That’s why Deref uses an associated type,
Target.
An associated type is uniquely determined by the trait
implementation. Since you can’t implement Deref
more than once, you’ll only be able to specify one Target
for a given type and there won’t be any ambiguity.
Generic traits
On the other hand, you can implement From multiple times
for a type, as long as the input type T is
different. For example, you can implement From for
WrappingU32 using both u32 and
u16 as input types:
impl From<u32> for WrappingU32 {
fn from(value: u32) -> Self {
WrappingU32 { inner: value }
}
}
impl From<u16> for WrappingU32 {
fn from(value: u16) -> Self {
WrappingU32 { inner: value.into() }
}
}This works because From<u16> and
From<u32> are considered different
traits.
There is no ambiguity: the compiler can determine which implementation
to use based on type of the value being converted.
Case study: Add
As a closing example, consider the Add trait from the
standard library:
pub trait Add<RHS = Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}It uses both mechanisms:
- it has a generic parameter,
RHS(right-hand side), which defaults toSelf - it has an associated type,
Output, the type of the result of the addition
RHS
RHS is a generic parameter to allow for different types
to be added together.
For example, you’ll find these two implementations in the standard
library:
impl Add<u32> for u32 {
type Output = u32;
fn add(self, rhs: u32) -> u32 {
// ^^^
// This could be written as `Self::Output` instead.
// The compiler doesn't care, as long as the type you
// specify here matches the type you assigned to `Output`
// right above.
// [...]
}
}
impl Add<&u32> for u32 {
type Output = u32;
fn add(self, rhs: &u32) -> u32 {
// [...]
}
}This allows the following code to compile:
let x = 5u32 + &5u32 + 6u32;because u32 implements Add<&u32>
as well as Add<u32>.
Output
Output represents the type of the result of the
addition.
Why do we need Output in the first place? Can’t we just
use Self as output, the type implementing Add?
We could, but it would limit the flexibility of the trait. In the
standard library, for example, you’ll find this implementation:
impl Add<&u32> for &u32 {
type Output = u32;
fn add(self, rhs: &u32) -> u32 {
// [...]
}
}The type they’re implementing the trait for is &u32,
but the result of the addition is u32.
It would be impossible1 to provide this implementation if
add had to return Self,
i.e. &u32 in this case. Output lets
std decouple the implementor from the return type, thus
supporting this case.
On the other hand, Output can’t be a generic parameter.
The output type of the operation must be uniquely
determined once the types of the operands are known. That’s why it’s an
associated type: for a given combination of implementor and generic
parameters, there is only one Output type.
Conclusion
To recap:
- Use an associated type when the type must be uniquely determined for a given trait implementation.
- Use a generic parameter when you want to allow multiple implementations of the trait for the same type, with different input types.
Flexibility is rarely free: the trait definition is more complex due to
Output, and implementors have to reason about what they want to return. The trade-off is only justified if that flexibility is actually needed. Keep that in mind when designing your own traits.↩︎