Using Trait Objects for Dynamic Dispatch

In previous sections, we explored how to use traits as trait bounds on generic types to achieve compile-time polymorphism. This approach requires knowing all concrete types at compile time.

Sometimes, you need to work with multiple types through a common interface where the actual type is only known at runtime. Oxide provides trait objects using the dyn keyword to achieve this dynamic dispatch.

What Are Trait Objects?

A trait object is a dynamically-sized type that represents any type implementing a specific trait. It allows you to store or pass around values of different types as long as they all implement the trait.

The syntax for a trait object is &dyn Trait (for borrowed trait objects) or Box<dyn Trait> (for owned trait objects).

Why Trait Objects?

Consider a scenario where you want to create a collection of different types that all share the same behavior:

public trait Shape {
    fn area(): Float
    fn perimeter(): Float
}

public struct Circle {
    public radius: Float,
}

public struct Rectangle {
    public width: Float,
    public height: Float,
}

extension Circle: Shape {
    fn area(): Float {
        3.14159 * self.radius * self.radius
    }

    fn perimeter(): Float {
        2.0 * 3.14159 * self.radius
    }
}

extension Rectangle: Shape {
    fn area(): Float {
        self.width * self.height
    }

    fn perimeter(): Float {
        2.0 * (self.width + self.height)
    }
}

If you wanted to store both Circle and Rectangle in the same vector, you'd need trait objects:

fn main() {
    // This won't compile because Vec<Shape> doesn't know the size
    // let shapes: Vec<Shape> = vec![Circle { ... }, Rectangle { ... }]

    // This works! Vec<Box<dyn Shape>> is a vector of trait objects
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box { Circle { radius: 5.0 } } as Box<dyn Shape>,
        Box { Rectangle { width: 10.0, height: 20.0 } } as Box<dyn Shape>,
    ]

    var totalArea = 0.0
    for shape in shapes {
        totalArea = totalArea + shape.area()
    }

    println!("Total area: \(totalArea)")
}

Trait Objects vs. Generics

When should you use trait objects instead of generics? Let's compare:

Generics: Compile-Time Polymorphism

// Generic approach - compile time polymorphism
fn printShape<T: Shape>(shape: &T) {
    println!("Area: \(shape.area())")
    println!("Perimeter: \(shape.perimeter())")
}

fn main() {
    let circle = Circle { radius: 5.0 }
    let rect = Rectangle { width: 10.0, height: 20.0 }

    printShape(&circle)  // Type known at compile time
    printShape(&rect)    // Type known at compile time
}

Advantages:

  • Zero runtime overhead
  • Can call trait methods efficiently
  • Compiler knows the concrete type

Disadvantages:

  • Can't store different types in a single collection
  • Monomorphization increases binary size
  • All types must be known at compile time

Trait Objects: Runtime Polymorphism

// Trait object approach - runtime polymorphism
fn printShape(shape: &dyn Shape) {
    println!("Area: \(shape.area())")
    println!("Perimeter: \(shape.perimeter())")
}

fn main() {
    let circle = Circle { radius: 5.0 }
    let rect = Rectangle { width: 10.0, height: 20.0 }

    printShape(&circle)  // Type checked at runtime
    printShape(&rect)    // Type checked at runtime

    // Can also store mixed types
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box { circle },
        Box { rect },
    ]
}

Advantages:

  • Can store different types together
  • More compact binary (no monomorphization)
  • Flexible collection types

Disadvantages:

  • Small runtime overhead for dynamic dispatch
  • Can only call methods from the trait, not type-specific methods
  • Less efficient than generics

Creating and Using Trait Objects

Borrowed Trait Objects (&dyn Trait)

Use &dyn Trait when you want to pass a reference to something implementing the trait:

public trait Draw {
    fn draw()
}

public struct Button {
    public label: String,
}

public struct TextField {
    public placeholder: String,
}

extension Button: Draw {
    fn draw() {
        println!("[Button: \(self.label)]")
    }
}

extension TextField: Draw {
    fn draw() {
        println!("[TextField: \(self.placeholder)]")
    }
}

fn renderUI(component: &dyn Draw) {
    component.draw()
}

fn main() {
    let button = Button { label: "OK".toString() }
    let field = TextField { placeholder: "Enter text".toString() }

    renderUI(&button)  // Passes as &dyn Draw
    renderUI(&field)   // Passes as &dyn Draw
}

Owned Trait Objects (Box<dyn Trait>)

Use Box<dyn Trait> when you need to store trait objects that you own, typically in collections:

public trait Plugin {
    fn getName(): String
    fn execute()
}

public struct AudioPlugin {
    public name: String,
}

public struct VideoPlugin {
    public name: String,
}

extension AudioPlugin: Plugin {
    fn getName(): String {
        self.name
    }

    fn execute() {
        println!("Playing audio...")
    }
}

extension VideoPlugin: Plugin {
    fn getName(): String {
        self.name
    }

    fn execute() {
        println!("Playing video...")
    }
}

fn main() {
    var plugins: Vec<Box<dyn Plugin>> = vec![]

    plugins.append(Box { AudioPlugin { name: "MP3 Player".toString() } } as Box<dyn Plugin>)
    plugins.append(Box { VideoPlugin { name: "MP4 Player".toString() } } as Box<dyn Plugin>)

    for plugin in plugins {
        println!("Plugin: \(plugin.getName())")
        plugin.execute()
    }
}

Object Safety

