Hi! Today I'll tell you about a cool Rust 🦀 trick: dual-trait pattern! It's especially useful when you're dealing with the dyn
keyword and want to simulate OOP features in Rust.
The key idea is that we'll consider two perspectives (dual!) about a trait:
- implementer of the trait,
- user of the trait.
In the end, we will take a look at the dual-trait pattern in the wild - I’ve successfully applied the dual-trait pattern to an Apache project. 😉
Animal zoo
Let's start with a classic OOP example with animals. Each animal will:
- have a defined species (parrot, monkey, etc.),
- respond to a text command.
Management of the zoo favors diversity, so we must make sure, that each new animal is distinct from the already owned ones (it implies the use of the Eq
trait).
As a final requirement, our Rust library cannot assume a predefined set of species in the zoo. This (artificial restriction) precludes the use of enum
and forces out to use dyn Animal
in the software.
Let's do it!
First, we'll define the Animal
trait:
pub trait Animal: Eq + Debug {
fn species(&self) -> &'static str;
fn react(&mut self, command: &str) -> String;
}
Notice, that aside from the species and reaction we implement Eq
and Debug
.
The zoo has a beautiful garden with palm trees, let's bring some 🦜 parrots:
#[derive(PartialEq, Eq, Debug)]
pub enum FeatherColor {
Red,
Green,
Blue,
}
#[derive(PartialEq, Eq, Debug)]
pub struct Parrot {
feather_color: FeatherColor,
}
Let's see, how it's natural 🌿 to implement an Animal
trait. The Eq
trait was automatically implemented through #[derive(...)]
.
impl Animal for Parrot {
fn species(&self) -> &'static str {
"Parrot"
}
fn react(&mut self, command: &str) -> String {
match command {
"repeat" => "Polly want a cracker".to_string(),
_ => "Squawk!".to_string(),
}
}
}
The zoo has empty cages. Let's fill them with 🐵 monkeys:
#[derive(PartialEq, Eq, Debug)]
pub enum FurColor {
Brown,
Black,
White,
}
#[derive(PartialEq, Eq, Debug)]
pub struct Monkey {
fur_color: FurColor,
}
impl Animal for Monkey {
fn species(&self) -> &'static str {
"Monkey"
}
fn react(&mut self, command: &str) -> String {
if command.starts_with("invite") {
let who = command.split_whitespace().last().unwrap_or_default();
format!("Oooh oooh aah aah {}", who)
} else {
"Aaaah!".to_string()
}
}
}
The zoo doesn't compile!
The zoo is just an array of animals:
pub struct Zoo {
animals: Vec<Box<dyn Animal>>,
}
However, it gives a compiler error 🛑:
the trait `Animal` cannot be made into an object
The problem is that with the dyn
keyword we requested a polymorphic version of Animal
trait, however, it's impossible, because the equality eq
from PartialEq
uses Self
type:
fn eq(&self, other: &Self) -> bool;
But in polymorphic dispatch, we don't know the type of Self
...
At first glance, it might look like a limitation of Rust. However, in Java and C# we have a similar problem. They solved it by taking Object
as an argument, not a specific type:
public bool Equals(Object obj)
So each implementation must cast the obj
to the Self
type manually, just like in C# documentation:
// C# code below, kind of a better Java
// 1. Self is Person6 class
public class Person6
{
// some person fields...
private string idNumber;
// 2. Taking Object, not Person6 as an argument
public override bool Equals(Object obj)
{
// 3. Casting to Self
Person6 personObj = obj as Person6;
if (personObj == null) {
// 4. Not Person6
return false;
} else {
// 5. Got another Person6. Comparing all fields manually.
return idNumber.Equals(personObj.idNumber);
}
}
}
Object safe animals
We must introduce a new version of the Animal
trait - an object safe one (it means it can be used with the dyn
keyword)!
pub trait Animal: Debug {
fn dyn_eq(&self, other: &dyn Animal) -> bool;
fn as_any(&self) -> &dyn Any;
fn species(&self) -> &'static str;
fn react(&mut self, command: &str) -> String;
}
This differs from the original Animal
trait:
- there is no
Eq
trait, so we have no method with aSelf
type parameter, - we've introduced
dyn_eq
, taking any animal, - the
as_any
returns us an instance of theAny
trait - which allows us to perform downcasting.
As you'll notice, the equality implementation will be similar to the one in the C# example above. Let's implement the trait for parrot:
#[derive(PartialEq, Eq, Debug)]
pub struct Parrot {
// the same...
}
impl Animal for Parrot {
fn dyn_eq(&self, other: &dyn Animal) -> bool {
// 1. Downcasting, to check whether the other animal is a parrot
match other.as_any().downcast_ref::<Self>() {
// 2. It's a parrot, let's use a comparison from Eq
Some(o) => self == o,
// 3. Not a parrot
None => false,
}
}
fn as_any(&self) -> &dyn Any {
self
}
fn species(&self) -> &'static str {
// the same...
}
fn react(&mut self, command: &str) -> String {
// the same...
}
}
It'll work, however, it has a major drawback - we've lost the simplicity of just deriving an Eq
and calling it a day. If we had more traits with Self
to support, like Hash
and Clone
, the Parrot
implementation would become cluttered.
What is worse, each new Animal
must implement this routine:
#[derive(PartialEq, Eq, Debug)]
pub struct Monkey {
// the same...
}
impl Animal for Monkey {
// 1. Copy and paste from Parrot
fn dyn_eq(&self, other: &dyn Animal) -> bool {
match other.as_any().downcast_ref::<Self>() {
Some(o) => self == o,
None => false,
}
}
// 2. Copy and paste from Parrot
fn as_any(&self) -> &dyn Any {
self
}
fn species(&self) -> &'static str {
// the same...
}
fn react(&mut self, command: &str) -> String {
// the same...
}
}
Zoo
We can finally implement our zoo! Diversity requirements are satisfied with dyn_eq
.
pub struct Zoo {
// 1. This time it compiles
animals: Vec<Box<dyn Animal>>,
}
pub struct InvalidAnimalError {
pub animal: Box<dyn Animal>,
}
impl Zoo {
pub fn new() -> Self {
Zoo {
animals: Vec::new(),
}
}
pub fn add(&mut self, animal: Box<dyn Animal>) -> Result<(), InvalidAnimalError> {
// 2. Notice, how dyn_eq is used
let already_exists = self.animals.iter().any(|a| a.dyn_eq(animal.as_ref()));
if already_exists {
// 3. Tigers will have a feast
Err(InvalidAnimalError { animal })
} else {
// 4. Go to a cage for the rest of your life!
self.animals.push(animal);
Ok(())
}
}
}
The dual-trait pattern
There we have a collision between an implementer of the trait and a user of the trait.
-
Animal
developer wants to use cool#[derive(Eq)]
features and strong static typing. - The
Zoo
developer wants to have a more OOP-like style approach, supportingdyn
and requiring tedious downcasting from animals.
The solution is to have... two traits! And we've already written them in this article!
First, let's make the easy to implement variant of the Animal
trait:
/// This trait facilitates the implementation of the [`Animal`] trait.
pub trait AnimalCore: Eq + Debug + 'static {
fn species(&self) -> &'static str;
fn react(&mut self, command: &str) -> String;
}
Notice, that it's not object safe, because we use Eq
. Another change is the Core
suffix in the trait's name. However, it's easy to implement, just like in the "Let's do it!" section.
Let's make the easy to use Animal
trait:
/// The [`AnimalCore`] trait is *the recommended way to implement* this trait.
pub trait Animal: Debug {
fn dyn_eq(&self, other: &dyn Animal) -> bool;
fn as_any(&self) -> &dyn Any;
fn species(&self) -> &'static str;
fn react(&mut self, command: &str) -> String;
}
It's the same trait as in the "Object safe animals" section. Of course, this is object safe.
And now the final trick 🎩🪄 - with Rust's blanket implementation the language will automatically implement an object safe variant for each AnimalCore
// 1. For each AnimalCore, we'll implement Animal
impl<T: AnimalCore> Animal for T {
fn dyn_eq(&self, other: &dyn Animal) -> bool {
// 2. The OOP downcasting hell
match other.as_any().downcast_ref::<Self>() {
Some(o) => self == o,
None => false,
}
}
fn as_any(&self) -> &dyn Any {
self
}
fn species(&self) -> &'static str {
// 3. Delegate to the original implementation
AnimalCore::species(self)
}
fn react(&mut self, command: &str) -> String {
// 4. Delegate to the original implementation
AnimalCore::react(self, command)
}
}
So... that's it! With that, you just implement the AnimalCore
trait with idiomatic #[derive(Eq)]
, and Rust will automatically provide you with an object-safe variant, which can be put in a zoo.
As an exercise, you can rewrite the zoo using HashSet
, so the addition of an animal will take a constant time. This will require you to support hashing with the dyn_hash
function. You can try the exercise in 🏀 the Rust playground!
One more thing...
As a last whiff, we should make it possible to compare the &dyn Animal
with the ==
operator.
// 1. Notice the dyn keyword
impl PartialEq for dyn Animal {
fn eq(&self, other: &Self) -> bool {
// 2. Delegating to the polymorphic dyn_eq
self.dyn_eq(other)
}
}
impl Eq for dyn Animal {}
So now you can use the ==
operator in the add
function:
let already_exists = self.animals.iter().any(|a| a == &animal);
Or even better, use the contains
algorithm from the standard library compatible with the Eq
trait:
let already_exists = self.animals.contains(&animal);
Drawbacks
The dual-trait pattern makes maintenance of the trait itself more complex. Fortunately, this complexity does not impact implementers or users of the pattern.
The performance shouldn't be impaired, except for the polymorphic calls themselves, rest of the code will be probably inlined.
Using the dyn
keyword for file interfaces and I/O is usually a good choice. However, if you need to support complex traits like Eq
or Hash
, then you should first try to use enum
and generics.
As a last resort, go for the dual-trait pattern to simulate classic OOP.
In the wild
Animals somehow escaped into the wild... 🐾
It turns out that the dual-trait pattern is used in a production Rust software.
Apache DataFusion is an SQL query engine developed in Rust, used by Apple and InfluxDB.
I've invented 😎 this dual-trait pattern for the purposes of the logical planner, as seen in this merged PR. The problem was that the nodes in the plan (filter, select, etc.) had to support at the same time:
- equality
Eq
and hashingHash
, - custom nodes with
dyn
keyword.
This prompted the use of the dual-trait pattern. Therefore there are two traits:
-
UserDefinedLogicalNodeCore
for an implementer, -
UserDefinedLogicalNode
object-safe variant for a user.
There is a neat example, of how a third party project belonging to the Linux Foundation, is implementing UserDefinedLogicalNodeCore
: MetricObserver
in delta-rs. The developer had to use only #[derive(Debug, Hash, Eq, PartialEq)]
to get dyn_eq
and dyn_hash
implemented.
Conclusions
Usually, your Rust types should be modeled with struct
and enum
from functional paradigm. However, when there is a need for OOP classes, just like in the logical planner example, then the dual-trait pattern should resolve this 🎯 object-functional impedance.
If you're into 🦀 Rust, then you might enjoy my other dev.to article Array duality in Go and Rust, comparing various ways to allocate an array in Rust, like Vec
or Cow
.
Comments 💬 and questions are welcome! Don't forget to check out 🏀 the Rust playground!
Top comments (2)
Perfect example of philosophy vs reality check. Models that require few straightforward definitions in any language supporting OOP became obfuscated monstrosity in Rust.
I love Rust, but when I must deal with any complex models I just use Raku. Not trying to hammer a nail with a vase.
Good article BTW, I finally learned about
Eq
traits.Hi! I'm glad that you've enjoyed the article.
I was mainly doing Python at work, so kinda I understand the joy (and
AttributeError
) in scripting languages like Raku.This reality check isn't actually about the Rust itself, but about writing a nonidiomatic code in a given programming language.
The idiomatic way to express the logical plan with a custom user's node is to use
enum
and generics (just like in Rust's inspiration - Haskell):It doesn't require any tricks. Rust was strongly inspired by Haskell, so the strongly static-typed solutions like
enum
are preferred todyn
.As a context, I was fixing the
Eq
implementation in the Apache DataFusion, and there already was thisdyn
used... The maintainers seem to be influenced by Java and Go...