Introduction
Polymorphism is a fundamental concept in object-oriented programming that allows a single interface to represent different underlying forms (data types). In Rust, polymorphism is typically achieved using trait
, which are similar to interfaces in other languages. Trait
enable us to define shared behavior that different types can implement, allowing for flexible and reusable code.
However, while traits are a powerful feature of Rust, they are not the only way to achieve polymorphism. enum
allow us to define a type by enumerating its possible variants, each of which can hold different data and have different behaviors.
In this blog post, we'll explore how to implement polymorphism in Rust using enum
instead of trait
. We'll start with a brief overview of how traits are typically used for polymorphism, and then dive into how enum
can be used as an alternative approach. We'll compare the two methods, discuss their advantages and drawbacks, and provide practical examples to illustrate the concepts.
JavaScript
Before diving into Rust, let's consider a common scenario in JavaScript.
Imagine you have an interface SlotSource
that defines a method getSlot
. Various classes implements this interface, each providing their own version of the getSlot
method:
export interface SlotSource {
getSlot(): number;
}
export class SlotSubscriber implements SlotSource {
public getSlot(): number {
...
}
}
export class OrderSubscriber implements SlotSource {
public getSlot(): number {
...
}
}
Now let's say we have a class FooSubscriber
that has property slotSource
which can be any object that implements the SlotSource
interface. This allows FooSubscriber
to call the getSlot
method without needing to know the specific type of slotSource
:
export class FooSubscriber {
slotSource: SlotSource;
constructor(slotSource: SlotSource) {
this.slotSource = slotSource;
}
public some_function(): void {
let slot = this.slotSource.getSlot();
// Use the slot number
}
}
This approach, using interfaces and polymorphism, is common in many object-oriented languages like JavaScript. But what if we want to achieve similar polymorphic behavior in Rust?
Traits and Polymorphism in Rust
If we want to convert following code into Rust, we can write something like this.
Normally, interface
in JS can be translated into trait
in Rust.
trait SlotSource {
fn get_slot(&self) -> u64;
}
Define each struct SlotSubscriber
and OrderSubscriber
, and then implements SlotSource
for both structs.
struct SlotSubscriber;
impl SlotSource for SlotSubscriber {
fn get_slot(&self) -> u64 {
1
}
}
struct OrderSubscriber;
impl SlotSource for OrderSubscriber {
fn get_slot(&self) -> u64 {
2
}
}
Define FooSubscriber
.
struct FooSubscriber<T: SlotSource> {
slot_source: T;
}
impl<T: SlotSource> FooSubscriber<T> {
pub fn new(slot_source: T) -> Self {
Self {
slot_source
}
}
pub fn some_function(&self) {
let slot = self.slotSource.getSlot();
println!("Slot: {slot}");
}
}
Here's how you might use these structs and traits.
fn main() {
let slot_subscriber = SlotSubscriber;
let order_subscriber = OrderSubscriber;
let foo1 = FooSubscriber::new(slot_subscriber);
let foo2 = FooSubscriber::new(order_subscriber);
foo1.some_function();
foo2.some_function();
}
Polymorphism with Enums
While traits are a common way to achieve polymorphism in Rust, we can also use enums for certain cases. Enums in Rust allow us to define a type that can be one of several variants, each of which can hold different data and have different behaviors. This can be particularly useful when the set of possible types is fixed and known in advance.
Let's revisit our example using enums instead of traits.
First, we'll define an enum SlotSource with two variants: SlotSubscriber and OrderSubscriber. Each variant will implement a method get_slot.
enum SlotSource {
SlotSubscriber,
OrderSubscriber,
}
impl SlotSource {
fn get_slot(&self) -> u64 {
match self {
SlotSource::SlotSubscriber => {
// Implementation for SlotSubscriber
1
}
SlotSource::OrderSubscriber => {
// Implementation for OrderSubscriber
2
}
}
}
}
Next, we'll define FooSubscriber to hold a SlotSource enum instead of a generic type.
struct FooSubscriber {
slot_source: SlotSource,
}
impl FooSubscriber {
pub fn new(slot_source: SlotSource) -> Self {
Self { slot_source }
}
pub fn some_function(&self) {
let slot = self.slot_source.get_slot();
println!("Slot: {}", slot);
// Additional functionality
}
}
Here's how you might use these structs and enums:
fn main() {
let slot_subscriber = SlotSource::SlotSubscriber;
let order_subscriber = SlotSource::OrderSubscriber;
let foo1 = FooSubscriber::new(slot_subscriber);
let foo2 = FooSubscriber::new(order_subscriber);
foo1.some_function();
foo2.some_function();
}
Comparing Enums and Traits
Using enums for polymorphism can be beneficial when the set of possible types is known and fixed. Enums provide a simpler and more explicit way to handle multiple types, and they can be more efficient since the variants are all part of the same type.
However, traits offer more flexibility and extensibility. With traits, you can easily add new implementations without modifying existing code. Traits also allow for more dynamic behavior and can be used with generics to create highly reusable code.
In summary, both traits and enums have their place in Rust. Understanding the strengths and limitations of each approach will help you choose the right tool for your specific use case.
Benchmarking
To compare the performance of trait-based(dynamic dispatch one and static dispatch one) and enum-based polymorphism in Rust, I conducted a series of benchmarks.
Trait-based(dynamic dispatch)
use criterion::{black_box, criterion_group, criterion_main, Criterion};
trait SlotSourceTrait {
fn get_slot(&self) -> u64;
}
struct SlotSubscriber;
impl SlotSourceTrait for SlotSubscriber {
fn get_slot(&self) -> u64 {
1
}
}
struct OrderSubscriber;
impl SlotSourceTrait for OrderSubscriber {
fn get_slot(&self) -> u64 {
2
}
}
struct FooSubscriberTrait {
slot_source: Box<dyn SlotSourceTrait>,
}
impl FooSubscriberTrait {
pub fn new(slot_source: Box<dyn SlotSourceTrait>) -> Self {
Self { slot_source }
}
pub fn some_function(&self) {
let slot = self.slot_source.get_slot();
black_box(slot); // Use black_box to prevent optimizations
}
}
fn benchmark_traits_dynamic(c: &mut Criterion) {
let slot_subscriber = SlotSubscriber;
let order_subscriber = OrderSubscriber;
let foo1 = FooSubscriberTrait::new(Box::new(slot_subscriber));
let foo2 = FooSubscriberTrait::new(Box::new(order_subscriber));
c.bench_function("traits_slot_subscriber", |b| {
b.iter(|| foo1.some_function())
});
c.bench_function("traits_order_subscriber", |b| {
b.iter(|| foo2.some_function())
});
}
criterion_group!(benches, benchmark_traits_dynamic);
criterion_main!(benches);
Trait-based(static dispatch)
use criterion::{black_box, criterion_group, criterion_main, Criterion};
trait SlotSourceTrait {
fn get_slot(&self) -> u64;
}
struct SlotSubscriber;
impl SlotSourceTrait for SlotSubscriber {
fn get_slot(&self) -> u64 {
1
}
}
struct OrderSubscriber;
impl SlotSourceTrait for OrderSubscriber {
fn get_slot(&self) -> u64 {
2
}
}
struct FooSubscriberTrait<T: SlotSourceTrait> {
slot_source: T,
}
impl<T: SlotSourceTrait> FooSubscriberTrait<T> {
pub fn new(slot_source: T) -> Self {
Self { slot_source }
}
pub fn some_function(&self) {
let slot = self.slot_source.get_slot();
black_box(slot); // Use black_box to prevent optimizations
}
}
fn benchmark_traits(c: &mut Criterion) {
let slot_subscriber = SlotSubscriber;
let order_subscriber = OrderSubscriber;
let foo1 = FooSubscriberTrait::new(slot_subscriber);
let foo2 = FooSubscriberTrait::new(order_subscriber);
c.bench_function("traits_slot_subscriber", |b| {
b.iter(|| foo1.some_function())
});
c.bench_function("traits_order_subscriber", |b| {
b.iter(|| foo2.some_function())
});
}
criterion_group!(benches, benchmark_traits);
criterion_main!(benches);
Enum-based
use criterion::{black_box, criterion_group, criterion_main, Criterion};
enum SlotSource {
SlotSubscriber,
OrderSubscriber,
}
impl SlotSource {
fn get_slot(&self) -> u64 {
match self {
SlotSource::SlotSubscriber => 1,
SlotSource::OrderSubscriber => 2,
}
}
}
struct FooSubscriber {
slot_source: SlotSource,
}
impl FooSubscriber {
pub fn new(slot_source: SlotSource) -> Self {
Self { slot_source }
}
pub fn some_function(&self) {
let slot = self.slot_source.get_slot();
black_box(slot); // Use black_box to prevent optimizations
}
}
fn benchmark_enums(c: &mut Criterion) {
let slot_subscriber = SlotSource::SlotSubscriber;
let order_subscriber = SlotSource::OrderSubscriber;
let foo1 = FooSubscriber::new(slot_subscriber);
let foo2 = FooSubscriber::new(order_subscriber);
c.bench_function("enums_slot_subscriber", |b| b.iter(|| foo1.some_function()));
c.bench_function("enums_order_subscriber", |b| {
b.iter(|| foo2.some_function())
});
}
criterion_group!(benches, benchmark_enums);
criterion_main!(benches);
Here are the results:
Trait-based(dynamic dispatch)
Performance regressed significantly compared to the static dispatch, indicating the overhead associated with dynamic dispatch.
Running benches/dynamic.rs (target/release/deps/dynamic-3303b3f3ed1e59d0)
Gnuplot not found, using plotters backend
traits_slot_subscriber time: [925.68 ps 926.64 ps 927.76 ps]
change: [+407.06% +407.74% +408.41%] (p = 0.00 < 0.05)
Performance has regressed.
Found 13 outliers among 100 measurements (13.00%)
13 (13.00%) high severe
traits_order_subscriber time: [912.04 ps 912.54 ps 913.51 ps]
change: [+397.81% +398.31% +398.86%] (p = 0.00 < 0.05)
Performance has regressed.
Found 6 outliers among 100 measurements (6.00%)
3 (3.00%) high mild
3 (3.00%) high severe
Trait-based(static dispatch)
Performance improved dramatically compared to dynamic dispatch, highlighting the benefits of static dispatch when using traits.
Running benches/static.rs (target/release/deps/static-3987288cf744c972)
Gnuplot not found, using plotters backend
traits_slot_subscriber time: [182.78 ps 182.86 ps 182.99 ps]
change: [-80.276% -80.257% -80.239%] (p = 0.00 < 0.05)
Performance has improved.
Found 7 outliers among 100 measurements (7.00%)
1 (1.00%) high mild
6 (6.00%) high severe
traits_order_subscriber time: [182.90 ps 183.21 ps 183.59 ps]
change: [-79.945% -79.873% -79.790%] (p = 0.00 < 0.05)
Performance has improved.
Found 8 outliers among 100 measurements (8.00%)
4 (4.00%) high mild
4 (4.00%) high severe
Enum-based
Performance remained stable with no significant change detected. Enums provide a more consistent and efficient solution compared to dynamic dispatch traits.
Running benches/enum.rs (target/release/deps/enum-5b9a9ddb913d92ca)
Gnuplot not found, using plotters backend
enums_slot_subscriber time: [182.41 ps 182.73 ps 183.24 ps]
change: [-0.1698% +0.6072% +1.6609%] (p = 0.21 > 0.05)
No change in performance detected.
Found 18 outliers among 100 measurements (18.00%)
8 (8.00%) high mild
10 (10.00%) high severe
enums_order_subscriber time: [182.73 ps 182.81 ps 182.90 ps]
change: [+0.2471% +0.3127% +0.3715%] (p = 0.00 < 0.05)
Change within noise threshold.
Found 13 outliers among 100 measurements (13.00%)
1 (1.00%) low mild
4 (4.00%) high mild
8 (8.00%) high severe
Conclusion
In this blog post, we've explored two different approaches to achieving polymorphism in Rust: using traits and using enums. We started with a familiar example in JavaScript and translated it into Rust using both traits and enums. We then set up benchmarks to compare the performance of these implementations.
Thank you for reading! I hope this exploration of polymorphism in Rust has been informative and helpful.