If you are familiar with object-oriented programming (OOP), you may have heard of Polymorphism. Polymorphism is a useful feature of OOP that describes the ability of a variable or interface to exhibit behaviors of different objects within its hierarchical tree.
Polymorphism allows you to build flexible applications that easily adjust to runtime requirements without breaking the system. In addition to reducing boilerplate codebase, polymorphism also ensures that your application’s components are not tightly coupled to one another.
In this article, we’ll explore the concepts of polymorphism in the Rust programming language. We will also demonstrate different ways to implement polymorphism in Rust.
Jump ahead:
The underlying theory of polymorphism in Rust is similar to that of any other OOP language.
Consider a program that makes different creatures walk. You could design a walk
function to accept a concrete type of a specific creature (e.g., a Cat
) as a parameter.
Now, suppose you want to walk a Dog
; you’d have to create another function that accepts Dog
as its parameter.
Following this concept, it’s implied that each creature would need its respective function to perform the corresponding walk
as shown in the below snippet:
struct Cat; impl Cat { fn walk(&self) -> (){ println!("Cat is walking") } } struct Dog; impl Dog { fn walk(&self) -> (){ println!("Dog is walking") } } fn main() { let cat = Cat{}; let dog = Dog {}; cat.walk(); dog.walk(); }
This is a flawed implementation; it introduces duplicate code all over your application and also makes it difficult to maintain the growing number of functions for each creature. As you create more and more individual creatures in your application, the number of functions will grow exponentially.
With polymorphism, you can create a single function that accepts any object as long as it performs a specific operation. In the context of the creatures example, this function will accept any object that implements a walk()
method.
Each creature will have its custom implementation of the walk()
method that runs whenever it is invoked on the respective creature object.
Rust provides different options for implementing polymorphism in your application through traits. Each option has its own peculiarities and advantages. There are several tradeoffs to consider when deciding which implementation to adopt. Let’s take a look.
The static dispatch concept decides which implementation of a trait
to invoke at compile time, based on the type value it receives as an argument. By default, this is how a Rust program performs its operations.
It uses generics to reduce duplicate codes by allowing you to write functions or types that are acceptable with other types, without having to specify the concrete types in advance. Instead, you provide variables for these types that the program will invoke at runtime.
Using our creatures example, here’s how you could use generics to implement polymorphism:
trait Walkable { fn walk(&self); } struct Cat; struct Dog; impl Walkable for Cat { fn walk(&self) { println!("Cat is walking"); } } impl Walkable for Dog { fn walk(&self) { println!("Dog is walking"); } } fn generic_walk<T: Walkable>(t: T) { t.walk(); } fn main() { let cat = Cat{}; let dog = Dog{}; generic_walk(cat); // Cat is walking generic_walk(dog); // Dog is walking }
The above code declares a Walkable
trait that contains a method, walk
, with no return type. Any struct or type that implements this trait will also have to provide the concrete implementation for the walk
method. Hence, the declared structs, Cat
and Dog
, which both implement the trait, also each provide their respective concrete implementation of the walk
method.
The generic_walk()
function is a generic function that accepts any type as long as it implements the Walkable
trait. This function invokes the walk()
method of the object passed as an argument and runs the operation specific to the type.
The main()
method demonstrates how the generic_walk()
function accepts the Cat
and Dog
objects each time it is invoked, thereby, implicitly calling the walk()
method for each type.
The static dispatch trait uses monomorphization to compile and create multiple copies of the generic_walk()
function for each type passed as the argument, hence, it is aware of the execution process of each type. This is quite efficient in terms of performance because the program decides the implementation to use at compile time rather than at runtime, but this efficiency comes at a cost.
Since the program creates a copy of the generic_walk()
function for each type passed, it’s implied that it allocates memory space for each copy. If you are interested in improved performance and do not have a concern with increasing binary size in the application scheme, then you should consider this approach.
In the dynamic dispatch approach, the program decides the implementation of the trait to execute at runtime depending on the concrete type provided. The dynamic dispatch concept differs from the static dispatch in that it does not have to create multiple copies of the generic function it executes. Instead, it refers to a single copy of the function and determines the concrete implementation to execute at runtime.
Using our creatures example, here’s how you could use the dynamic dispatch approach to implement polymorphism:
trait Walkable { fn walk(&self); } struct Cat; struct Dog; impl Walkable for Cat { fn walk(&self) { println!("Cat is walking"); } } impl Walkable for Dog { fn walk(&self) { println!("Dog is walking"); } } fn dynamic_dispatch(w: &dyn Walkable) { w.walk(); } fn main() { let cat = Cat{}; let dog = Dog{}; dynamic_dispatch(cat); // Cat is walking dynamic_dispatch(dog); // Dog is walking }
Just like the previous static dispatch example, the above code snippet contains the implementations of the Walkable
trait by the Cat
and Dog
structs. The significant difference here is that the dynamic_dispatch()
function accepts a trait object as an argument.
The dyn
keyword in the function parentheses is used to indicate that the function performs dynamic dispatch polymorphism through the concrete implementations of the trait provided.
As shown in the main method, the dynamic_dispatch()
function accepts both Cat
and Dog
objects and implicitly invokes their respective walk()
methods.
Dynamic dispatch is less efficient than static dispatch in terms of performance, particularly because it has to determine the implementation to run along with its execution dependencies during runtime. However, dynamic dispatch makes up for this deficiency through minimal memory usage. This is because it does not need to create multiple copies of the polymorphic function like the static dispatch approach.
The dynamic dispatch approach can be very useful in situations where you need to prioritize memory usage over performance, such as when building embedded systems or any other program that depends on memory utilization.
Enums are data structures that allow you to present data in different variants. They are also very useful when implementing polymorphism.
With enums, you can execute specific operations for each variant using pattern matching as shown below:
Cat{}, Dog{}, } impl Creature { pub fn walk(&self) { match self { Creature::Cat{} => println!("Cat is walking"), Creature::Dog{} => println!("Dog is walking") } } } fn enum_walk(c: Creature) { c.walk(); } fn main() { let cat = Creature::Cat{}; let dog = Creature::Dog{}; enum_walk(cat); // Cat is walking enum_walk(dog); // Dog is walking }
In the code above, the Cat
and Dog
structs are declared as variants in the Creature
enum. Also, using pattern matching, custom implementations are created for each variant when invoking the walk()
method on the enum.
The enum_walk()
function accepts a Creature
enum as an argument (i.e., any variant or derivative of the enum is a valid argument for the function). Then it invokes the walk()
method on the variant passed at runtime as demonstrated in the main()
method.
Building a polymorphic program through enums is generally considered less complex than the static and dynamic dispatch approaches discussed in previous sections. However, it can introduce tight coupling.
All variants of the enum must be declared within the enum scope and it is not extensible by types declared in other libraries. This could be a limitation in cases where you want to leverage the polymorphic benefits of the enum in other areas of your application. If you’re interested in learning more about enums, here is a guide to get you started.
In this article, we discussed the concept of polymorphism in OOP and its peculiarities in the Rust programming language. We also demonstrated how to implement polymorphism in Rust using static dispatch, dynamic dispatch, and enums, and we discussed the advantages and tradeoffs of each approach.
The code snippets provided in this tutorial are available on GitHub.
Debugging Rust applications can be difficult, especially when users experience issues that are hard to reproduce. If you’re interested in monitoring and tracking the performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your Rust application. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.
Modernize how you debug your Rust apps — start monitoring for free.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowReact Native’s New Architecture offers significant performance advantages. In this article, you’ll explore synchronous and asynchronous rendering in React Native through practical use cases.
Build scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
2 Replies to "How to build polymorphic components in Rust"
It looks like the code example in the Enum section omits the definition of the enum Creature. Could that be fixed or could you send me the complete example.
The dynamic dispatch section has the two lines
dynamic_dispatch(cat); // Cat is walking
dynamic_dispatch(dog); // Dog is walking
I think they should be
dynamic_dispatch(&cat); // Cat is walking
dynamic_dispatch(&dog); // Dog is walking
instead.