DEV Community

Cover image for [Rust Guide] 17.2. Using Trait Objects to Store Values of Different Types
SomeB1oody
SomeB1oody

Posted on

[Rust Guide] 17.2. Using Trait Objects to Store Values of Different Types

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),
    ];
}
Enter fullscreen mode Exit fullscreen mode

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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
  • First, we declare a public trait called Draw, which defines a draw method but provides no concrete implementation.
  • Then we declare a public struct called Screen, which has a public field called components. Its type is a Vec whose elements are Box<dyn Draw>. Box<> is used to define a trait object, meaning the value inside the box implements the Draw trait.
  • We use an impl block to define a run method for Screen; 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();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • A Button struct might have width, height, and label fields, so we define it this way.
  • We implement the Draw trait for Button in an impl block, 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
    }
}
Enter fullscreen mode Exit fullscreen mode
  • The SelectBox struct in main.rs has three fields: width, height, and options.
  • We implement the Draw trait for SelectBox in an impl block, 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();
}
Enter fullscreen mode Exit fullscreen mode
  • The main program creates a Screen instance containing both a SelectBox and a Button (wrapped with Box::new()). The reason this vector can hold different types is precisely because we defined a trait object.
  • Then we call the run method on Screen to render it. In practice, run does not care what specific type is passed in, as long as that type implements the Draw trait.

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);
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)