EthicsComputerCourses
Join training +91 90532 10052

Advanced Traits: Associated Types & Trait Objects

Rust · Lesson 23 of 43 Intermediate 8 min read Updated

In Traits and Generics & Trait Bounds you defined traits, implemented them and used them as bounds. Real libraries use a few more trait features again and again: associated types and constants, traits that require other traits, implementations that cover many types at once, and conversion traits such as From and TryFrom. You also need to choose, many times a week, between generics and trait objects.

This lesson covers those tools with small, practical examples. When you finish, the signatures you see on docs.rs, such as impl<T: Display + ?Sized> ToString for T, will make sense.

Associated types or generic parameters?

An associated type is a type placeholder inside a trait, written type Name;. Each implementation picks the concrete type once. You have already met type Item in Iterators and type Output on Add.

Rust
trait Sensor {
    type Reading;
    fn read(&self) -> Self::Reading;
}

struct Thermometer {
    celsius: f32,
}

struct RainGauge {
    mm_per_day: Vec<u32>,
}

impl Sensor for Thermometer {
    type Reading = f32;
    fn read(&self) -> f32 {
        self.celsius
    }
}

impl Sensor for RainGauge {
    type Reading = u32;
    fn read(&self) -> u32 {
        let mut total = 0;
        for mm in &self.mm_per_day {
            total += mm;
        }
        total
    }
}

fn main() {
    let pune = Thermometer { celsius: 31.5 };
    let kochi = RainGauge { mm_per_day: vec![12, 40, 7] };
    println!("Pune: {} C", pune.read());
    println!("Kochi rain this week: {} mm", kochi.read());
}
Output
Pune: 31.5 C
Kochi rain this week: 59 mm

Why not write trait Sensor<R> with a generic parameter instead? The difference is how many implementations a type may have:

  • With an associated type, a type implements the trait once. A thermometer has exactly one kind of reading, so callers never need to say which one they mean.
  • With a generic parameter, a type can implement the trait many times, once per parameter. String implements From<&str>, From<char>, From<Box<str>> and more, because a string can be built from many things.

Rule of thumb: if there is one natural answer per type, use an associated type; if several make sense, use a generic parameter.

Associated constants and supertraits

A trait can also declare associated constants, values each implementation must provide. And a trait can require another trait: trait Account: Display means "every Account must also implement Display". Display is then a supertrait, and default methods can use it.

Rust
use std::fmt;

trait Account: fmt::Display {
    const DAILY_LIMIT: u32;
    fn balance(&self) -> u32;

    fn report(&self, amount: u32) -> String {
        let allowed = amount <= self.balance() && amount <= Self::DAILY_LIMIT;
        let verdict = if allowed { "allowed" } else { "refused" };
        format!("{self}: withdraw Rs. {amount} {verdict}")
    }
}

struct Savings {
    owner: String,
    balance: u32,
}

impl fmt::Display for Savings {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "Savings ({})", self.owner)
    }
}

impl Account for Savings {
    const DAILY_LIMIT: u32 = 25_000;
    fn balance(&self) -> u32 {
        self.balance
    }
}

fn main() {
    let isha = Savings { owner: String::from("Isha"), balance: 40_000 };
    println!("{}", isha.report(10_000));
    println!("{}", isha.report(30_000));
    println!("Limit: Rs. {}", Savings::DAILY_LIMIT);
}
Output
Savings (Isha): withdraw Rs. 10000 allowed
Savings (Isha): withdraw Rs. 30000 refused
Limit: Rs. 25000

The report method can write {self} only because the supertrait guarantees Display. If you try to implement Account for a type without Display, the compiler stops you.

Blanket implementations and extension traits

A blanket implementation implements a trait for every type that meets a bound: impl<T: Display> MyTrait for T. The standard library uses this heavily. ToString is implemented for every type that implements Display, which is why implementing Display gives you .to_string() for free. Likewise, implementing From<A> for B automatically gives you Into<B> for A.

The extension trait pattern uses a trait to add methods to a type you do not own. The orphan rule allows it because the trait is yours:

Rust
trait IndianUnits {
    fn in_words(&self) -> String;
}

impl IndianUnits for u64 {
    fn in_words(&self) -> String {
        let n = *self as f64;
        if *self >= 1_00_00_000 {
            format!("{:.2} crore", n / 1e7)
        } else if *self >= 1_00_000 {
            format!("{:.2} lakh", n / 1e5)
        } else {
            self.to_string()
        }
    }
}

fn main() {
    println!("{}", 2_50_000u64.in_words());
    println!("{}", 3_45_00_000u64.in_words());
    println!("{}", 950u64.in_words());
}
Output
2.50 lakh
3.45 crore
950

