Advanced Traits
Traits are a core feature of Oxide, enabling abstraction and code reuse. In this chapter, we'll explore advanced trait techniques that let you write flexible, powerful code.
Associated Types
Associated types let you define placeholder types inside a trait that concrete types will specify:
trait Iterator {
type Item
mutating fn next(): Item?
}
Here, Item is an associated type. When you implement Iterator, you specify what type Item is:
struct CountUp {
current: Int,
max: Int,
}
extension CountUp: Iterator {
type Item = Int
mutating fn next(): Int? {
if current < max {
current += 1
return Some(current)
}
null
}
}
fn main() {
var counter = CountUp { current: 0, max: 3 }
println!("\(counter.next():?)") // Some(1)
println!("\(counter.next():?)") // Some(2)
println!("\(counter.next():?)") // Some(3)
println!("\(counter.next():?)") // null
}
Why Associated Types Matter
Associated types are more flexible than generic type parameters. Compare these approaches:
// Using generics (less flexible)
trait IteratorGeneric<Item> {
mutating fn next(): Item?
}
// Using associated types (more flexible)
trait Iterator {
type Item
mutating fn next(): Item?
}
With generics, one type could implement IteratorGeneric<Int> and IteratorGeneric<String>. But with associated types, each implementation must choose exactly one Item type. This prevents ambiguity and is usually what you want.
Associated Types in Generic Code
You can use associated types in generic bounds:
fn processIterator<I>(mut iter: I)
where
I: Iterator,
{
while let Some(item) = iter.next() {
println!("Processing: \(item)")
}
}
fn main() {
let counter = CountUp { current: 0, max: 3 }
processIterator(counter)
}
Default Generic Type Parameters
You can specify default types for generic parameters:
trait Add<Rhs = Self> {
type Output
consuming fn add(rhs: Rhs): Output
}
Here, Rhs defaults to Self. This means you can write:
extension Int: Add {
type Output = Int
consuming fn add(rhs: Int): Int {
// ...
}
}
extension Int: Add<String> {
type Output = String
consuming fn add(rhs: String): String {
// ...
}
}
Default generic type parameters enable:
- Backward compatibility - Adding generic parameters without breaking existing code
- Operator overloading - Different types can use operators in different ways
- Convenience - Sensible defaults reduce boilerplate
Trait Objects
Sometimes you want to store different types that implement the same trait. You can use trait objects:
trait Animal {
fn speak()
}
struct Dog;
struct Cat;
extension Dog: Animal {
fn speak() {
println!("Woof!")
}
}
extension Cat: Animal {
fn speak() {
println!("Meow!")
}
}
fn main() {
// Create a vector of trait objects
let animals: Vec<Box<dyn Animal>> = vec![
Box.new(Dog),
Box.new(Cat),
Box.new(Dog),
]
for animal in animals {
animal.speak()
}
// Output:
// Woof!
// Meow!
// Woof!
}
Trait Objects and Dynamic Dispatch
Trait objects enable dynamic dispatch: the method to call is determined at runtime:
trait Shape {
fn area(): Float
}
struct Circle {
radius: Float,
}
struct Rectangle {
width: Float,
height: Float,
}
extension Circle: Shape {
fn area(): Float {
3.14159 * radius * radius
}
}
extension Rectangle: Shape {
fn area(): Float {
width * height
}
}
fn printAreas(shapes: Vec<Box<dyn Shape>>) {
for shape in shapes {
println!("Area: \(shape.area())")
}
}
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
Box.new(Circle { radius: 2.0 }),
Box.new(Rectangle { width: 3.0, height: 4.0 }),
]
printAreas(shapes)
}
Trait Object Syntax
The syntax &dyn TraitName creates a trait object reference. Key rules:
// Trait objects must use reference or Box
let obj: &dyn Animal = &dog // OK: reference
let obj: Box<dyn Animal> = Box.new(dog) // OK: Box
// let obj: dyn Animal = dog // Error: cannot have unboxed trait objects
// Multiple trait bounds
let obj: &(dyn Animal + Debug) = &dog // OK: requires Animal and Debug
Limitations of Trait Objects
Trait objects have limitations compared to generics:
- Object safety - The trait must be "object safe"
- Performance - Dynamic dispatch is slower than static dispatch
- Size - You can't know the size of the concrete type at compile time
A trait is object safe if:
- All its methods return
Selfor don't referenceSelf - It has no static methods
- All methods don't have generic type parameters
trait ObjectSafe {
fn method()
fn returnsString(): String
}
trait NotObjectSafe {
fn returnsSelf(): Self // Error: returns Self
fn generic<T>(t: T) // Error: has generic parameter
}
// Can't create trait objects of NotObjectSafe
// let obj: Box<dyn NotObjectSafe> = Box.new(something) // Error!
Blanket Implementations
You can implement a trait for any type that implements another trait:
trait MyTrait {
fn doSomething()
}
trait AnotherTrait {}
// Blanket implementation: implement MyTrait for ANY type that implements AnotherTrait
extension<T> T: MyTrait
where
T: AnotherTrait,
{
fn doSomething() {
println!("Doing something!")
}
}
struct MyType;
extension MyType: AnotherTrait {}
fn main() {
let obj = MyType
obj.doSomething() // Works because MyType implements AnotherTrait
}
Real-World Example: ToString
The standard library uses blanket implementations effectively:
// Simplified version of what's in std:
trait Display {
fn fmt(f: &mut Formatter): Result
}
trait ToString {
fn toString(): String
}
// Blanket implementation
extension<T> T: ToString
where
T: Display,
{
fn toString(): String {
format!("\(self)")
}
}
Now any type that implements Display automatically gets toString():
extension Int: Display {
fn fmt(f: &mut Formatter): Result {
// implementation
}
}
fn main() {
let n = 42
println!("\(n.toString())") // Works!
}
Supertraits
A trait can require that implementors also implement another trait:
trait OutlineDisplay: Display {
fn outlinePrint() {
println!("*** \(toString()) ***")
}
}
struct Point {
x: Int,
y: Int,
}
extension Point: Display {
fn fmt(f: &mut Formatter): Result {
println!("(\(x), \(y))")
}
}
extension Point: OutlineDisplay {}
fn main() {
let p = Point { x: 5, y: 10 }
p.outlinePrint() // Prints: *** (5, 10) ***
}
The syntax trait OutlineDisplay: Display means:
- "To implement
OutlineDisplay, you must also implementDisplay" - Inside methods of
OutlineDisplay, you can call methods fromDisplay
Associated Type Bounds
You can constrain associated types with trait bounds:
trait Container {
type Item
fn capacity(): Int
}
fn printItems<C>(container: C)
where
C: Container,
C.Item: Display,
{
println!("Capacity: \(container.capacity())")
for item in container {
println!("Item: \(item)")
}
}
The constraint C.Item: Display means "the associated Item type must implement Display".
Implementing Trait Methods with Defaults
Trait methods can have default implementations:
trait Animal {
fn speak()
fn sleep() {
println!("Zzz...")
}
fn eat() {
println!("Nom nom!")
}
}
struct Dog;
extension Dog: Animal {
fn speak() {
println!("Woof!")
}
// Can use default implementations for sleep and eat
}
fn main() {
let dog = Dog
dog.speak() // Prints: Woof!
dog.sleep() // Prints: Zzz...
dog.eat() // Prints: Nom nom!
}
You can override defaults when needed:
struct Cat;
extension Cat: Animal {
fn speak() {
println!("Meow!")
}
fn sleep() {
println!("Cat naps for 16 hours...")
}
}
Advanced Generic Bounds
Combine multiple traits with +:
fn process<T>(item: T)
where
T: Clone + Display + Debug,
{
let cloned = item.clone()
println!("Original: \(item)")
println!("Cloned: \(cloned:?)")
}
Use lifetime bounds with traits:
trait Produces<'a> {
type Output: 'a
}
extension<'a> SomeType: Produces<'a> {
type Output = &'a str
}
Higher-ranked trait bounds:
// For all lifetimes 'a, T must implement Fn(&'a str) -> UInt
fn takesClosure<F>(f: F)
where
F: for<'a> Fn(&'a str) -> UInt,
{
// ...
}
Example: Building a Plugin System
Let's combine these concepts into a real-world plugin system:
trait Plugin: Send + Sync {
fn name(): &str
fn version(): &str
fn execute(input: String): String?
}
struct PluginManager {
plugins: Vec<Box<dyn Plugin>>,
}
extension PluginManager {
static fn new(): Self {
PluginManager { plugins: vec![] }
}
mutating fn register<P: Plugin + 'static>(plugin: P) {
self.plugins.push(Box.new(plugin))
}
fn executeAll(input: String): Vec<String> {
self.plugins
.iter()
.filterMap { plugin ->
plugin.execute(input.clone())
}
.collect()
}
}
struct UppercasePlugin;
extension UppercasePlugin: Plugin {
fn name(): &str {
"Uppercase"
}
fn version(): &str {
"1.0"
}
fn execute(input: String): String? {
Some(input.toUppercase())
}
}
fn main() {
var manager = PluginManager.new()
manager.register(UppercasePlugin)
let results = manager.executeAll("hello".toString())
for result in results {
println!("\(result)") // Prints: HELLO
}
}
Summary
Advanced traits enable:
- Associated types - Flexible placeholder types in traits
- Default generic parameters - Sensible defaults and backward compatibility
- Trait objects - Storing different types implementing the same trait
- Blanket implementations - Implement traits for broad categories of types
- Supertraits - Require implementations of multiple traits
- Complex bounds - Fine-grained control over generic constraints
Understanding these patterns will help you write more flexible, reusable Oxide code and better understand the standard library.