Polymorphism in programming allows for the use of a single interface to represent different data types or operations. Rust, despite being known for its strict type system and memory safety guarantees, offers a variety of ways to achieve polymorphism including closures, trait objects, and smart pointers. Each of these techniques has its own strengths and use-cases. In this article, we explore and compare these mechanisms to help you choose the most appropriate one for your needs.
Table of Contents
Closures
Closures in Rust are anonymous functions you can save in a variable or pass as arguments to other functions. This is one form of polymorphism in the sense that you can store different closures that implement the same interface in a single type.
fn execute_closure(f: F)
where
F: FnOnce() {
f();
}
fn main() {
let closure = || println!("I'm a closure!");
execute_closure(closure);
}Closures capture environment variables by borrowing them, gaining access while maintaining ownership semantics tailored to your design. However, closures are more suited for situations where they do not leave the scope they are defined in.
Trait Objects
Trait objects provide a means to achieve runtime polymorphism. By using dyn keyword, Rust allows you to store types that implement a specific trait on the heap and use them through a single reference.
trait Drawable {
fn draw(&self);
}
struct Circle;
impl Drawable for Circle {
fn draw(&self) {
println!("Drawing a circle!");
}
}
fn draw_object(obj: &dyn Drawable) {
obj.draw();
}
fn main() {
let circle = Circle;
draw_object(&circle);
}Trait objects are more flexible than closures as they can be stored and passed around outside their original scope. However, this flexibility comes with a performance cost due to dispatch overhead.
Smart Pointers
Smart pointers in Rust, like Box, Rc, and Arc, manage data with additional capabilities, such as ownership semantics. They can be combined with trait objects to enable polymorphism.
use std::rc::Rc;
enum Shape {
Circle,
Square,
}
trait Area {
fn area(&self) -> f64;
}
impl Area for Shape {
fn area(&self) -> f64 {
match self {
Shape::Circle => 3.14 * 10.0 * 10.0, // Example value
Shape::Square => 10.0 * 10.0, // Example value
}
}
}
fn main() {
let shape: Rc = Rc::new(Shape::Circle);
println!("Area: {}", shape.area());
}Combined with trait objects, smart pointers allow for dynamic allocation and shared ownership. They can be particularly useful in concurrent scenarios where multiple threads need access to the same data.
Comparing and Choosing
Each of these tools serves different needs. Closures provide zero-cost abstraction but are suited for short-lived tasks and limited scope. Trait objects offer flexibility at the expense of runtime performance, ideal for plugin architectures or scenarios where the data type is unknown at compile time. Smart pointers, paired with trait objects, provide dynamic allocation and concurrency benefits, adding overhead but useful for shared ownership requirements.
Deciding which one to use relies heavily on the specifics of your application requirements. Consider performance impacts, desired ownership semantics, and how you intend to use polymorphism when choosing among closures, trait objects, and smart pointers.