Polymorphism in Rust: Enum vs Trait + Struct

Rust isn’t an object-oriented language in the same way as C++ and Java. You cannot write classes that extend behavior from another class for example. However, Rust does support polymorphism and there are two common approaches.

For this blog post I’m going to use an example of writing a crate for generating svg (scalable vector graphics) files based on a list of shapes. Ideally, it should be possible for users of this library to add their own shapes.

The two approaches I am going to demonstrate are algebraic data types (enumerations) and structs combined with traits.

Polymorphism using Enumerations

Let’s look at enumerations first. We can define an enumeration to model all of our shapes like this (full source code for this example is available here).

It is now possible to write a function that can generate an svg file given a vector of shapes.The function can use pattern matching to determine the behavior for each type of shape.

This looks pretty clean, but a user of this crate cannot add their own shapes since enumerations do not support inheritance and there is no other mechanism to add a new shape to the existing enumeration.

Polymorphism using Traits and Structs

The second approach is to use a trait to define some behavior and use structs to represent the shapes. This time we define a separate struct for each shape like this (full source code for this example is available here).

We also define a trait. Rather than call the trait ‘Shape’ we will call it ‘SvgWriter’ since we are using the trait to define behavior rather than type.

Next, we need to provide an implementation of this trait on each of our shapes.

Finally, we can write our function to generate the svg file given a vector of shapes.

The advantage of this approach over enumerations is that a user of this crate can go ahead and define new structs to implement custom shapes. For example, the user could create a shape that combines a square and a circle:

The svg writer can now be invoked with this custom shape as follows. Note that because we’re using traits, we cannot simply add structs to the vector because one of the requirements of a vector is that all of its elements must be the same size on the stack and traits do not have a fixed size (the data types implementing the trait have a size and there could be multiple types with different sizes). To work around these, we put the data types that implement the trait into a “box”. This is effectively a fixed size pointer to the data type (it’s actually a “fat pointer” containing a pointer and a v-table, but that’s beyond the scope of this blog post). The boxed trait is often referred to as a “trait object”.

Mixing and Matching Enums, Traits, and Structs

It is possible to mix these two approaches by adding an enum variant to model a user-defined shape and have that contain a trait object, as follows:

I have used this approach before but I think it is generally best to stick to one approach or the other for a more consistent coding style and reduced complexity.

Conclusion

Enumerations make for very readable code with pattern matching but are not extensible. They make sense for internal structures that users of your crate do not need to add functionality to. Enumeration values can be passed around efficiently on the stack as well.

Traits provide more flexibility at the cost of placing objects on the heap and using dynamic dispatch instead of static dispatch. This cost is unlikely to be an issue for the majority of applications and is a price worth paying for the additional flexibility, in my opinion.

Leave a Reply

Your email address will not be published. Required fields are marked *