Using Trait Objects in Rust
Polymorphism allows code to operate on multiple types of data. While Rust does not support classical object-oriented inheritance, it achieves polymorphism through trait objects. This chapter explores how trait objects enable heterogeneous collections, how they contrast with generics via static versus dynamic dispatch, and the rules of object safety.
1. The GUI Library Problem: Why Do We Need Trait Objects?
Imagine building a Graphical User Interface (GUI) library. The goal of the library is to take a list of visual elements—such as buttons, text boxes, and sliders—and draw them to the screen.
The Extensibility Challenge
- Compile-time limitation: At the time the library is written and compiled, the full breadth of objects that consumers will create is unknown.
- Shared Behavior: All visual components share a common operation: they can all be drawn to the screen.
- Classical OOP Approach: In languages with classical inheritance, you would create a base class
VisualComponentwith a virtualdraw()method, and subclass it for buttons, sliders, and text boxes. - Rust Approach: Rust uses traits instead of classes. We define a shared behavior trait (
Draw) and leverage trait objects to handle different underlying types through a unified pointer.
2. Defining the Draw Trait and Screen Struct
To implement this in Rust, we split our code into a library (lib.rs) and an executable binary (main.rs). We first define the shared trait and the container struct.
Defining the Draw Trait
// lib.rs
pub trait Draw {
fn draw(&self);
}
Defining the Screen Struct with a Trait Object Vector
To hold a collection of mixed components that all implement Draw, we cannot store concrete types directly in a standard vector because vectors require uniform sizing. Instead, we use a trait object wrapped inside a Box smart pointer:
// lib.rs
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Note on Syntax (
dynkeyword): A trait object is defined by specifying a pointer (like a reference&or a smart pointer likeBox<T>), followed by thedynkeyword (short for dynamic), and then the trait name. Thedynkeyword signals to the compiler and reader that dynamic dispatch is used.
Implementing the Screen Run Loop
Next, we implement a method on Screen to iterate over our components and call their respective draw() methods:
// lib.rs
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
3. Generics vs. Trait Objects: Homogeneous vs. Heterogeneous Collections
A natural question arises: Why use trait objects instead of generics with trait bounds?
The Generics Approach (Homogeneous Collections)
If we implemented our Screen struct using generics, it would look like this:
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T: Draw> Screen<T> {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
Key Differences Comparison
4. Implementing Drawable Components
Let's implement a concrete component (Button) inside our library, and allow library consumers to implement their own custom components (SelectBox).
Library Component: Button
// lib.rs
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// Code to actually draw a button to the screen
}
}
Note: Components can hold component-specific fields (like label, width, height) and define custom helper methods (such as an on_click handler) alongside the mandatory draw method.
Consumer Component: SelectBox
In main.rs, a consumer of our library imports the Draw trait and defines a brand-new component:
// main.rs
use gui_lib::Draw;
pub struct SelectBox {
pub width: u32,
pub height: u32,
pub options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// Code to draw the select box
}
}
5. Using the GUI Library in main.rs
Now we assemble our application, instantiate our Screen, populate it with boxed trait objects of different types, and run the display loop.
// main.rs
use gui_lib::{Screen, Button};
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No"),
],
}),
Box::new(Button {
width: 50,
height: 20,
label: String::from("OK"),
}),
],
};
screen.run();
}
Compile-Time Type Safety Guard
If a consumer attempts to insert a type that does not implement the Draw trait into the components vector, Rust's compiler immediately rejects it:
// This will fail to compile:
Box::new(String::from("This is not drawable"))
Compiler Error: The trait
Drawis not implemented for theStringtype. Therefore,Stringcannot be used in this list.
6. Static Dispatch vs. Dynamic Dispatch
Understanding how Rust executes method calls under the hood requires distinguishing between two mechanisms.
Monomorphization and Static Dispatch
- Definition: When using generics and trait bounds, Rust performs monomorphization at compile time.
- Mechanism: The compiler analyzes every concrete type used with the generic function and generates dedicated, non-generic copies of that function for each type (e.g., generating
draw_buttonanddraw_select_box). - Dispatch: Because concrete functions are known entirely at compile time, calls are resolved instantly (Static Dispatch).
Trait Objects and Dynamic Dispatch
- Definition: When using trait objects (
dyn Draw), the compiler cannot know all possible concrete types that will be passed into the collection at compile time. - Mechanism: Rust relies on Dynamic Dispatch. The compiler generates a pointer to the data and a pointer to a virtual method table (vtable), which maps runtime types to their corresponding method implementations.
- Trade-off: You gain immense architectural flexibility at the cost of disabling compiler inlining optimizations and incurring a small runtime pointer-chasing cost.
7. Object Safety
Not every trait in Rust can be made into a trait object. Traits must be object safe.
The Two Rules of Object Safety
A trait is object safe if and only if all of its associated methods satisfy the following two properties:
- The return type is not
Self: The method cannot return the concrete implementor type. - There are no generic type parameters: The method cannot take generic type arguments.
Why Does Object Safety Matter?
If a trait method violated these rules, the compiler would be unable to construct a vtable because the exact size and method signatures of the implementing types would be ambiguous at runtime. For advanced details, consult the official Rust RFC documentation on object safety.
8. Summary and Key Takeaways
- Polymorphism in Rust: Achieved through trait objects rather than classical object-oriented class inheritance.
- Syntax: Trait objects are written using pointer types combined with the
dynkeyword (e.g.,Box<dyn Draw>or&dyn Draw). - Heterogeneous Collections: Trait objects enable vectors and data structures to store mixed concrete types that share a common trait behavior.
- Generics vs. Trait Objects: Generics enforce homogeneous collections with zero-cost static dispatch (monomorphization); trait objects enable heterogeneous collections with dynamic dispatch.
- Vtables: Dynamic dispatch utilizes virtual method tables at runtime to route method calls to the correct underlying concrete implementation.
- Object Safety: Traits can only be used as trait objects if their methods do not return
Selfand do not contain generic parameters.