On Generics and Associated Types

A Rust language feature adventure!

Compared to other languages I've learned, Rust has a fair few concepts that can be a bit tricky to get your head around. Borrowing, ownership, and the borrow-checker are common enough to have spawned a range of memes on their own, and I've personally spent hours on lifetime issues only to give up and rewrite something using cloning.

Associated types, though not something that'll have you banging your head on your desk for hours, is something that it took me quite a few tries to finally understand---or at least think I understand.

What really never stuck was how it was different from generics, and why you'd need (or event want) associated types. So what's a good way to learn and internalize a topic like this? Well, write something down and release it to the internet, of course! They'll let you know if you're wrong.[1]

tl;dr:

The quick and dirty answer to when to use generics and when to use associated types is: Use generics if it makes sense to have multiple implementations of a trait for a specific type (such as the From<T> trait). Otherwise, use associated types (like Iterator and Deref).

Goals and constraints of this post

This post is intended to demonstrate and explain the differences and similarities between associated types and generic types. It will deal specifically with traits, as this is the only place where associated types come into play.

Furthermore, even if we're talking about associated types, we will not venture into the dark forest of generic associated types. However, if you're curious about this and haven't kept up to date, I encourage you to look through the RFC.

If, after reading this post, you're still not sure what I'm on about, check out the /Advanced Traits/ chapter of the Book, and specifically the section on associated types.

Finally, this post assumes you have some familiarity with programming (and with Rust specifically), and with some forms of generic programming. For a primer on generic types in Rust, check out Chapter 10.1, /Generic Data Types/, of the book

Definitions

To make sure we're all on the same page, let's have some quick definitions to start us off, shall we?

Generic types

In the context of traits, generic types, also known as type parameters, are a way of deferring the specific types that your trait uses until the trait is implemented. Generic types can be completely open, such that any type would work, or they can be constrained to types that implement some trait.

Take, for instance, Rust's ~std::convert::From<T>~ trait. The T in the type signature says that this type is generic over any type T [2], meaning you can convert any type T into the type you're implementing the trait for.

Constrained (or bounded) generic types are more often seen in generic functions than in generic traits, but what they do is allow the author of trait X to say that 'only types which implement some other trait ~Y~ can be used for this trait'. We'll see examples of this later, so don't worry if it's a bit fuzzy right now.

Associated types

Associated types are, as the name implies, types that are associated with a trait. When you define the trait, the type is still unspecified. Much like with generics, you can put constraints on the type if you want to, or you can choose not to.

One of the most prominent examples of a trait with associated types is the ~Iterator~ trait. The Iterator trait has an associated type Item and a function next. The next function returns an Option<Self::Item>. You could have done the same thing with generic types, but, as we'll see later, using associated types offer some benefits in certain situations.

Syntax

Before we go any further, let's just quickly review the syntax for these concepts. If for no other reason, then just to make everything a bit less abstract. We'll define two traits, Generic and Associated, which use generics and associated types respectively, and we'll look at using bounds and default types as well.

Basic traits

A type-parameterized trait can look a little something like this:

trait Generic<T> {
    fn get(&self) -> T;
}

Similarly, a similar trait with an associated type looks like this:

trait Associated {
    type T;
    fn get(&self) -> Self::T;
}

Note how the type gets moved from the type signature and into the trait definition itself, and how, when referencing it later, we need to use Self::T, instead of just T.

With constraints

If we want to set constraints on the associated type or type parameter, we use the same syntax as Rust uses everywhere else for bounds: the : operator.

For instance, say we want to constrain our types to only types that implement the core::fmt::Display trait:

trait Generic<T: Display> {
    fn get(&self) -> T;
}

// or using the `where` keyword
trait Generic<T>
where
    T: Display,
{
    fn get(&self) -> T;
}

With associated types, the syntax is much the same:

trait Associated {
    type T: Display;
    fn get(&self) -> Self::T;
}

With default types

Rust has a cool feature for generic types where you can set the default type, which will be assumed if no type is specified. This can be useful if, for most use cases, you want to use a specific type, but want to be able to override it sometimes. See the section on default generic types in the Book for more information.

They look like this:

