Files
100-exercises-to-learn-rust/book/src/04_traits/10_assoc_vs_generic.md
Luca Palmieri 1aae615bb4 Automatically add exercise links to sections. (#52)
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.
2024-05-24 18:15:38 +02:00

4.5 KiB
Raw Blame History

Generics and associated types

Lets 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, its a generic parameter, T.
In the case of Deref, its an associated type, Target.

Whats 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. Its 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?

Thats why Deref uses an associated type, Target.
An associated type is uniquely determined by the trait implementation. Since you cant implement Deref more than once, youll only be able to specify one Target for a given type and there wont 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 to Self
  • 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, youll 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? Cant 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, youll find this implementation:

impl Add<&u32> for &u32 {
    type Output = u32;

    fn add(self, rhs: &u32) -> u32 {
        // [...]
    }
}

The type theyre 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 cant be a generic parameter. The output type of the operation must be uniquely determined once the types of the operands are known. Thats why its 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.

  1. 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.↩︎