Polymorphism with Rust, part 3: Trait objects

This is the fourth post in a four part series on polymorphism with Rust. In this post I discuss how trait objects can be used to implement polymorphism in Rust.

What are trait objects?

Trait objects are a special type in Rust represented by dyn Trait, that can represent any type that implements a given trait. Unlike generics which do compile time monomorphization, trait objects use dynamic dispatch at run time. Meaning we do not need to know the underlying type at compile time. This gives trait objects a lot of flexibility but also means that they are an unsized type, because the compiler does not know the size of the underlying type at compile time. As we will see this comes with certain limitations.

The most notable one is that we cannot directly refer to dyn Trait in our function or struct definitions, some level of indirection is required. In our examples we will focus on &dyn Trait and Box<dyn Trait>, but this also works with other pointer types in Rust like Rc and Arc.

Aside
Wondering what the difference between &dyn Trait and Box<dyn Trait> is? They’re both just pointers to something that implements a trait right? The fundamental difference is that Box<dyn Trait> takes ownership of the underlying instance while &dyn Trait simply borrows it.

Polymorphic functions

Updating our get_ray_color function to use dyn Trait is quite straight forward:

fn get_ray_color(ray: &Ray, object: &dyn Hittable) -> Color {
    // ...
}

It will then accept either a reference to a Sphere or a Triangle much like the generic version:

let ray = Ray {
    // ...
};
let sphere = Sphere {
    // ...
};
let triangle = Triangle {
    // ...
};

let _ = get_ray_color(&ray, &sphere);
let _ = get_ray_color(&ray, &triangle);

However this does not really offer any benefits over the generic version we saw in part 2, so in most cases I would recommend sticking with that for this use case. The real benefit of dyn Trait we will see in the next section.

Polymorphic lists

Unlike generics, trait objects are really the solution we need to make our scene accept any type that implements a trait. Although the size of a trait object is not known at compile time the size of a pointer to it is and so we can create a vector of such pointers. In the case of our Scene struct we want it to take ownership of the objects passed into it so we will use Box<dyn Hittable>.

struct Scene {
    pub objects: Vec<Box<dyn Hittable>>,
}

We can now happily add spheres, triangles, and any other type that implements Hittable to our scene:

fn make_scene() -> Scene {
    Scene {
        objects: vec![
            Box::new(Sphere {
                // ...
            }),
            Box::new(Triangle {
                // ...
            }),
        ],
    }
}

Note that typically you would not want to box elements before adding them to a vector as this creates a double indirection, however in this case it is necessary because of the nature of trait objects.

Passing trait objects to generics

You might be wondering about passing a trait object to our generic get_ray_color function from earlier in this series.

fn get_ray_color<H: Hittable>(ray: &Ray, object: &H) -> Color {
    let does_hit = object.does_hit(ray);
    // ...
}

The dyn Hittable type does implement Hittable, that’s kind of its whole thing. There is also an automatic implementation provided for &dyn Hittable. So this should be straight forward right? Let’s try the following:

let sphere: &dyn Hittable = &Sphere {
    center: (0.0, 0.0, 0.0),
    radius: 0.0,
};

let _ = get_ray_color(&ray, sphere);

This fails to compile with the error ‘the size for values of type dyn Hittable cannot be known at compilation time’. This is because there is in fact an implicit Sized constraint applied to all generic types and dyn Hittable is unsized. So in order for this to work we need to relax this constraint, this can be done with the rather unique ?Sized syntax as follows:

fn get_ray_color<H: Hittable + ?Sized>(ray: &Ray, object: &H) -> Color {
    let does_hit = object.does_hit(ray);
    // ...
}

The previous example will now work as expected.

impl Trait for Box<dyn Trait>>

What if our function takes ownership of our trait object? Something like:

fn get_ray_color_own<T: Hittable>(ray: &Ray, object: T) -> Color {
    let does_hit = object.does_hit(ray);
    // ...
}

As we have seen before the size of our trait object is not known at compile time so we cannot pass in a raw trait object, this leaves us with Box<dyn Hittable>. However a Box<dyn Trait> does not implement the trait by default. So we need to first implement Hittable for Box<dyn Hittable>. Naively we might do the following:

impl Hittable for Box<dyn Hittable> {
    fn does_hit(&self, ray: &Ray) -> bool {
        self.does_hit(ray)
    }
}

However this causes an infinite recursion because when we call does_hit on self, which is a Box<dyn Hittable>, it calls the method we are currently defining. To get around this we need to explicitly call the does_hit method defined on dyn Hittable, like so:

impl Hittable for Box<dyn Hittable> {
    fn does_hit(&self, ray: &Ray) -> bool {
        <dyn Hittable>::does_hit(self, ray)
    }
}

Aside
There is a slightly cleaner way of achieving this by doubly dereferencing self, but personally I think using the explicit version above is more readable, especially to someone who hasn’t seen this before.

impl Hittable for Box<dyn Hittable> {
    fn does_hit(&self, ray: &Ray) -> bool {
        (**self).does_hit(ray)
    }
}

Using the get_ray_color_own function we defined above we can now do the following:

let sphere: Box<dyn Hittable> = Box::new(Sphere {
    center: (0.0, 0.0, 0.0),
    radius: 0.0,
});

let _ = get_ray_color_own(&ray, sphere);

If you want more information on implementing a trait for Box<dyn Trait> I would recommend this article by QuineDot, as there are a few more considerations not discussed here.

Advanced use cases

