Skip to content
AgileLogics
8 min read

Advanced Traits

Prerequisites: Level 2 — Intermediate

Level 2 used traits to share behaviour and bound generics. This lesson covers the parts of the trait system you meet once you design libraries: associated types, trait objects and their limits, supertraits, operator overloading, blanket implementations and the orphan rule.

Associated types

An associated type is a type that each implementation chooses once. Iterator is the classic example: type Item; is fixed per iterator.

trait Container {
    type Item;

    fn get(&self, index: usize) -> Option<&Self::Item>;
}

struct Names(Vec<String>);

impl Container for Names {
    type Item = String;

    fn get(&self, index: usize) -> Option<&String> {
        self.0.get(index)
    }
}

Use an associated type when there is exactly one sensible choice per implementing type. Use a generic parameter (trait From<T>) when a type should implement the trait several times with different types.

Operator overloading

Operators are traits in std::ops. a + b calls Add::add(a, b), and the right-hand side is a generic parameter (defaulting to Self), so you can implement Mul<f64> alongside Mul<Vec2>:

use std::ops::{Add, Mul};

#[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 Mul<f64> for Vec2 {
    type Output = Vec2;
    fn mul(self, k: f64) -> Vec2 {
        Vec2 { x: self.x * k, y: self.y * k }
    }
}

fn main() {
    let a = Vec2 { x: 1.0, y: 2.0 };
    let b = Vec2 { x: 0.5, y: -1.0 };
    println!("{:?}", a + b);
    println!("{:?}", a * 3.0);
    println!("{}", a + b == Vec2 { x: 1.5, y: 1.0 });
}

Expected output:

Vec2 { x: 1.5, y: 1.0 }
Vec2 { x: 3.0, y: 6.0 }
true

Comparison operators come from PartialEq/PartialOrd, indexing from Index/IndexMut, and += from AddAssign. Overload an operator only when its meaning is obvious for your type.

Generics vs trait objects

There are two ways to accept "anything that implements Shape":

trait Shape {
    fn area(&self) -> f64;
}

struct Square(f64);
struct Circle(f64);

impl Shape for Square {
    fn area(&self) -> f64 { self.0 * self.0 }
}
impl Shape for Circle {
    fn area(&self) -> f64 { std::f64::consts::PI * self.0 * self.0 }
}

// Static dispatch: one copy compiled per T, all elements the same type.
fn total_generic<T: Shape>(shapes: &[T]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

// Dynamic dispatch: one copy, calls go through a vtable, types can mix.
fn total_dyn(shapes: &[Box<dyn Shape>]) -> f64 {
    shapes.iter().map(|s| s.area()).sum()
}

fn main() {
    let squares = [Square(1.0), Square(2.0)];
    let mixed: Vec<Box<dyn Shape>> = vec![Box::new(Square(2.0)), Box::new(Circle(1.0))];
    println!("{:.2}", total_generic(&squares));
    println!("{:.2}", total_dyn(&mixed));
}

Expected output:

5.00
7.14

Generics are usually faster (calls can be inlined) but grow the binary. dyn Trait is the tool for heterogeneous collections and plugin-style APIs.

Dyn compatibility

Not every trait can become dyn Trait. A trait is dyn compatible (formerly "object safe") only if the compiler can build a vtable for it. Roughly, its methods must not:

  • return Self or take Self by value in a way that needs its size,
  • have their own generic type parameters,
  • be associated functions without a self receiver.

A method you want to exclude from the vtable can opt out with where Self: Sized; it stays callable on concrete types.

Supertraits and blanket impls

A supertrait says "implementing this requires that too". A blanket impl implements a trait for every type satisfying a bound — std uses one to give every Display type a to_string().

use std::fmt::Display;

trait Labelled: Display {
    fn label(&self) -> String {
        format!("[{self}]") // allowed because Self: Display
    }
}

// Every Display type is now Labelled.
impl<T: Display> Labelled for T {}

fn main() {
    println!("{} {}", 42.label(), "ok".label());
}

Expected output:

[42] [ok]

The orphan rule and the newtype pattern

You may implement a trait for a type only if the trait or the type is defined in your crate. This keeps two crates from supplying conflicting impls. To implement a foreign trait for a foreign type, wrap it:

use std::fmt;

struct Csv(Vec<String>);

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

Csv(vec!["id".into(), "name".into()]) now displays as id,name. The wrapper costs nothing at runtime. Add Deref or explicit forwarding methods only if callers genuinely need the inner API.

Common mistakes

  • Implementing a foreign trait on a foreign type directly:
impl std::fmt::Display for Vec<String> { /* ... */ }
error[E0117]: only traits defined in the current crate can be implemented for types defined outside of the crate
  • Making a trait with fn duplicate(&self) -> Self; and then using Box<dyn ThatTrait>. The compiler reports error[E0038] because the trait is not dyn compatible. Add where Self: Sized to that method, or return Box<dyn ThatTrait> instead.
  • Reaching for dyn Trait by default. If every element has the same type, a generic is simpler and faster.

Practice

Define a Money newtype around i64 cents. Implement Add (returning Money), Display (formatting as $12.34), and std::iter::Sum so iter().copied().sum::<Money>() works. Print the total of [1250, 99, 1].

Show solution
use std::fmt;
use std::iter::Sum;
use std::ops::Add;

#[derive(Debug, Clone, Copy, PartialEq)]
struct Money(i64);

impl Add for Money {
    type Output = Money;
    fn add(self, rhs: Money) -> Money {
        Money(self.0 + rhs.0)
    }
}

impl Sum for Money {
    fn sum<I: Iterator<Item = Money>>(iter: I) -> Money {
        iter.fold(Money(0), |acc, m| acc + m)
    }
}

impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "${}.{:02}", self.0 / 100, self.0 % 100)
    }
}

fn main() {
    let items = [Money(1250), Money(99), Money(1)];
    let total: Money = items.iter().copied().sum();
    println!("{total}");
}

This prints $13.50. (Negative amounts would need extra handling in Display.)