Skip to content
academia.sh

Lesson 03 / 15

Associated Types

A trait can carry a type within itself without taking a type parameter — an associated type; when the same contract is written with an associated type and with a generic parameter, the run count comes out exactly the same, the difference is in what the caller has to write in the signature, and an associated type carries a constraint that allows only one implementation per type.

Published Updated

In this lesson

  • Defines the associated type syntax and distinguishes it from the same contract written with a generic parameter
  • Shows that the two forms' run counts come out the same, the difference being in the information the caller has to write
  • Compares and explains that an associated type is a constraint allowing one implementation per type, while a generic parameter allows no such thing
Contents

The previous lesson showed how a trait bound constrains a type parameter from the outside: the T: Label syntax said which types the caller could pass in. A trait can sometimes do the opposite too — carry a type within the contract itself, a type decided not by the caller but by the implementer. The kernel that ran in the course’s first lesson already had an example of this: the Add trait’s implementation carried the line type Output = Length; — that line in the operator-overloading section is exactly this lesson’s subject, the associated type. That lesson passed over it without introducing it; this lesson comes back to it.

A trait bound answered the caller’s question of “which types can I pass to this function.” An associated type answers a different question: “a type implementing this contract carries which other type alongside itself.” A trait can declare not just method signatures but also a helper type those methods work with — this helper type can differ per implementation, but is fixed for any single implementation. This means a contract can define not just “what will be done” but also “which type it will work with.”

Neither of the two bodies measured in this lesson leaves the course’s three classes — both fall into just one: every time. Neither the associated type nor the generic parameter causes a body to be deferred or skipped — both only shape the body’s signature, not whether it runs when called. This is the same observation as the previous two lessons: the class is determined not by how the body is named or where it is bound, but by what happens when it is called.

What Is an Associated Type

Writing type Name; inside a trait declaration means “every type implementing this contract must also declare a Name type within itself.” During implementation, this slot is filled by a type Name = ConcreteType; line. The two traits below define the same job with two different notations:

// times.rs — slice: the record/times ledger, the associated-type and generic-parameter contracts.

use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

pub type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;

pub fn record(ledger: &Ledger, name: &str) {
    *ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}

pub fn times(ledger: &Ledger, name: &str) -> u32 {
    *ledger.borrow().get(name).unwrap_or(&0)
}

// Contract carries a type within itself: Product is decided by the implementer.
pub trait Contract {
    type Product;
    fn produce(&self, ledger: &Ledger) -> Self::Product;
}

// The same idea, with a generic parameter: C is a type the caller supplies from outside.
pub trait GenericContract<C> {
    fn produce_generic(&self, ledger: &Ledger) -> C;
}

pub struct Container {
    pub value: i32,
}

impl Contract for Container {
    type Product = i32;
    fn produce(&self, ledger: &Ledger) -> i32 {
        record(ledger, "associated-type-body");
        self.value
    }
}

impl GenericContract<i32> for Container {
    fn produce_generic(&self, ledger: &Ledger) -> i32 {
        record(ledger, "generic-parameter-body");
        self.value
    }
}

The Self:: prefix in the Self::Product syntax is not arbitrary: when the trait declaration is written, it is not yet known which type will implement this contract, so Product cannot be written as a bare name — it has to be written as Self::Product, meaning “the implementing type’s own Product.” Inside the impl block this ambiguity disappears — since Self now corresponds to Container, the line type Product = i32; can write i32 directly, with no need for the Self:: prefix. The same name (Product) plays two different roles: a placeholder in the trait declaration, a decision inside the implementation.

In Contract, Product is part of the contract: the return type of the produce method is written as Self::Product, not a concrete type name. When Container implements this contract, it declares once, at the top of the impl block, that Product is i32; the rest of the block inherits this decision. In GenericContract<C>, the same information is instead inside the trait’s name: when impl GenericContract<i32> for Container is written, C resolves to i32 there, but because this information is part of the trait name, it can be repeated more than once, with different C values — the constraint side of this difference is looked at below.

Same Count, Different Syntax

Calling both notations the same number of times makes the run count directly comparable.