Multiple traits

What about our case of requiring the object to be clone-able and hittable? Imagine the following:

fn get_ray_color<H: Hittable + Clone + ?Sized>(ray: &Ray, object: &H) -> Color {
    let does_hit = object.does_hit(ray);
    let clone = (*object).clone();
    // ...
}

Can we just pass in a reference to our trait object?

let sphere: &dyn Hittable = &Sphere {
    // ...
};

let _ = get_ray_color(&ray, sphere);

Unsurprisingly this does not work, the compiler telling us that dyn Hittable does not meet the required Clone constraint on our function. Recall that Sphere does implement clone though, so can we simply supply multiple constraints to our trait object like we can with generics?

let sphere: &(dyn Hittable + Clone) = &Sphere {
    // ...
};

let _ = get_ray_color(&ray, sphere);

Unfortunately the compiler says no, stating ‘only auto traits can be used as additional traits in a trait object’. As this message suggests there are certain traits, called auto traits, that this will work with such as Send and Sync, but not all traits.

Another thing we could try is defining a new trait that combines Hittable and Clone as super traits?

trait HittableClone: Hittable + Clone {}

let sphere: &dyn HittableClone = &Sphere {
    // ...
};

let _ = get_ray_color(&ray, sphere);

Once again the compiler says no, stating that ‘the trait HittableClone is not dyn compatible’. Dyn compatibility is probably a whole topic on it’s own, but in this case the error arises because Clone requires the Sized trait. Since dyn Trait is unsized it can never be clone-able. However this techniques would work for traits that are dyn compatible.

So is this just not possible then? Well not quite. As we have seen before Box<dyn Trait> is sized so this means that so long as the underlying type is clone-able we can clone Box<dyn Trait>, though the implementation is not straight forward.

We start by creating a new trait that defines this behaviour, our Hittable trait will take this as a super trait.

trait DynHittableClone {
    fn dyn_clone<'a>(&self) -> Box<dyn Hittable + 'a>
    where
        Self: 'a;
}

trait Hittable: DynHittableClone {
    fn does_hit(&self, ray: &Ray) -> bool;
}

Note the lifetime 'a, this removes the need for adding an explicit 'static constraint to Hittable.

Next we will create a blanket implementation for any type that is hittable and clone-able, this covers our Sphere and Triangle types.

impl<T: Hittable + Clone> DynHittableClone for T {
    fn dyn_clone<'a>(&self) -> Box<dyn Hittable + 'a>
    where
        Self: 'a,
    {
        Box::new(self.clone())
    }
}

Finally we implement Clone for Box<dyn Hittable>, note that we have to be very specific about which version of dyn_clone we call to avoid an infinite recursion.

impl Clone for Box<dyn Hittable> {
    fn clone(&self) -> Self {
        <dyn Hittable as DynHittableClone>::dyn_clone(self)
    }
}

Now we can at last pass a trait object into our function that needs to clone it:

let sphere: Box<dyn Hittable> = Box::new(Sphere {
    // ...
});

let _ = get_ray_color(&ray, &sphere);

If this seems painful that’s because it is, fortunately there is a crate that can simplify this: dyn-clone.

Underlying type

What about getting back the underlying type of a trait object? Well as we saw in the previous article on generics this is possible via the builtin Any trait. The Any trait is designed to allow for dynamic typing in Rust, it specifically does this via the dyn Any type, which will allow you to fallibly downcast it to any type.

So if we want to apply special handling for a specific underlying type of dyn Hittable we first need to upcast it to dyn Any then try to downcast it to the underlying type we are looking for. As of Rust 1.86 trait upcasting allows us to upcast any trait to a super trait, which should make this quite straight forward. For this to work we need to explicitly list Any as a super trait of Hittable:

use std::any::Any;

trait Hittable: Any {
    // ...
}

Then the following will work:

let some_hittable: &dyn Hittable = &Sphere {
    //...
};

let any_hittable = some_hittable as &dyn Any; // Upcast
match any_hittable.downcast_ref::<Triangle>() { // Downcast
    Some(triangle) => todo!(), // Do something special here
    None => (),
}

For even more information on dyn Trait I would recommend ‘A tour of dyn Trait’ in this book by QuineDot.

Nested type

Finally, what if we want a Scene instance to contain another scene? So long as Scene implements Hittable this is quite straight forward:

fn make_scene() -> Scene {
    Scene {
        objects: vec![
            // ...
            Box::new(Scene {
                objects: Vec::new(),
            }),
        ],
    }
}

Advantages & disadvantages

The main advantage of dyn Trait is that it can solve problems that the other methods we have seen here just can’t. Such as creating a list of elements that all implement a trait without knowing the complete list of types that implement that trait, like we saw with our Scene struct.

Unfortunately it has several downsides, most notably in my opinion is that it has restrictions that can be difficult to work with as we saw with implementing Clone on Box<dyn Trait>. I hope this will improve in the future as Rust continues to add more features.

The dynamic dispatch of functions implemented by dyn Trait also has some performance overhead as the function needs to be looked up from a table at run time. This also stops the compiler from doing certain optimizations like inlining a function. These are minor but not nothing.

It’s also worth noting that use of Box<dyn Trait> requires a heap allocation that could potentially be avoided.

Summary

To summarise this series there are several ways of doing polymorphism in Rust to achieve inheritance-like behaviour. Personally I prefer using enums and generics, but trait objects are good to know about for when you need them.

Hopefully you learnt something new!