If you find this helpful, please like, bookmark, and follow. To keep learning along, follow this series.
17.2.1 Requirements
This article uses an example to introduce how to use trait objects in Rust to store values of different types.
In Chapter 8, we mentioned that one limitation of Vecs is that they can store only one element type. We created a workaround in 8.2. Vector + Enum Applications by defining a SpreadsheetCell enum with variants for storing integers, floating-point numbers, and text. This means we can store different types of data in each cell while still having a vector that represents a row of cells. When the items we want to interchange are a fixed set of types that we know when compiling the code, this is a very good solution.
The code looks like this:
enum SpreadSheetCell {
Int(i32),
Float(f64),
Text(String),
}
fn main() {
let row = vec![
SpreadSheetCell::Int(5567),
SpreadSheetCell::Text("up up".to_string()),
SpreadSheetCell::Float(114.514),
];
}
However, sometimes we want users of our library to be able to extend the set of types that are valid in a given context. Here is the requirement for this example:
Create a GUI tool that iterates through a list of elements and calls each element’s draw method in turn for rendering (for example, elements such as Button and TextField).
In an object-oriented language such as Java or C#, this requirement could be handled by defining a Component parent class with a draw method. Then you define classes such as Button and TextField that inherit from Component.
The previous article explained that Rust does not provide inheritance, so if we want to build a GUI tool in Rust, we need another approach: define a trait for shared behavior.
17.2.2 Defining a Trait for Shared Behavior
First, let’s clarify some terminology: in Rust, we avoid calling structs or enums objects, because they are separate from impl blocks. Trait objects are somewhat similar to objects in other languages, because they combine data and behavior to some extent.
Trait objects also differ from traditional objects in that we cannot add data to a trait object.
Trait objects are specifically used to abstract shared behavior, and they are not as general-purpose as objects in other languages.
Here is how the GUI tool is written:
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
- First, we declare a public trait called
Draw, which defines adrawmethod but provides no concrete implementation. - Then we declare a public struct called
Screen, which has a public field calledcomponents. Its type is aVecwhose elements areBox<dyn Draw>.Box<>is used to define a trait object, meaning the value inside the box implements theDrawtrait. - We use an
implblock to define arunmethod forScreen; when it runs, it draws all the elements.
If both are expressing that some type implements some trait or traits, why not use generics instead? Let’s look at the generic version:
pub trait Draw {
fn draw(&self);
}
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where
T: Draw,
{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
This is because once T is fixed in a generic Vec<T>, the vector can store only that one type. For example, if the first element inserted into the vector is a Button, then every other element in that vector must also be a Button because all elements in a vector must have the same type.
With Vec<Box<dyn Draw>>, however, if the first element is a Button, you can still store a TextField later, as long as the type implements the Draw trait.
Next, let’s write what a type that implements the Draw trait looks like:
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// Draw the button
}
}
- A
Buttonstruct might havewidth,height, andlabelfields, so we define it this way. - We implement the
Drawtrait forButtonin animplblock, and we ignore the actual drawing logic.
This is only the content of lib.rs; next we write the main program in main.rs:
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// Draw a selection box
}
}
- The
SelectBoxstruct inmain.rshas three fields:width,height, andoptions. - We implement the
Drawtrait forSelectBoxin animplblock, and we ignore the actual drawing logic.
Now look at the main function:
use gui::{Button, Screen};
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: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
- The main program creates a
Screeninstance containing both aSelectBoxand aButton(wrapped withBox::new()). The reason this vector can hold different types is precisely because we defined a trait object. - Then we call the
runmethod onScreento render it. In practice,rundoes not care what specific type is passed in, as long as that type implements theDrawtrait.
17.2.3 Trait Objects Use Dynamic Dispatch
When Rust applies trait bounds to generics, the compiler performs monomorphization: for every concrete type used to replace the generic parameter, it generates a non-generic implementation of the corresponding functions and methods.
This was explained in [10.2. Generics].
For example:
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
Here, integer is Option<i32> and float is Option<f64>. During compilation, the compiler expands Option<T> into Option_i32 and Option_f64:
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
In other words, the generic definition Option<T> is replaced with two concrete type definitions.
The monomorphized main function becomes this:
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
Code generated through monomorphization uses static dispatch, which determines which method to call during compilation.
Dynamic dispatch cannot determine at compile time which method you are actually calling. The compiler generates extra code so that the desired method can be found at runtime. Using trait objects performs dynamic dispatch. The trade-off is some runtime overhead, and it prevents the compiler from inlining method code, which means some optimizations cannot be performed.
17.2.4 Trait Objects Must Be Object Safe
Only traits that are object-safe can be converted into trait objects. Rust uses a series of rules to determine whether a trait is object safe; you only need to remember two:
- The method return type is not
self. - The method does not contain any generic type parameters.
Take a look at this example:
pub trait Clone {
fn clone(&self) -> self;
}
The standard library’s Clone trait and the clone function signature look like this. Because the return value of clone is self, the Clone trait is not object safe.
Top comments (0)