// basic trait, no constraint
trait Generic<T = String> {
    // ...
}

// with constraint
trait Generic<T: Display = String> {
    // ...
}

// or using the `where` clause
trait Generic<T = String>
where
    T: Display,
{
    // ...
}

From what I tried, it seems you cannot put the default type (= String) in the where clause of the trait.

For associated types, there is no such thing as default types on stable rust today. However, if you're on nightly, you can use the #![feature(associated_type_defaults)] flag, which enables this. Judging from the GitHub tracking issue, this is being worked on but I've not seen anything about stabilization of this yet.

But let's see what it'd look like:

#![feature(associated_type_defaults)]

// simple
trait Associated {
    type T = String;
    // ...
}

// with constraint
trait Associated {
    type T: Display = String;
    // ...
}

It's pretty neat and pretty similar. You could even do something like this:

trait Associated {
    type T: Display = String;
    type U = Self::T;
    // ...
}

Don't know when it'd be useful, but it's nifty 🤷

Commonalities

Now that we've covered what they are and what the syntax looks like, let's continue by looking at what they have in common.

The most important thing is that both generics and associated types allow you to defer the decision of what type to use for the trait implementation. Even if the notation is a bit different, anywhere you have an associated type, you can replace it with a generic type instead (though the opposite does not hold true). As the the RFC puts it: "associated types do not increase the expressiveness of traits per se, because you can always use extra type parameters to a trait instead". However, they do offer other benefits.

The fact that you can always use generics instead of associated types is why it took me so long to understand what associated types exist for. As we move into the next section, we'll examine what makes them different, and why you'd want to choose one over the other.

Differences

As we've seen, generics and associated types cover a lot of the same use cases, but there are some reasons why you might choose one over the other.

Generics allow you to implement the same trait numerous times for the same type by changing the type parameter. The From<T> trait was mentioned earlier, and it's a great example of this. Because From<T> uses a generic type, we can implement it for any number of type parameters.

For instance, say you have a type MyNumeric. You can implement From<u8>, From<u16>, From<u32>, and so on. This makes generics very useful if it makes sense to have multiple trait implementations varying only in type parameters.

Associated types, on the other hand, only allow a single implementation. Because a type can only implement a trait once, this can be used to constrain the number of implementations.

The ~Deref~ trait comes to mind. Deref has an associated type Target, which it can be dereferenced to. It would get really confusing if a type could implement Deref into an arbitrary set of other types (and probably very tricky for type inference!).

Because a trait can only be implemented once per type, associated types also offer some notational benefits. Using associated types means you don't have to add type annotations for all the extra types. This is touted as an engineering benefit in the RFC.

Summary and further reading

In short, use generics when you want to a type A to be able to implement a trait any number of times for different type parameters, such as in the case of the From<T> trait.

Use associated types if it makes sense for a type to only implement the trait once, such as with Iterator and Deref.

If you want to know more about associated types and what problems they solve, I recommend starting with the RFC that introduced them and the section on associated types in the Book. The section on the ~Add~ trait, which uses both generics (with defaults) and associated types, is also well worth a read. As an alternative resource, this Stack Overflow response also contains a well-worded explanation, with examples, of when you'd use one over the other.

Footnotes

[1]

This is mostly said in jest. I haven't actually gotten very many corrections on statements I've made at all; and the ones I have had, have always been very nice. I like you, reader. You're my friend.

[2]

Well, actually, it says that this type is generic over any type T that implements the Sized trait. We'll gloss over this in this article, but for more information, check out this section from chapter 19.4, /Advanced Functions and Closures/, of the Book.

[1]

This is mostly said in jest. I haven't actually gotten very many corrections on statements I've made at all; and the ones I have had, have always been very nice. I like you, reader. You're my friend.

[2]

Well, actually, it says that this type is generic over any type T that implements the Sized trait. We'll gloss over this in this article, but for more information, check out this section from chapter 19.4, /Advanced Functions and Closures/, of the Book.



Thomas Heartman is a developer, writer, speaker, and one of those odd people who enjoy lifting heavy things and putting them back down again. Preferably with others. Doing his best to gain and share as much knowledge as possible.