// associated-type.rs — the same job in two notations: associated type and generic parameter.
mod times;
use times::{times, Ledger, Container, Contract, GenericContract};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let container = Container { value: 9 };

    // The caller never writes Product in the associated-type form - the compiler already knows it.
    let _ = container.produce(&ledger);
    let _ = container.produce(&ledger);
    let _ = container.produce(&ledger);

    // The same number of calls, the same body, with the generic parameter too.
    let _ = GenericContract::<i32>::produce_generic(&container, &ledger);
    let _ = GenericContract::<i32>::produce_generic(&container, &ledger);
    let _ = GenericContract::<i32>::produce_generic(&container, &ledger);

    let mut bodies: BTreeMap<&str, u32> = BTreeMap::new();
    bodies.insert("associated-type-body", times(&ledger, "associated-type-body"));
    bodies.insert("generic-parameter-body", times(&ledger, "generic-parameter-body"));

    println!("{:<24}{}", "body", "how many times");
    for (name, n) in &bodies {
        println!("{:<24}{}", name, n);
    }
}
body                    how many times
associated-type-body    3
generic-parameter-body  3

TG5. Both ran three times — two rows in the every time class, the exact same number. The difference does not show up in the run ledger, it shows up in the call line itself: container.produce(&ledger) never says what Product is, because the compiler already knows it from Container‘s Contract implementation. GenericContract::<i32>::produce_generic(&container, &ledger), on the other hand, carries i32 explicitly — because Container could have more than one implementation of GenericContract (shown below), stating which one is meant becomes the caller’s job.

The Interfaces, Errors and Generics course wrote Go’s generics from the binding axis; what’s measured here is not the question that course asked, it is the two notations’ body count — and the count comes out exactly equal. This shows that an associated type and a generic parameter carry no different cost at run time; the difference between them is entirely in the source’s readability and in the information the caller has to write.

An Associated Type Is Per Type

The constraint applies not to the trait as a whole, but separately to each implementation. Container‘s Product is fixed to i32 once, but another type can implement the same Contract with its own Product — what’s being constrained is not “this trait must use one Product everywhere,” it is “this type must have chosen one Product for this trait.”

// two-types.rs — an associated type is fixed per implementation, not per trait.
mod times;
use times::{times, Ledger, Container, Contract, GenericContract};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

struct Labeled {
    name: String,
}

impl Contract for Labeled {
    type Product = String;
    fn produce(&self, ledger: &Ledger) -> String {
        times::record(ledger, "labeled-body");
        self.name.clone()
    }
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let container = Container { value: 5 };
    let labeled = Labeled { name: String::from("title") };

    let numeric: i32 = container.produce(&ledger);
    let textual: String = labeled.produce(&ledger);
    let _ = GenericContract::<i32>::produce_generic(&container, &ledger);

    println!("numeric={} textual={}", numeric, textual);
    println!("associated-type-body={}", times(&ledger, "associated-type-body"));
    println!("labeled-body={}", times(&ledger, "labeled-body"));
}
numeric=5 textual=title
associated-type-body=1
labeled-body=1

TG8. Container implements the same Contract with Product = i32, Labeled with Product = String, and both sit side by side in the same source — this shows that the trait itself is not bound to one type, only that type’s implementation is. The constraint still holds: neither Container nor Labeled can implement Contract a second time with a different Product. The example also calls GenericContract<i32> on Container once more — to show that two separate contracts (Contract and GenericContract) can coexist on the same type in the same source, without excluding each other.

Using It Inside Generic Code

The difference looks small while calling on a single concrete type (Container): both add about three words, a difference of barely a few characters. The difference grows once Contract or GenericContract is used in the bound of another generic function.

// generic-use.rs — using Contract in the bound of another generic function.
mod times;
use times::{times, record, Ledger, Container, Contract, GenericContract};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;

// No need to write the Product type separately - T::Product is resolved by the compiler.
fn apply<T: Contract>(x: &T, ledger: &Ledger) -> T::Product {
    record(ledger, "generic-use-body");
    x.produce(ledger)
}

// The same idea with a generic parameter: C has to be ADDED to the signature SEPARATELY.
fn apply_generic<T: GenericContract<C>, C>(x: &T, ledger: &Ledger) -> C {
    record(ledger, "generic-use-generic-body");
    x.produce_generic(ledger)
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
    let container = Container { value: 9 };

    let a = apply(&container, &ledger);
    let b = apply_generic(&container, &ledger);

    println!("apply={} apply_generic={}", a, b);
    println!("generic-use-body={}", times(&ledger, "generic-use-body"));
    println!("generic-use-generic-body={}", times(&ledger, "generic-use-generic-body"));
}
apply=9 apply_generic=9
generic-use-body=1
generic-use-generic-body=1

