Skip to content

Instantly share code, notes, and snippets.

@DarinM223
Last active September 21, 2023 13:24
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save DarinM223/32ae8cbd6a4eb8f895b90d0d1afddaab to your computer and use it in GitHub Desktop.
Save DarinM223/32ae8cbd6a4eb8f895b90d0d1afddaab to your computer and use it in GitHub Desktop.
Associated types

Introduction to associated types

One of the most useful things in typed programming languages is generics. Generics allows you to write code that works across multiple types while still being checkable by the compiler. Even better is that with many languages like Rust and C#, generics have a distinct performance advantage over runtime casting. However although generics are extremely useful, many programming languages that have them don't allow for convenient ways of expressing them, especially for traits/interfaces. Like in Java if you want a generic interface you are forced to use the same Name<Type1, Type2, Type3, ...> syntax that you would use for a class. However, that often leads to ugly overly-verbose code.

Here's an example: Lets say that you have two traits/interfaces Foo and Bar that depend on three subtypes and you wanted a function that takes in any implementation of both Foo and Bar and returns the first type of Foo and the third type of Bar. The traditional Rust version that is similar to other languages would look like this:

trait Foo<A, B, C> {
    // ...
}

trait Bar<A, B, C> {
    // ...
}

fn do_something<FooImpl, BarImpl, A, B, C, D, E, F>(f: FooImpl, b: BarImpl) -> (A, F)
    where FooImpl: Foo<A, B, C>, BarImpl: Bar<D, E, F>
{
    unimplemented!()
}

This piece of code looks very difficult to read, because it has to "pass on" the subtypes of Foo and Bar and most of the attention is on these subtypes. Just from reading this code it would be harder to realize that (A, F) means the first type of Foo and the third type of Bar because there is no connection between the type names and the trait that uses the types. It could be slightly easier if the types were named FooA, FooB, etc but that would cause the generic portion to get bloated with type names. Another problem is that if Foo or Bar ends up adding more subtypes then you would have to add the new subtypes manually to every function that uses them like do_something.

Enter associated types. Associated types are a completely different way of specifying generics for traits/interfaces. With associated types a piece of code can just generically handle implementations of the Foo and Bar traits without having to pass in all of the subtypes of Foo and Bar. Instead the subtypes are named inside the trait definition and to specify the trait's subtype you would use the syntax Type::Subtype.

So for the Foo and Bar example the trait definitions would be changed from:

trait Foo<A, B, C> {
    // ...
}

trait Bar<A, B, C> {
    // ...
}

to:

trait Foo {
    type A;
    type B;
    type C;
    
    // ...
}

trait Bar {
    type A;
    type B;
    type C;
    
    // ...
}

Now to start writing do_something you would do something like this:

fn do_something<F: Foo, B: Bar>(f: F, b: B) -> (F::A, B::C) {
    unimplemented!()
}

This function signature looks much cleaner than the previous do_something's one. From this function you can tell that do_something takes in something that implements Foo, something that implements Bar, and returns a tuple of Foo's first type and Bar's third type. Even better is that Foo or Bar can add extra subtypes to their definition without needing to refactor do_something.

There are a few other syntactical differences between normal generics and associated types. In order to specify the subtype inside the trait definition you can use the Self type:

trait Foo {
    type A;
    type B;
    type C;
    
    fn a_value(&self) -> Self::A;
    fn b_value(&self) -> Self::B;
    fn c_value(&self) -> Self::C;
}

Implementing a trait with associated types is similar to defining a trait in that the type declarations are in the body instead of next to the trait name:

impl Foo for FooStruct {
    type A = i32;
    type B = &'static str;
    type C = bool;
    
    // ...
}

Finally, referring to a specific implementation of an associated trait looks like Trait<Typename = Type, ...> Here are some examples:

pub type SpecificFoo = Foo<A = i32, B = &'static str, C = bool>;

fn blah(a: Foo<A = i32, B = &'static str, C = bool>, b: Bar<A = bool, B = i32, C = &'static str>) {
    unimplemented!()
}

When should we use associated types over normal generics? Well, if the number of subtypes for a trait is small and is known to be fixed, then normal generics can be more concise because you can refer an implementation of a trait as Trait<Type1, Type2> instead of Trait<TypeName1 = Type1, TypeName2 = Type2>. However, if there are a lot of types or if the number of types is not known yet, then using associated types can result in cleaner code and prevent lots of needless refactors whenever you change the subtypes.

Here are two pieces of runnable sample code that have identical results to show the differences between associated types and normal generics. You can copy and paste the code for both into the Rust Playground to run it without having to download Rust.

Normal version:

