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:
- The trait doesn't contain any static methods
- The trait doesn't require
Selfto beSized - For object safety, methods do not return
Selfor takeSelfas a parameter (except as the receiver:fn,mutating fn, orconsuming 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:
- You need maximum performance in hot code paths
- 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 typesBox<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.