Underscores in number literals are ignored by the compiler, so you can group digits the Indian way. Any file that writes use crate::IndianUnits; can now call .in_words() on a u64; files that do not import the trait are unaffected. Popular crates use the same idea, for example extension traits that add methods to iterators or futures.

More operator traits: AddAssign and Index

The std::ops module has a trait for every operator. Beyond Add, two you will often implement are AddAssign (for +=) and Index (for value[key]):

Rust
use std::ops::{AddAssign, Index};

#[derive(Debug, Clone, Copy)]
struct Rupees(u32);

impl AddAssign for Rupees {
    fn add_assign(&mut self, other: Rupees) {
        self.0 += other.0;
    }
}

struct Menu {
    items: Vec<(String, Rupees)>,
}

impl Index<&str> for Menu {
    type Output = Rupees;
    fn index(&self, name: &str) -> &Rupees {
        for (item, price) in &self.items {
            if item == name {
                return price;
            }
        }
        panic!("{name} is not on the menu");
    }
}

fn main() {
    let menu = Menu {
        items: vec![
            (String::from("masala dosa"), Rupees(90)),
            (String::from("filter coffee"), Rupees(35)),
        ],
    };
    let mut bill = Rupees(0);
    bill += menu["masala dosa"];
    bill += menu["filter coffee"];
    bill += menu["filter coffee"];
    println!("{bill:?}");
}
Output
Rupees(160)

Index returns a reference, and like indexing a Vec out of range, a missing key panics. Offer a get method returning Option as well, just as Vec and HashMap do. Overload operators only when the meaning is obvious to a reader; a + that sends an email is a puzzle, not a feature.

Conversion traits and Default

Four standard traits handle conversions, and using them makes your types feel built in:

TraitMeaningGives you
From<T>Conversion that cannot failType::from(x) and, for free, x.into()
TryFrom<T>Conversion that can fail, with type ErrorType::try_from(x) and x.try_into()
AsRef<T>Cheap borrow as another typeFunctions that accept &str, String and more
DefaultA sensible starting valueType::default() and ..Default::default()
Rust
#[derive(Debug, Default)]
struct Order {
    city: String,
    qty: u32,
    express: bool,
}

#[derive(Debug)]
struct Pincode(u32);

impl TryFrom<u32> for Pincode {
    type Error = String;
    fn try_from(n: u32) -> Result<Self, Self::Error> {
        if (100_000..=999_999).contains(&n) {
            Ok(Pincode(n))
        } else {
            Err(format!("{n} is not a 6-digit PIN code"))
        }
    }
}

fn shout_city(city: impl AsRef<str>) -> String {
    city.as_ref().to_uppercase()
}

fn main() {
    let order = Order { city: "Lucknow".into(), ..Default::default() };
    println!("{order:?}");

    let pin: Result<Pincode, String> = 226001u32.try_into();
    println!("{pin:?}");
    println!("{:?}", Pincode::try_from(2260u32));

    println!("{}", shout_city("Kochi"));
    println!("{}", shout_city(String::from("Rohtak")));
}
Output
Order { city: "Lucknow", qty: 0, express: false }
Ok(Pincode(226001))
Err("2260 is not a 6-digit PIN code")
KOCHI
ROHTAK

"Lucknow".into() works because String implements From<&str>. ..Default::default() fills the remaining fields with zero, false and empty values. Implement From (never Into directly); the blanket impl gives callers .into() automatically. Implementing From<YourLibError> for an application error is exactly what makes ? convert errors, as you saw in Error Handling with ? & Custom Errors.

Generics or trait objects?

Both let one piece of code work with many types. They are compiled very differently:

QuestionGenerics and impl Traitdyn Trait
When is the method chosen?At compile time (static dispatch)At run time through a table of function pointers (dynamic dispatch)
SpeedCalls can be inlined and optimisedOne extra pointer lookup per call, no inlining
Binary sizeOne copy of the code per concrete typeOne copy shared by all types
Mixed types in one VecNoYes, Vec<Box<dyn Trait>>
Type chosen from a config file at run timeNoYes

Trait objects shine in plugin-style code, where the list of steps is decided at run time:

Rust
trait TextFilter {
    fn name(&self) -> &str;
    fn apply(&self, text: &str) -> String;
}

struct Trim;
struct Uppercase;
struct Mask {
    word: String,
}

impl TextFilter for Trim {
    fn name(&self) -> &str { "trim" }
    fn apply(&self, text: &str) -> String { text.trim().to_string() }
}

impl TextFilter for Uppercase {
    fn name(&self) -> &str { "upper" }
    fn apply(&self, text: &str) -> String { text.to_uppercase() }
}

impl TextFilter for Mask {
    fn name(&self) -> &str { "mask" }
    fn apply(&self, text: &str) -> String { text.replace(&self.word, "****") }
}

fn main() {
    let filters: Vec<Box<dyn TextFilter>> = vec![
        Box::new(Trim),
        Box::new(Mask { word: String::from("password") }),
        Box::new(Uppercase),
    ];
    let mut text = String::from("  my password is secret  ");
    for filter in &filters {
        text = filter.apply(&text);
        println!("after {:<6} -> {text:?}", filter.name());
    }
}
Output
after trim   -> "my password is secret"
after mask   -> "my **** is secret"
after upper  -> "MY **** IS SECRET"

Not every trait can become a dyn type. A trait is dyn compatible (older books say "object safe") only if the compiler can build that function table. The main rules: methods must not have their own generic type parameters, must not return Self, and the trait must not have associated constants. So the Account trait above cannot be used as dyn Account. A method that breaks the rules can be excluded from the table with where Self: Sized, for example fn create() -> Self where Self: Sized;; trait objects then simply cannot call that one method.

Start with generics. Switch to dyn Trait when you need a mixed collection, a type picked at run time, or smaller binaries and faster compiles in a large code base.

Common mistakes

  • Using a generic parameter where an associated type fits. Callers then have to write annotations like Sensor::<f32> everywhere. If there is one answer per type, use type.
  • Implementing Into instead of From. Implement From; Into comes free through the blanket impl.
  • Making a trait accidentally non-dyn-compatible. Adding a generic method or an associated constant breaks every dyn Trait use. Put where Self: Sized on such methods or keep them in a separate trait.
  • Expecting extension methods without the import. An extension trait's methods exist only where the trait is in scope.
  • Surprising operator overloads. +, [] and friends should behave as readers expect.

Key points

  • Associated types (type Item;) fix one type per implementation; generic parameters (From<T>) allow many implementations per type.
  • Associated constants give each implementation its own value; supertraits (trait A: Display) let default methods rely on other traits.
  • Blanket impls (impl<T: Display> X for T) cover whole families of types; extension traits add methods to types you do not own.
  • AddAssign, Index and the other std::ops traits power operators; From, TryFrom, AsRef and Default make conversions feel natural.
  • Generics give static dispatch and speed; dyn Trait gives run-time choice and mixed collections, but only for dyn-compatible traits.
  • Since Rust 1.86, &dyn Sub can be upcast to &dyn Super.

Exercise

Create a trait Notifier with one method, send(&self, msg: &str) -> String. Implement it for Sms { phone: String } and Email { address: String }, put one of each in a Vec<Box<dyn Notifier>>, and send "Your train is on platform 4" through both. Then implement TryFrom<&str> for Sms so that only 10-digit phone numbers are accepted (error type String), and print the result of converting "9876543210" and "12345".

Show solution
Rust
trait Notifier {
    fn send(&self, msg: &str) -> String;
}

#[derive(Debug)]
struct Sms {
    phone: String,
}

struct Email {
    address: String,
}

impl Notifier for Sms {
    fn send(&self, msg: &str) -> String {
        format!("SMS to {}: {msg}", self.phone)
    }
}

impl Notifier for Email {
    fn send(&self, msg: &str) -> String {
        format!("Email to {}: {msg}", self.address)
    }
}

impl TryFrom<&str> for Sms {
    type Error = String;
    fn try_from(phone: &str) -> Result<Self, Self::Error> {
        let mut digits = 0;
        for c in phone.chars() {
            if !c.is_ascii_digit() {
                return Err(format!("{phone} contains a non-digit"));
            }
            digits += 1;
        }
        if digits != 10 {
            return Err(format!("{phone} is not a 10-digit phone number"));
        }
        Ok(Sms { phone: phone.to_string() })
    }
}

fn main() {
    let channels: Vec<Box<dyn Notifier>> = vec![
        Box::new(Sms { phone: String::from("9876543210") }),
        Box::new(Email { address: String::from("meera@example.com") }),
    ];
    for channel in &channels {
        println!("{}", channel.send("Your train is on platform 4"));
    }

    println!("{:?}", Sms::try_from("9876543210"));
    println!("{:?}", Sms::try_from("12345"));
}
Output
SMS to 9876543210: Your train is on platform 4
Email to meera@example.com: Your train is on platform 4
Ok(Sms { phone: "9876543210" })
Err("12345 is not a 10-digit phone number")

Notifier has no generic methods and never returns Self, so it is dyn compatible and both types fit in one vector. TryFrom is the right trait because the conversion can fail, and it gives callers "...".try_into() for free.

Quick Quiz

1. When is an associated type a better choice than a generic type parameter on a trait?

2. What does trait Account: Display mean?

3. Which method stops a trait from being used as dyn Trait?

4. You implemented From<Celsius> for Fahrenheit. What else do you get?

Learn Rust with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call