Not all traits can be used as trait objects. A trait is object-safe if:

  1. The trait doesn't contain any static methods
  2. The trait doesn't require Self to be Sized
  3. For object safety, methods do not return Self or take Self as a parameter (except as the receiver: fn, mutating fn, or consuming fn)

Trait methods can return Self; doing so just means the trait can't be used as a dyn trait object unless those methods are restricted to Self: Sized (and then they aren't callable on the trait object). This is the same rule as Rust.

For example, this trait is NOT object-safe:

public trait Drawable {
    fn draw(): Self  // Returns Self - not object-safe!
}

// This won't compile:
// let obj: Box<dyn Drawable> = Box { ... }

But you can make it object-safe by using a different return type:

public trait Drawable {
    fn draw(): String  // Returns String instead - object-safe!
}

// This works:
let obj: Box<dyn Drawable> = Box { SomeType { ... } }

Practical Example: A Media Player

Here's a realistic example showing trait objects in action:

public trait MediaSource {
    fn load(path: String)
    fn play()
    fn pause()
    fn stop()
    fn getDuration(): Float
}

public struct AudioFile {
    public path: String,
    public duration: Float,
    public isPlaying: Bool,
}

public struct VideoFile {
    public path: String,
    public duration: Float,
    public width: Int,
    public height: Int,
    public isPlaying: Bool,
}

extension AudioFile: MediaSource {
    fn load(path: String) {
        println!("Loading audio: \(path)")
    }

    fn play() {
        println!("Playing audio")
    }

    fn pause() {
        println!("Pausing audio")
    }

    fn stop() {
        println!("Stopping audio")
    }

    fn getDuration(): Float {
        self.duration
    }
}

extension VideoFile: MediaSource {
    fn load(path: String) {
        println!("Loading video: \(path) (\(self.width)x\(self.height))")
    }

    fn play() {
        println!("Playing video")
    }

    fn pause() {
        println!("Pausing video")
    }

    fn stop() {
        println!("Stopping video")
    }

    fn getDuration(): Float {
        self.duration
    }
}

public struct MediaPlayer {
    private currentMedia: Box<dyn MediaSource>?,
    private playlist: Vec<Box<dyn MediaSource>>,
    private currentTrackIndex: Int,
}

extension MediaPlayer {
    static fn create(): Self {
        Self {
            currentMedia: null,
            playlist: vec![],
            currentTrackIndex: 0,
        }
    }

    public fn addToPlaylist(media: Box<dyn MediaSource>) {
        self.playlist.append(media)
    }

    public fn loadNext() {
        if self.currentTrackIndex < self.playlist.count() {
            self.currentMedia = self.playlist.remove(self.currentTrackIndex)
            self.currentTrackIndex = self.currentTrackIndex + 1
        }
    }

    public fn playCurrentMedia() {
        if let media = self.currentMedia {
            media.play()
        }
    }

    public fn getPlaylistDuration(): Float {
        var total = 0.0
        for media in self.playlist {
            total = total + media.getDuration()
        }
        return total
    }
}

fn main() {
    var player = MediaPlayer.create()

    player.addToPlaylist(
        Box { AudioFile {
            path: "song.mp3".toString(),
            duration: 240.0,
            isPlaying: false,
        } } as Box<dyn MediaSource>
    )

    player.addToPlaylist(
        Box { VideoFile {
            path: "video.mp4".toString(),
            duration: 180.0,
            width: 1920,
            height: 1080,
            isPlaying: false,
        } } as Box<dyn MediaSource>
    )

    println!("Total playlist duration: \(player.getPlaylistDuration()) seconds")

    player.loadNext()
    player.playCurrentMedia()
}

Dynamic Dispatch Overhead

When you use trait objects, method calls are dispatched at runtime using a virtual method table (vtable). This adds a small performance cost compared to static dispatch with generics:

// Static dispatch - zero cost abstraction
fn process<T: Shape>(shape: &T) {
    shape.area()  // Inlined or directly called
}

// Dynamic dispatch - small runtime cost
fn process(shape: &dyn Shape) {
    shape.area()  // Looked up in vtable at runtime
}

The overhead is typically minimal and well worth the flexibility. Only use static dispatch if:

  1. You need maximum performance in hot code paths
  2. You're working with a small, fixed set of types

Comparison with Rust

Rust's trait object syntax is identical to Oxide's:

Rust:

#![allow(unused)]
fn main() {
let shapes: Vec<Box<dyn Shape>> = vec![
    Box::new(Circle { radius: 5.0 }),
    Box::new(Rectangle { width: 10.0, height: 20.0 }),
];
}

Oxide:

let shapes: Vec<Box<dyn Shape>> = vec![
    Box { Circle { radius: 5.0 } } as Box<dyn Shape>,
    Box { Rectangle { width: 10.0, height: 20.0 } } as Box<dyn Shape>,
]

The core mechanism is the same - dynamic dispatch through vtables.

Summary

Trait objects (dyn Trait) enable runtime polymorphism:

  • &dyn Trait - Borrowed trait object, pass references to different types
  • Box<dyn Trait> - Owned trait object, store different types together
  • Object safety - Not all traits can be trait objects (static methods and Self return types aren't allowed)
  • Dynamic dispatch - Method calls are resolved at runtime, with slight overhead
  • When to use - When you have a collection of different types or don't know the type until runtime

Trait objects are a powerful tool for creating flexible, extensible architectures. Combined with the encapsulation and composition patterns covered in this chapter, they enable you to write robust, maintainable object-oriented code in Oxide.