trait Foo<A, B, C> {
    fn a_value(&self) -> A;
    fn b_value(&self) -> B;
    fn c_value(&self) -> C;
}

trait Bar<A, B, C> {
    fn a_value(&self) -> A;
    fn b_value(&self) -> B;
    fn c_value(&self) -> C;
}

struct FooStruct {}

impl Foo<i32, &'static str, bool> for FooStruct {
    fn a_value(&self) -> i32 { 1 }
    fn b_value(&self) -> &'static str { "hello" }
    fn c_value(&self) -> bool { true }
}

struct BarStruct {}

impl Bar<bool, i32, &'static str> for BarStruct {
    fn a_value(&self) -> bool { false }
    fn b_value(&self) -> i32 { 2 }
    fn c_value(&self) -> &'static str { "world" }
}

fn do_something<FooImpl, BarImpl, A, B, C, D, E, F>(f: FooImpl, b: BarImpl) -> (A, F)
    where FooImpl: Foo<A, B, C>, BarImpl: Bar<D, E, F>
{
    (f.a_value(), b.c_value())
}

fn main() {
    println!("Value: {:?}", do_something(FooStruct{}, BarStruct{}));
}

Associated type version:

trait Foo {
    type A;
    type B;
    type C;
    
    fn a_value(&self) -> Self::A;
    fn b_value(&self) -> Self::B;
    fn c_value(&self) -> Self::C;
}

trait Bar {
    type A;
    type B;
    type C;
    
    fn a_value(&self) -> Self::A;
    fn b_value(&self) -> Self::B;
    fn c_value(&self) -> Self::C;
}

struct FooStruct {}

impl Foo for FooStruct {
    type A = i32;
    type B = &'static str;
    type C = bool;
    
    fn a_value(&self) -> i32 { 1 }
    fn b_value(&self) -> &'static str { "hello" }
    fn c_value(&self) -> bool { true }
}

struct BarStruct {}

impl Bar for BarStruct {
    type A = bool;
    type B = i32;
    type C = &'static str;
    
    fn a_value(&self) -> bool { false }
    fn b_value(&self) -> i32 { 2 }
    fn c_value(&self) -> &'static str { "world" }
}

fn do_something<F: Foo, B: Bar>(f: F, b: B) -> (F::A, B::C) {
    (f.a_value(), b.c_value())
}

fn main() {
    println!("Value: {:?}", do_something(FooStruct{}, BarStruct{}));
}

Rust is not the only language that has associated types. Scala and Swift also have them. Here are equivalent associated type examples for these languages:

Scala (associated types are called abstract types):

trait Foo {
  type A
  type B
  type C

  def aValue(): A
  def bValue(): B
  def cValue(): C
}

class FooStruct extends Foo {
  type A = Int
  type B = String
  type C = Boolean

  override def aValue(): Int = 1
  override def bValue(): String = "hello"
  override def cValue(): Boolean = true
}

trait Bar {
  type A
  type B
  type C

  def aValue(): A
  def bValue(): B
  def cValue(): C
}

class BarStruct extends Bar {
  type A = Boolean
  type B = Int
  type C = String

  override def aValue(): Boolean = false
  override def bValue(): Int = 2
  override def cValue(): String = "world"
}

object DoSomething {
  def doSomething[F <: Foo, B <: Bar](f: F, b: B): (F#A, B#C) = {
    (f.aValue(), b.cValue())
  }
}

object Main {
  def main(args: Array[String]): Unit = {
    println("Value: " + DoSomething.doSomething(new FooStruct(), new BarStruct()))
  }
}

Swift 2.2 (paste into Swift playground):

protocol Foo {
    associatedtype A
    associatedtype B
    associatedtype C
    
    func aValue() -> A
    func bValue() -> B
    func cValue() -> C
}

protocol Bar {
    associatedtype A
    associatedtype B
    associatedtype C
    
    func aValue() -> A
    func bValue() -> B
    func cValue() -> C
}

struct FooStruct: Foo {
    typealias A = Int
    typealias B = String
    typealias C = Bool
    
    func aValue() -> Int {
        return 1
    }
    
    func bValue() -> String {
        return "hello"
    }
    
    func cValue() -> Bool {
        return true
    }
}

struct BarStruct: Bar {
    typealias A = Bool
    typealias B = Int
    typealias C = String
    
    func aValue() -> Bool {
        return false
    }
    
    func bValue() -> Int {
        return 2
    }
    
    func cValue() -> String {
        return "world"
    }
}

func doSomething<F: Foo, B: Bar>(f: F, b: B) -> (F.A, B.C) {
    return (f.aValue(), b.cValue())
}

print("Value: ", doSomething(FooStruct(), b: BarStruct()))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment