Lesson 17 of 22ยท16 min

Advanced Traits

The traits lesson covered the everyday parts of traits: defining them, implementing them, default methods and derives. The generics and trait objects lessons used them as bounds and as dyn Trait. This lesson covers the features you meet as soon as you read the standard library's own traits: associated types (the Item in Iterator<Item = u32>), supertraits (a trait that requires another one), operator overloading (what + actually calls), default type parameters, and fully qualified syntax for when two traits give a type methods with the same name.

Associated types

Here is a simplified version of the Iterator trait from the standard library:

trait Iterator {
    type Item;
 
    fn next(&mut self) -> Option<Self::Item>;
}

type Item; declares an associated type: a placeholder type that every implementation fills in. Inside the trait it is written Self::Item, "the Item type of whichever type implements this". An iterator over u32s sets it to u32, an iterator over lines sets it to String, and next returns Option<u32> or Option<String> accordingly.

Implementing Iterator for your own type means picking the Item and writing next:

struct Countdown {
    remaining: u32,
}
 
impl Iterator for Countdown {
    type Item = u32;
 
    fn next(&mut self) -> Option<Self::Item> {
        if self.remaining == 0 {
            return None;
        }
        let current = self.remaining;
        self.remaining -= 1;
        Some(current)
    }
}
 
fn main() {
    for n in (Countdown { remaining: 3 }) {
        println!("{n}...");
    }
    println!("liftoff");
 
    let total: u32 = Countdown { remaining: 4 }.sum();
    let all: Vec<u32> = Countdown { remaining: 5 }.collect();
    println!("{total} {all:?}");
}
3...
2...
1...
liftoff
10 [5, 4, 3, 2, 1]

Writing next is the only work. for loops, sum, collect and dozens of other methods are default methods of Iterator, and they all work with Self::Item, so they come for free. The closures and iterators lesson goes through those methods.

Your own trait with an associated type

Associated types are useful whenever a trait's methods deal with a type that depends on the implementation. A sensor returns a reading, but a thermometer's reading is a temperature and a door switch's reading is open or closed:

use std::fmt::Debug;
 
trait Sensor {
    type Reading;
 
    fn name(&self) -> String;
    fn read(&mut self) -> Self::Reading;
}
 
struct Thermometer {
    celsius: f64,
}
 
struct DoorSwitch {
    open: bool,
}
 
impl Sensor for Thermometer {
    type Reading = f64;
 
    fn name(&self) -> String {
        String::from("thermometer")
    }
 
    fn read(&mut self) -> f64 {
        self.celsius += 0.5;
        self.celsius
    }
}
 
impl Sensor for DoorSwitch {
    type Reading = bool;
 
    fn name(&self) -> String {
        String::from("front door")
    }
 
    fn read(&mut self) -> bool {
        self.open = !self.open;
        self.open
    }
}
 
fn poll<S: Sensor>(sensor: &mut S) -> S::Reading
where
    S::Reading: Debug,
{
    let reading = sensor.read();
    println!("{}: {reading:?}", sensor.name());
    reading
}
 
fn main() {
    let mut thermometer = Thermometer { celsius: 20.0 };
    let mut door = DoorSwitch { open: false };
 
    let temperature = poll(&mut thermometer);
    poll(&mut door);
    poll(&mut door);
 
    println!("above 20: {}", temperature > 20.0);
}
thermometer: 20.5
front door: true
front door: false
above 20: true

Two details are worth noticing. In an impl, read can return f64 directly instead of Self::Reading, since they are the same type there. And in the generic poll, S::Reading names "the reading type of S", so the function can return it and put a bound on it (S::Reading: Debug) without knowing what it is. Because poll returns S::Reading, temperature is an f64 and can be compared with 20.0.

Constraining an associated type

In a bound, Trait<Name = Type> requires the associated type to be one specific type. This is how you write "any iterator of f64s":

fn average(values: impl Iterator<Item = f64>) -> f64 {
    let mut sum = 0.0;
    let mut count = 0;
    for value in values {
        sum += value;
        count += 1;
    }
    sum / count as f64
}
 
fn main() {
    let readings = vec![19.5, 21.0, 22.5];
    println!("{}", average(readings.into_iter()));
    println!("{}", average([1.0, 2.0].into_iter()));
}
21
1.5

Associated types or generic parameters?

A trait could take the type as a generic parameter instead, as in trait Sensor<R>. The difference is how many implementations one type can have:

  • With a generic parameter, a type can implement the trait many times, once per R. From<T> works like this: String implements From<&str>, From<char>, From<Box<str>> and more.
  • With an associated type, a type implements the trait once and the associated type is fixed by that implementation. Countdown yields u32s and nothing else, so Iterator uses an associated type.

The associated type also keeps signatures short: you write S: Sensor, not S: Sensor<R> plus an extra R parameter on every function that uses it. A good rule: if there should be exactly one answer per implementing type, use an associated type.

Supertraits

Sometimes a trait only makes sense for types that also implement another trait. A trait for alerts that prints itself needs the alert to be displayable. Writing trait Alert: fmt::Display makes Display a supertrait of Alert: every type that implements Alert must implement Display too, and in return Alert's methods can use Display:

use std::fmt;
 
trait Alert: fmt::Display {
    fn level(&self) -> u8;
 
    fn announce(&self) {
        let marker = "!".repeat(self.level() as usize);
        println!("{marker} {self}");
    }
}
 
struct DiskFull {
    percent: u8,
}
 
struct HostDown {
    host: String,
}
 
impl fmt::Display for DiskFull {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "disk is {}% full", self.percent)
    }
}
 
impl Alert for DiskFull {
    fn level(&self) -> u8 {
        if self.percent > 95 { 3 } else { 1 }
    }
}
 
impl fmt::Display for HostDown {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{} is not responding", self.host)
    }
}
 
impl Alert for HostDown {
    fn level(&self) -> u8 {
        2
    }
}
 
fn worst(alerts: &[&dyn Alert]) -> String {
    let mut worst = alerts[0];
    for alert in alerts {
        if alert.level() > worst.level() {
            worst = *alert;
        }
    }
    worst.to_string()
}
 
fn main() {
    let disk = DiskFull { percent: 97 };
    let host = HostDown {
        host: String::from("db-2"),
    };
 
    disk.announce();
    host.announce();
    println!("worst: {}", worst(&[&host, &disk]));
}
!!! disk is 97% full
!! db-2 is not responding
worst: disk is 97% full

The default method announce formats self with {self}, which needs Display. That compiles only because of the supertrait: the compiler knows that whatever type implements Alert is also Display. The same goes for worst: it only asks for &dyn Alert, yet it can call to_string(), which comes from Display.

A supertrait is a requirement, not inheritance. DiskFull implements Display and Alert in two separate impl blocks, and Alert doesn't inherit or override anything from Display. A trait can have several supertraits, written with +: trait Record: fmt::Debug + Clone + PartialEq.

You've already seen supertraits in the standard library: Copy requires Clone, Eq requires PartialEq, and Ord requires Eq and PartialOrd. That is why the derivable traits table in the traits lesson says Copy needs Clone.

Operator overloading

Operators in Rust are calls to trait methods. a + b is std::ops::Add::add(a, b), a == b is PartialEq::eq(&a, &b), and so on. Implement the trait and the operator works for your type. Here is the Add trait:

trait Add<Rhs = Self> {
    type Output;
 
    fn add(self, rhs: Rhs) -> Self::Output;
}

It has a generic parameter Rhs, the type on the right of the +, and an associated type Output, the type of the result. A 2D vector type can add two vectors, subtract them and negate one:

use std::ops::{Add, Neg, Sub};
 
#[derive(Debug, Clone, Copy, PartialEq)]
struct Vec2 {
    x: f64,
    y: f64,
}
 
impl Add for Vec2 {
    type Output = Vec2;
 
