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:

  1. Backward compatibility - Adding generic parameters without breaking existing code
  2. Operator overloading - Different types can use operators in different ways
  3. 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:

  1. Object safety - The trait must be "object safe"
  2. Performance - Dynamic dispatch is slower than static dispatch
  3. Size - You can't know the size of the concrete type at compile time

A trait is object safe if:

  • All its methods return Self or don't reference Self
  • 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 implement Display"
  • Inside methods of OutlineDisplay, you can call methods from Display

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.