TG7. apply‘s return type is T::Product — whatever T resolves to, its Product is inherited automatically; there is no separate slot for Product in the signature, because none is needed. apply_generic, after writing T: GenericContract<C> in its bound, is also forced to add C to the function’s own type parameter list — C cannot be inferred automatically from T, because T could in principle carry more than one implementation of GenericContract, and the compiler could not know which one is meant. This shows that the difference at the call site from the previous section turns into a signature difference when writing generic code: apply‘s signature carries a single type parameter while apply_generic‘s carries two, even though the second function’s only job is the same as the first’s.

The Trait Bounds lesson’s combine<T: Label, U: Label> example showed how two independent type parameters can stand side by side; apply_generic<T: GenericContract<C>, C> uses the same notation, but here C is not an independent choice — it is a repetition of information T already carries. The two functions fit syntactically into the same pattern — more than one parameter inside <...> — but while in combine each parameter really answers a different question, in apply_generic, C only asks the caller to rewrite information T already carries. An associated type removes this repetition.

The Measurement’s Limit

An associated type adds a constraint to the contract: a type can implement Contract only once, because Product is bound to a single value for that type. Once Product = i32 is declared for Container, a second impl Contract for Container block — a second one that tries to make Product be String — is rejected by the compiler; this rejection is not put in a code block, because source that does not compile is never shown as a code block in this repository. The constraint itself is described in prose; the accepted counterpart is already run above by associated-type.rs — there is a single Contract implementation for Container, and the source compiles as it stands.

GenericContract<C> does not carry this same constraint: because C is part of the trait name, GenericContract<i32> and GenericContract<String> are two separate traits to the compiler, and Container can implement both at once.

// constraint.rs — a generic parameter allows more than one implementation for the same type;
// an associated type does not (one type = one Product binding).
trait GenericContract<C> {
    fn produce_generic(&self) -> C;
}

struct Container {
    value: i32,
}

impl GenericContract<i32> for Container {
    fn produce_generic(&self) -> i32 {
        self.value
    }
}

impl GenericContract<String> for Container {
    fn produce_generic(&self) -> String {
        self.value.to_string()
    }
}

fn main() {
    let container = Container { value: 9 };
    let numeric: i32 = GenericContract::<i32>::produce_generic(&container);
    let textual: String = GenericContract::<String>::produce_generic(&container);
    println!("numeric={} textual={}", numeric, textual);
}
numeric=9 textual=9

TG6. Container implements both GenericContract<i32> and GenericContract<String> at the same time — both are accepted, both compile. This shows that the constraint an associated type offers is not a shortcoming but a deliberate design choice: if Contract had been used instead, Container’s Product would be fixed to a single value, and the caller would never have to ask “which Product?” Using GenericContract buys this flexibility, but its cost shows up at the call site: container.produce_generic(&ledger) alone is not enough, since Container carries two implementations — which C is meant (::<i32> or ::<String>) has to be written explicitly. With an associated type, this statement is never needed, because there is only ever one option.

This constraint should be read not as a shortcoming but as a design tool. If a contract expects, by the nature of a type, to work with a single output type (say, “the element type it holds” for a container, or “the result type it produces” for a converter), an associated type turns this uniqueness into a syntax rule: the implementer cannot accidentally implement the same type with two different output types, because the compiler does not allow it. A generic parameter does not force this uniqueness — where a type genuinely can work with more than one output form (as seen in this lesson, where a Container can have both a numeric and a textual counterpart), it is the right tool. The reason both notations exist is that “one type = one output” and “one type = more than one output” ask for different contracts.

Summary

  • An associated type is a type slot a trait carries within itself with a type Name; declaration, filled by the implementer; a generic parameter carries the same information inside the trait’s name.
  • Both notations fall into the every-time class and give exactly the same run count (three and three); the difference is not at run time, it is in the information the caller has to write in the signature.
  • An associated type carries a constraint: a type can implement a trait with its associated type only once.
  • A generic parameter does not carry this constraint: the same type can implement the same trait more than once with different parameter values — the cost is that the caller has to state explicitly which implementation is meant.

Next Step

These two lessons showed that the run count does not change whether the same body is written with a bound or with an associated type — both are resolved at compile time. The next lesson turns to the question of when the call is resolved: is the same contract used with a call resolved at compile time, or one resolved at run time, and does this pair change the run count?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close