    fn add(self, rhs: Vec2) -> Vec2 {
        Vec2 {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}
 
impl Sub for Vec2 {
    type Output = Vec2;
 
    fn sub(self, rhs: Vec2) -> Vec2 {
        Vec2 {
            x: self.x - rhs.x,
            y: self.y - rhs.y,
        }
    }
}
 
impl Neg for Vec2 {
    type Output = Vec2;
 
    fn neg(self) -> Vec2 {
        Vec2 {
            x: -self.x,
            y: -self.y,
        }
    }
}
 
fn main() {
    let position = Vec2 { x: 1.0, y: 2.0 };
    let velocity = Vec2 { x: 0.5, y: -1.0 };
 
    let next = position + velocity;
    println!("{next:?}");
    println!("{:?}", next - position);
    println!("{:?}", -velocity);
    println!("{}", next - velocity == position);
}
Vec2 { x: 1.5, y: 1.0 }
Vec2 { x: 0.5, y: -1.0 }
Vec2 { x: -0.5, y: 1.0 }
true

impl Add for Vec2 doesn't mention Rhs, so it gets the default, Self: this implementation adds a Vec2 to a Vec2. == works because of the derived PartialEq, and position can be used again after position + velocity because Vec2 is Copy. More on that in the mistakes below.

Default type parameters

Rhs = Self in trait Add<Rhs = Self> is a default type parameter: if an implementation doesn't say what Rhs is, it is Self. You can override it to put a different type on the right of the operator. Scaling a vector by a number is Vec2 * f64, so it implements Mul<f64>:

use std::ops::{AddAssign, Mul};
 
#[derive(Debug, Clone, Copy)]
struct Vec2 {
    x: f64,
    y: f64,
}
 
impl Mul<f64> for Vec2 {
    type Output = Vec2;
 
    fn mul(self, factor: f64) -> Vec2 {
        Vec2 {
            x: self.x * factor,
            y: self.y * factor,
        }
    }
}
 
impl AddAssign for Vec2 {
    fn add_assign(&mut self, rhs: Vec2) {
        self.x += rhs.x;
        self.y += rhs.y;
    }
}
 
fn main() {
    let velocity = Vec2 { x: 2.0, y: 0.5 };
    let mut position = Vec2 { x: 0.0, y: 0.0 };
 
    for _ in 0..3 {
        position += velocity * 0.5;
    }
    println!("{position:?}");
}
Vec2 { x: 3.0, y: 0.75 }

Mul<f64> for Vec2 covers vec * 2.0 only. The operands are not swapped for you: 2.0 * vec would need impl Mul<Vec2> for f64. Each combination of left type, right type and operator is its own implementation, and Output doesn't have to match either side.

AddAssign is the trait behind +=. Its method takes &mut self and returns nothing, because it changes the left side in place instead of producing a new value.

Which operators you can overload

Every overloadable operator has a trait in std::ops, or in std::cmp for comparisons:

OperatorTraitMethod
a + bAddadd(self, rhs)
a - bSubsub(self, rhs)
a * bMulmul(self, rhs)
a / bDivdiv(self, rhs)
a % bRemrem(self, rhs)
-aNegneg(self)
!aNotnot(self)
a += bAddAssignadd_assign(&mut self, rhs)
a[i]Index, IndexMutindex(&self, index)
a == b, a != bPartialEq (std::cmp)eq(&self, other)
a < b, a >= bPartialOrd (std::cmp)partial_cmp(&self, other)

The other compound assignments follow the same pattern (SubAssign for -=, MulAssign for *=, and so on). Operators like &&, ||, = and . can't be overloaded. Only overload an operator when its meaning is obvious for the type: adding two vectors is, "adding" two users probably isn't.

Calling a method by its full name

Two traits may each give a type a method with the same name. Nothing stops a type from implementing both:

trait Network {
    fn status(&self) -> String;
    fn kind() -> String;
}
 
trait Hardware {
    fn status(&self) -> String;
    fn kind() -> String;
}
 
struct Printer {
    ip: String,
    paper: u32,
}
 
impl Network for Printer {
    fn status(&self) -> String {
        format!("online at {}", self.ip)
    }
 
    fn kind() -> String {
        String::from("network device")
    }
}
 
impl Hardware for Printer {
    fn status(&self) -> String {
        format!("{} sheets left", self.paper)
    }
 
    fn kind() -> String {
        String::from("peripheral")
    }
}
 
fn main() {
    let printer = Printer {
        ip: String::from("10.0.0.7"),
        paper: 42,
    };
 
    println!("{}", Network::status(&printer));
    println!("{}", Hardware::status(&printer));
    println!("{}", <Printer as Network>::kind());
    println!("{}", <Printer as Hardware>::kind());
}
online at 10.0.0.7
42 sheets left
network device
peripheral

printer.status() would be ambiguous, so the calls name the trait: Network::status(&printer) calls the method as a plain function and passes the receiver explicitly. That works for methods, since the type of the argument tells the compiler which implementation to use.

kind has no self, so Network::kind() alone gives the compiler nothing to go on. The fully qualified syntax <Type as Trait>::function() names both the type and the trait. You rarely need it, but it is the answer whenever the compiler says it can't tell which implementation you mean. If a type has an inherent method (one in a plain impl Printer block) with the same name as a trait method, printer.status() picks the inherent one.

Common mistakes

Using an operator the type doesn't implement

#[derive(Debug)]
struct Money {
    cents: u64,
}
 
fn main() {
    let price = Money { cents: 1250 };
    let tip = Money { cents: 200 };
    println!("{:?}", price + tip);
}

Deriving Debug or PartialEq doesn't give a type arithmetic. + needs an impl Add for Money, and the compiler's note points you at it. Add can't be derived, since the compiler can't guess what adding two values of your type means.

Leaving out the associated type

use std::ops::Add;
 
struct Money {
    cents: u64,
}
 
impl Add for Money {
    fn add(self, rhs: Money) -> Money {
        Money {
            cents: self.cents + rhs.cents,
        }
    }
}
 
fn main() {
    let total = Money { cents: 1250 } + Money { cents: 200 };
    println!("{}", total.cents);
}

An associated type is a required item of the trait, just like a required method. Add type Output = Money; to the impl block.

Using a value after an operator moved it

use std::ops::Add;
 
#[derive(Debug)]
struct Money {
    cents: u64,
}
 
impl Add for Money {
    type Output = Money;
 
    fn add(self, rhs: Money) -> Money {
        Money {
            cents: self.cents + rhs.cents,
        }
    }
}
 
fn main() {
    let price = Money { cents: 1250 };
    let tip = Money { cents: 200 };
    let total = price + tip;
    println!("{total:?} from {price:?}");
}

add takes self by value, so price + tip moves both operands into it, exactly like any other function taking ownership. For a small type made of Copy fields, derive Clone, Copy and the values are copied instead, as with Vec2 above. For a larger type, implement the operator for references too (impl Add<&Money> for &Money) and write &price + &tip.

Forgetting the supertrait

use std::fmt;
 
trait Alert: fmt::Display {
    fn level(&self) -> u8;
}
 
struct HostDown {
    host: String,
}
 
impl Alert for HostDown {
    fn level(&self) -> u8 {
        2
    }
}
 
fn main() {
    let alert = HostDown {
        host: String::from("db-2"),
    };
    println!("{}", alert.host);
}

Implementing Alert promises that the type is also Display, and the compiler checks the promise at the impl. Implementing Alert is not enough on its own: add an impl fmt::Display for HostDown as well.

Calling an ambiguous method

trait Network {
    fn status(&self) -> String;
}
 
trait Hardware {
    fn status(&self) -> String;
}
 
struct Printer;
 
impl Network for Printer {
    fn status(&self) -> String {
        String::from("online")
    }
}
 
impl Hardware for Printer {
    fn status(&self) -> String {
        String::from("jammed")
    }
}
 
fn main() {
    println!("{}", Printer.status());
}

The compiler lists both candidates and suggests the fix: name the trait, as in Network::status(&Printer).

Summary

  • An associated type (type Item;) is a type that each implementation of a trait picks once. Refer to it as Self::Item in the trait and T::Item in generic code, and constrain it with Trait<Item = Type>.
  • Use an associated type when there is one answer per implementing type, and a generic parameter when a type should implement the trait many times.
  • trait A: B makes B a supertrait: implementors of A must implement B as well, and A's methods and users can rely on B's methods.
  • Operators are trait methods from std::ops and std::cmp. Add<Rhs = Self> has a default type parameter for the right-hand side and an associated Output for the result.
  • Binary operators take their operands by value, so non-Copy values are moved.
  • When two traits give a type methods with the same name, call Trait::method(&value), or <Type as Trait>::function() for functions without self.

Practise what you read

Challenges that use the ideas from this lesson.