12 KiB
Using Trait Objects That Allow for Values of Different Types
In ch8 we discussed one of the limitation of vectors is that they can store elements of only one type
We created a workaround where we defined a SpreadsheetCell
enum that had variants to hold integers, floats and text.
This meant that we could store different types of data in each cell and still have a vector that represented a row of cells.
This is a good solution when our interchangeable items are a fixed set of types that we know when our code is compiled.
However, sometimes we want our library user to be able to extend the set of types that are valid in a particular situation.
To show how we may achieve this, we will create a example graphical user interface (GUI) tool that iterates through a list of items, calling a draw
method on each one to draw it to the screen.
This is a common technique for GUI tools.
We will start with creating a library crate called gui
that contains the structure of a GUI library.
This crate might include some types for people to use, such as Button
or TextField
.
In addition gui
users will want to create their own types that can be drawn.
For example, one programmer might add an Image
and another programmer might add a SelectBox
In this example we won't build a fully fledged GUI library, but instead will show how all of the pieces would fit together.
At the time of writing the library, we cant know and define all the types other programmers might want to create.
We do know that gui
needs to keep track of many values of different and it needs to call a draw
method on each of these differently typed values.
It doesn't need to know exactly what will happen when we call the draw
method, just that the value will have that method available for us to call.
In a language to do this, we might define a class named Component
that has a method named draw
on it.
The other classes, such as Button
, Image
and SelectBox
, would inherit form Component
and thus inherit the draw
method.
Each one would override the draw
method to define their custom behavior, but the framework could treat all of the types as if they were Component
instances and call draw
on them.
Due to Rust not having inheritance, we need a different way to structure the gui
library to allow users to extend it with new types.
Defining a Trait for Common Behavior
In order to implement the behavior we want gui
to have, we will define a trait named Draw
that will have one method named draw
.
We then can define a vector that takes a trait object.
A trait object points to both an instance of a type implementing our specified trait and a table used to look up trait methods on that type at runtime.
We create a trait object by specifying some sort of pointer, such as a &
reference or a Box<T>
smart pointer, then the dyn
keyword and then specifying the relevant trait.
We will discuss about the reason trait objects must use a pointer in Ch20.
We can use trait objects in place of a generic or concrete type.
Whenever we use a trait object, Rust's type system will ensure at compile time that any value used in that context will implement the trait object's trait.
Due to this we don't need to know all possible types at compile time.
We mentioned that in Rust, we refrain from calling structs an enums "objects" to distinguish them from other language's objects.
In a struct or enum, the data in the struct fields and the behavior in impl
blocks are separated, where as in other languages, the data and behavior combined into one concept is often labeled an object.
Trait objects are more like objects in other languages in the sense that they combine data and behavior.
Trait objects still differ form traditional objects in that we can't add data to a trait object.
Trait objects aren't as generally useful as objects in other languages: their purpose is to allow abstraction across common behavior.
Here is an example of how to define a trait named Draw
with one method named draw
pub trait Draw {
fn draw(&self);
}
This is similar syntax to how we defined traits in Ch10.
Next comes some new syntax.
Here we define a struct named Screen
that holds a vector named components
.
This vector is of type Box<dyn Draw>
, which is a trait object; it is a stand in for any type inside a Box
that implements the Draw
trait.
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
Now on the Screen
struct we will define a method named run
that will call the draw
method on each of its components
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
This works differently form defining a struct that uses a generic type parameter with trait bounds.
A generic type parameter can only be substituted with one concrete type at a time, whereas trait objects allow for multiple concrete types to fill in for the trait object at runtime.
For example we could have defined the Screen
struct using generic type and a trait bound, this is shown here.
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 restricts us to a Screen
instance that has a list components all of type Button
or all of type TextField
.
If you only ever have homogeneous collections, using generics and trait bounds is preferable because the definitions will be monomerized at compile time to use the concrete types.
With the method using trait objects, one Screen
instance can hold a Vec<T>
that contains a Box<Button>
as well as a Box<TextField>
.
Now lets look at how that works and then we will talk about the runtime performance implications.
Implementing the Trait
Next we will add some types that implement the Draw
trait.
We will provide the Button
type.
So the draw
method won't have any useful implementation in its body.
To imagine what the implementation might look like, a Button
struct might have fields for width
, height
and label
.
Here is an example of this
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
}
}
The width
, height
and label
fields on Button
will differ from the fields on other components.
For example a TextField
type might have those same fields plus a placeholder
field.
Each of the types we want to draw on the screen will implement the Draw
trait, but will use different code in the draw
method to define how to draw that particular type as Button
has here (without any actual GUI code).
The Button
type, for instance, might have an additional impl
block containing method related to what happens when a user clicks the button.
These kinds of methods related to what happens when a user clicks the button.
These kinds of methods won't apply to types like TextField
.
If someone using our library decides to implement a SelectBox
struct that has width
, height
, and options
fields, they implement the Draw
trait on the SelectBox
type as well.
This is shown below
use gui::Draw;
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
When we originally wrote the library, we didn't know that someone might add the SelectBox
type, but our Screen
implementation was able to operate on the new type and draw it because SelectBox
implements the Draw
trait.
This means that it implements the draw
method.
This concept of being concerned only with the messages a value responds to rather than the value's concrete type.
This is similar to the concept of duck typing in dynamically typed languages.
If it walks like a duck and quacks like a duck, then it must be a duck!
In the implementation of Run
on Screen
, run
doesn't need to know what the concrete type of each component is.
It doesn't check whether a component is an instance of a Button
or a SelectBox
, it just calls the draw
method on the component.
By specifying Box<dyn Draw>
as the type of the values in the componets
vector.
Here we have defined Screen
to need values that we can call the draw
method on.
The advantage of using trait objects and Rust's type system to write code similar to code using duck typing is that we never have to check whether a value implements a particular method at runtime or worry about getting errors if a value doesn't implement a method but we call it anyway.
Rust will not compile the code if the values don't implement the traits that the trait objects need.
Here is an example of this shows what happens if we try to create a Screen
with a String
as a component
use gui::Screen;
fn main() {
let screen = Screen {
components: vec![Box::new(String::from("Hi"))],
};
screen.run();
}
Here we will get this error because String
doesn't implement the Draw
trait
$ cargo run
Compiling gui v0.1.0 (file:///projects/gui)
error[E0277]: the trait bound `String: Draw` is not satisfied
--> src/main.rs:5:26
|
5 | components: vec![Box::new(String::from("Hi"))],
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Draw` is not implemented for `String`
|
= help: the trait `Draw` is implemented for `Button`
= note: required for the cast from `Box<String>` to `Box<dyn Draw>`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `gui` (bin "gui") due to 1 previous error
This error let us know that either we are passing something to Screen
we didn't mean to pass and so should pass a different type or we should implement Draw
on String
so that Screen
is able to call draw
on it.
Trait Objects Preform Dynamic Dispatch
Recall the "Performance of Code Using Generics" our discussion on the monomorphization process performed on generics by the compiler.
The compiler generates nongeneric implementations of functions and methods for each concrete type that we use in place of a generic type parameter.
The code that results from monomorphization is doing static dispatch, which is when the compiler know what method you are calling at compile time.
This is the opposite to dynamic dispatch, which is when the compiler can't tell at compile time which method you are calling.
In dynamic dispatch cases, the compiler emits code that at runtime will figure out which method to call.
When we use trait objects, Rust must use dynamic dispatch.
The compiler won't know all the types that might be used with the code that is using trait objects, so it doesn't know all the types that might be used with the code that is using trait objects, so it doesn't know which method implemented on which type to call.
Instead at runtime, Rust uses the pointers inside the trait object to know which method to call.
This lookup incurs a runtime cost that doesn't occur with static dispatch.
Dynamic dispatch also prevents the compiler from choosing to inline a method's code, which prevents some optimizations, and Rust has some rules about where you can and cannot use dynamic dispatch.
This is called dyn compatibility.
However you will get the extra flexibility in the code that we wrote before and we were able to support in the following examples, so it is a trade-off to consider.