Traits are at the heart of Rust’s design, enabling polymorphism, code reuse, and abstraction. While most Rust developers are familiar with defining and implementing basic traits, the trait system has several advanced features that unlock even more power and flexibility.
This article explores some of these advanced capabilities, including associated types, supertraits, and the newtype pattern.
1. Associated Types: Connecting Types to Traits
Associated types are a way of connecting a placeholder type with a trait. When a trait has an associated type, any type that implements that trait must specify the concrete type to be used for the placeholder. This is useful when a trait’s methods need to work with a specific type, but that type can vary with each implementation.
The most famous example is the Iterator trait:
pub trait Iterator {
// `Item` is an associated type.
type Item;
// `next` returns an Option containing the associated type.
fn next(&mut self) -> Option<Self::Item>;
}
Here, Item is a placeholder for whatever type the iterator produces. When you implement Iterator for your own type, you must define what Item is.
struct Counter {
count: u32,
}
// We implement Iterator for our Counter.
impl Iterator for Counter {
// We specify that the associated type `Item` is u32.
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
This makes the trait easier to use. Without associated types, Iterator would need a generic parameter (trait Iterator<T>), which can make type signatures more verbose.
2. Default Generic Type Parameters and Operator Overloading
Traits can have generic parameters with default types. This is commonly used for operator overloading. For example, the Add trait allows you to customize the + operator.
use std::ops::Add;
// The `Add` trait has a generic parameter `Rhs` (Right-Hand Side)
// which defaults to `Self`.
// pub trait Add<Rhs = Self> {
// type Output;
// fn add(self, rhs: Rhs) -> Self::Output;
// }
#[derive(Debug, PartialEq)]
struct Point {
x: i32,
y: i32,
}
// We implement `Add` for our `Point` struct.
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}
The Rhs = Self syntax means that if you don’t specify a type for Rhs when implementing the trait, it will default to the type you are implementing it on (Point in this case).
3. Fully Qualified Syntax for Disambiguation
Sometimes, it’s possible for a type to have multiple methods with the same name. This can happen if a type implements two different traits that both have a method with the same name, or if a trait method has the same name as one of the type’s own methods.
To resolve this ambiguity, you can use fully qualified syntax.
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
fn main() {
let person = Human;
// Calls the inherent method on Human.
person.fly();
// To call the trait methods, we need to be explicit.
Pilot::fly(&person);
Wizard::fly(&person);
// We can also use fully qualified syntax.
<Human as Pilot>::fly(&person);
}
4. Supertraits: Requiring One Trait’s Functionality Within Another
You can require that a type implementing your trait must also implement another trait. This is done by specifying a supertrait. This is useful when your trait’s methods need to use the functionality of another trait.
For example, let’s create a trait OutlinePrint that requires the type to also implement Display.
use std::fmt;
// This syntax means any type that implements OutlinePrint
// must also implement fmt::Display.
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("* {} *", output);
println!("{}", "*".repeat(len + 4));
}
}
struct Point {
x: i32,
y: i32,
}
// We must implement Display for Point...
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
// ...before we can implement OutlinePrint.
impl OutlinePrint for Point {}
fn main() {
let p = Point { x: 1, y: 3 };
p.outline_print();
}
5. The Newtype Pattern to Implement External Traits on External Types
Rust has an “orphan rule” that states you can only implement a trait for a type if either the trait or the type is defined in your local crate. This prevents you from, for example, implementing the Display trait (from std) for Vec<T> (also from std).
To get around this, you can use the newtype pattern, which involves creating a new struct that wraps the external type.
use std::fmt;
// Create a wrapper struct around Vec<String>.
struct Wrapper(Vec<String>);
// Now we can implement Display for our local Wrapper type.
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {}", w);
}
This pattern allows you to add new behavior to external types while respecting Rust’s coherence rules.