DEV Community

Cover image for Make Rust Object Oriented with the dual-trait pattern
Michał Słapek
Michał Słapek

Posted on

Make Rust Object Oriented with the dual-trait pattern

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The zoo doesn't compile!

The zoo is just an array of animals:

pub struct Zoo {
    animals: Vec<Box<dyn Animal>>,
}
Enter fullscreen mode Exit fullscreen mode

However, it gives a compiler error 🛑:

the trait `Animal` cannot be made into an object
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Object safe animals

A 3D rendering showing parrots and monkeys playing with toys, like cylinders and cubes

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;
}
Enter fullscreen mode Exit fullscreen mode

This differs from the original Animal trait:

  • there is no Eq trait, so we have no method with a Self type parameter,
  • we've introduced dyn_eq, taking any animal,
  • the as_any returns us an instance of the Any 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...
    }
}
Enter fullscreen mode Exit fullscreen mode

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...
    }
}
Enter fullscreen mode Exit fullscreen mode

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(())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The dual-trait pattern

Parrot with a magic wand, creating new objects, like cylinders and cubes

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, supporting dyn 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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

So now you can use the == operator in the add function:

let already_exists = self.animals.iter().any(|a| a == &animal);
Enter fullscreen mode Exit fullscreen mode

Or even better, use the contains algorithm from the standard library compatible with the Eq trait:

let already_exists = self.animals.contains(&animal);
Enter fullscreen mode Exit fullscreen mode

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 escaping from the zoo using TNT

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 hashing Hash,
  • 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)

Collapse
 
bbkr profile image
Paweł bbkr Pabian

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.

Collapse
 
mslapek profile image
Michał Słapek

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):

#[derive(PartialEq, Eq)]
enum LogicalPlan<U: Eq> {
    Add(Box<LogicalPlan<U>>, Box<LogicalPlan<U>>),
    Literal(String),
    UserNode(U),
}
Enter fullscreen mode Exit fullscreen mode

It doesn't require any tricks. Rust was strongly inspired by Haskell, so the strongly static-typed solutions like enum are preferred to dyn.

As a context, I was fixing the Eq implementation in the Apache DataFusion, and there already was this dyn used... The maintainers seem to be influenced by Java and Go...