Unlocking Polymorphism in Rust: A Deep Dive
Understanding Polymorphism in Rust
Rust’s approach to polymorphism is similar to other object-oriented programming (OOP) languages. Consider a program that makes different creatures walk. Without polymorphism, you’d need to create separate functions for each creature type. This leads to duplicated code and maintenance issues. Polymorphism enables you to define a single function that accepts any object with a specific behavior, such as a walk method.
Implementing Polymorphism in Rust
Rust provides several options for implementing polymorphism through traits. Each approach has its tradeoffs, which we’ll discuss below.
Static Dispatch
Static dispatch resolves the implementation of a trait at compile time based on the type value received as an argument. This is the default behavior in Rust. Using generics, you can write functions or types that work with multiple types without specifying concrete types in advance.
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);
generic_walk(dog);
}
Choosing the Static Dispatch Approach
Static dispatch uses monomorphization to compile and create multiple copies of the generic function for each type passed as an argument. This approach is efficient in terms of performance but increases binary size. If performance is a priority and binary size is not a concern, consider this approach.
Dynamic Dispatch
Dynamic dispatch resolves the implementation of a trait at runtime based on the concrete type provided. This approach differs from static dispatch in that it doesn’t create multiple copies of the generic function.
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(t: &dyn Walkable) {
t.walk();
}
fn main() {
let cat = Cat;
let dog = Dog;
dynamic_dispatch(&cat);
dynamic_dispatch(&dog);
}
Choosing the Dynamic Dispatch Approach
Dynamic dispatch is less efficient than static dispatch in terms of performance but uses minimal memory. This approach is suitable when prioritizing memory usage over performance, such as in embedded systems.
Enums
Enums are data structures that allow presenting data in different variants. They’re useful for implementing polymorphism using pattern matching.
enum Creature {
Cat,
Dog,
}
impl Creature {
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);
enum_walk(dog);
}
Choosing the Enums Approach
Building polymorphic programs using enums is generally less complex than static and dynamic dispatch approaches. However, it can introduce tight coupling between variants and limit extensibility.
By understanding the tradeoffs of each approach, you can choose the best implementation method for your use case and leverage the power of polymorphism in Rust.