DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Magnus StrΓ₯le
Magnus StrΓ₯le

Posted on • Updated on

Rust Trait objects in a vector - non-trivial...

One (of many) challenges with learning Rust is to un-learn a lot of object-oriented thinking. My recent struggle with a refactoring to put trait objects into a Vec made this painfully obvious.

If you don't want to read about the road to enlightenment but skip straight to the answer, scroll down to the final code snippet now.

Basically I had a Vec<Sphere> and needed to generalize the Vec to take a Trait Shape instead. I e something like Vec<Shape>. My first OO-based mistake is to assume that Traits behave like interfaces. This mistake immediately makes the compiler puke all over the screen.

// Original code
struct Sphere {
    transformation: Matrix, // FYI - implements Copy trait
    material: Material
}

fn do_stuff(objects: Vec<Sphere>) {
}

// New code - won't compile
trait Shape {
    fn material(&self) -> &Material;
    fn transformation(&self) -> Matrix;
}

struct Sphere {
    transformation: Matrix,
    material: Material
}

impl Shape for Sphere {
    fn material(&self) -> &Material {
        &self.material
    }
    fn transformation(&self) -> Matrix {
        self.transformation
    }
}

fn do_stuff(objects: Vec<dyn Shape>) {
}
Enter fullscreen mode Exit fullscreen mode

The compiler is quite helpful here

error[E0277]: the size for values of type `(dyn Shape + 'static)` cannot be known at compilation time
  --> src\lib.rs:45:1
   |
45 | / fn do_stuff(objects: Vec<dyn Shape>) {
46 | | }
   | |_^ doesn't have a size known at compile-time
   |
   = help: the trait `std::marker::Sized` is not implemented for `(dyn Shape + 'static)`
   = note: to learn more, visit <https://doc.rust-lang.org/book/ch19-04-advanced-types.html#dynamically-sized-types-and-the-sized-trait>
   = note: required by `std::vec::Vec`
Enter fullscreen mode Exit fullscreen mode

So far quite obvious - Shape is a trait that can be implemented by any number of types with vastly differing memory footprints and this is not ok for Rust. The solution is to Box your Trait objects, which puts your Trait object on the heap and lets you work with Box like a regular, sized type. (I will experiment a bit with the Sized trait - probably subject of a future blog post, but let me walk down this path first) I e something like this:

fn do_stuff(objects: Vec<Box<dyn Shape>>) {
}
Enter fullscreen mode Exit fullscreen mode

Yup - it compiles. All good then? Not really since here comes the interesting parts. I want to check if one Shape is equal to another Shape, sounds trivial right? We have PartialEq and all should be good. Let's give it a first try

// Will not compile
fn do_stuff(objects: Vec<Box<dyn Shape>>) {
    let obj1 = &objects[0];
    let obj2 = &objects[1];
    if obj1 == obj2 { println!("Equal"); }
}
Enter fullscreen mode Exit fullscreen mode

The helpful compiler error says

error[E0369]: binary operation `==` cannot be applied to type `&std::boxed::Box<dyn Shape>`
  --> src\lib.rs:48:13
   |
48 |     if obj1 == obj2 { println!("Equal"); }
   |        ---- ^^ ---- &std::boxed::Box<dyn Shape>
   |        |
   |        &std::boxed::Box<dyn Shape>
   |
   = note: an implementation of `std::cmp::PartialEq` might be missing for `&std::boxed::Box<dyn Shape>`
Enter fullscreen mode Exit fullscreen mode

So we follow the directions and try to implement PartialEq for Box

impl PartialEq for Box<dyn Shape> {
    fn eq(&self, other: &Box<dyn Shape>) -> bool {
        // Double deref to get to the Shape, ref to avoid E0277 - 
        // "...size for values...unknown at compile time"
        let s = &**self; 
        let o = &**other;
        s == o
    }
}

// Also tweaked the trait definition to ensure that we have PartialEq trait on Shape
trait Shape: PartialEq {
    fn material(&self) -> &Material;
    fn transformation(&self) -> Matrix;
}

Enter fullscreen mode Exit fullscreen mode

Compiler says no

error[E0038]: the trait `Shape` cannot be made into an object
  --> src\lib.rs:47:30
   |
47 |     fn eq(&self, other: &Box<dyn Shape>) -> bool {
   |                              ^^^^^^^^^ the trait `Shape` cannot be made into an object
   |
   = note: the trait cannot use `Self` as a type parameter in the supertraits or where-clauses
Enter fullscreen mode Exit fullscreen mode

Time to think... I cannot get to the real implementation of PartialEq unless I work with the actual type for both self and other. I can delegate the work in the Box...eq function to the trait. This means that when the call is made to the trait method we know what the actual type is for &self (see box_eq below). The final step is figuring out the actual type of "other" and if it is the same as &self, convert other to that type and call Eq.

This took quite a lot of experimenting, reading of Rust docs and Googling. This StackOverflow question was what finally got me on the right track. Putting all the pieces together I ended up with this

#[derive(PartialEq)]
struct Sphere {
    transformation: Matrix,
    material: Material
}

trait Shape: Any {
    fn box_eq(&self, other: &dyn Any) -> bool;
    fn as_any(&self) -> &dyn Any;    
    fn material(&self) -> &Material;
    fn transformation(&self) -> Matrix;
}

impl Shape for Sphere {
    fn as_any(&self) -> &dyn Any {
        self
    }
    fn box_eq(&self, other: &dyn Any) -> bool {
        other.downcast_ref::<Self>().map_or(false, |a| self == a)
    }
    fn material(&self) -> &Material {
        &self.material
    }
    fn transformation(&self) -> Matrix {
        self.transformation
    }
}

impl PartialEq for Box<dyn Shape> {
    fn eq(&self, other: &Box<dyn Shape>) -> bool {
        self.box_eq(other.as_any())
    }
}

fn do_stuff(objects: Vec<Box<dyn Shape>>) {
    let obj1 = &objects[0];
    let obj2 = &objects[1];
    if obj1 == obj2 { println!("Equal"); }
}

Enter fullscreen mode Exit fullscreen mode

The important bits are:

  • box_eq - delegates to the Shape-implementing type which gives us the actual type of self.
  • Any - it is the closest thing to reflection there is in Rust. It allows us to do runtime type-casting with downcast_ref. This gives us the actual type and allows call to Eq.
  • as_any() - gives us "other" as Any in box_eq.

Note that the implementations for as_any and box_eq will be textually identical for any Shape implementation. Could make sense to turn it into a macro.

I hope this text might save you some time if you're faced with a similar problem.

Top comments (1)

Collapse
 
wpwoodjr profile image
Bill Wood

Try this, I think its much simpler: play.rust-lang.org/?version=stable...

Timeless DEV post...

Git Concepts I Wish I Knew Years Ago

The most used technology by developers is not Javascript.

It's not Python or HTML.

It hardly even gets mentioned in interviews or listed as a pre-requisite for jobs.

I'm talking about Git and version control of course.

One does not simply learn git