Polymorphism with Rust, part 2: Generics
08 May 2025This is the third post in a four part series on polymorphism with Rust. In this post I discuss how generics can be used to implement polymorphism in Rust.
Contents
- What are generics?
- Polymorphic functions
- Polymorphic lists
- Advanced use cases
- Advantages & disadvantages
impl Trait
This series
What are generics?
Generics allow us to generalize a function for multiple types, and we are able to specify certain traits that a generic type must implement. The syntax for this is as follows:
fn do_something<T: Trait>(x: T) {}
Aside
There is an alternative equivalent syntax using a where clause:fn do_something<T>(x: T) where T: Trait {}
This is achieved by compile time monomorphization. In other words the compiler creates a different version of the function for each concrete type it is called with. You can read more about generics here.
Polymorphic functions
This is perfect for our get_ray_color
function as we can see here:
fn get_ray_color<H: Hittable>(ray: &Ray, object: &H) -> Color {
let does_hit = object.does_hit(ray);
// ...
}
When we specify H
as <H: Hittable>
this implies that any type that is
substituted for H
must implement Hittable
. Hence we can pass in either a
Sphere
or a Triangle
to this function.
let ray = Ray {
// ...
};
let sphere = Sphere {
// ...
};
let triangle = Triangle {
// ...
};
let _ = get_ray_color(&ray, &sphere);
let _ = get_ray_color(&ray, &triangle);
Since we implemented Hittable
for our Object
enum (in part 1 of this series) we could even pass that
in to our function. Thus it is not necessary to pick between the enum pattern I
described previously and generics, we can use them both at the same time.
Polymorphic lists
What about our Scene
struct? Well generics can be applied to structs as
follows:
struct Scene<H: Hittable> {
pub objects: Vec<H>,
}
Aside
Vec<T>
is in fact already a generic struct.
Then you might think we could do the following:
let _ = Scene {
objects: vec![
Sphere {
// ...
},
// Adding a Triangle is a compiler error
Triangle {
// ...
},
],
};
However this fails with a compiler error of ‘mismatched types’ when we try to
add the Triangle
.
It may not be immediately obvious why this is a problem so let’s dig a little
deeper. When we create an instance of Scene<H>
the compiler assigns a concrete
value to H
. In this case the value of H
is inferred from the first type that
we add to our vector, so H = Sphere
. When we try to add a second element the
compiler is expecting to find another Sphere
but instead it finds a Triangle
and raises the ‘mismatched types’ error.
In other words our Scene<H>
can have a concrete value of Scene<Sphere>
(and
only contain spheres) or Scene<Triangle>
(and only contain triangles), it
cannot contain both spheres and triangles.
More generally speaking, this is because Vec
needs to know the size and type
of the values it will be storing at compile time and so it can only store values
of a single type that has a known size. This is not unique to Vec
and applies
to most other container types in Rust.
Aside
Enums are able to get around this because they are in fact a sized type. They simply take the size of their largest variant.
So generics will not work for our Scene
struct, at least not on their own. As
we will see in part 3 this
is possible with trait objects and we could provide a trait object as the
concrete type, such as Scene<Box<dyn Trait>>
.
Advanced use cases
Multiple traits
What about our case from before where get_ray_color
also requires our input
type to implement Clone
. This functionality is built into generics so we can
simply do this:
fn get_ray_color<H: Hittable + Clone>(ray: &Ray, object: &H) -> Color {
// ...
}
This generalizes to any additional traits.
Underlying type
As for accessing the underlying type… well that’s a little more complicated. I
will show you what works here but I’m not going to explain it in detail as I
will be going over this in part 3 when we discuss trait objects, and it
should make more sense once we understand what dyn Trait
is.
Imagine we need to do something special to triangles at the start of our
get_ray_color
function. This can be achieved by using the Any
trait from the
standard library as follows:
use std::any::Any;
fn get_ray_color<H: Hittable + 'static>(ray: &Ray, object: &H) -> Color {
// Upcast H to dyn Any
let my_any: &dyn Any = object;
// Try to downcast dyn Any to Triangle
if let Option::Some(_triangle) = my_any.downcast_ref::<Triangle>() {
todo!(); // Do something special here
}
// ...
}
Note that for this to work we need to impose an additional constraint of
'static
on H
. This is a constraint of upcasting to the Any
trait.
Nested type
As we saw earlier creating a version of our Scene
struct that can store
multiple different types of objects using generics is not possible so this has
limited usefulness. However it is possible to create a Scene
that contains
other scenes so long as they all have the same generic type. This can be done as
follows:
pub fn make_scene() -> Scene<Scene<Triangle>> {
Scene {
objects: vec![
Scene {
objects: vec![Triangle {
point1: (0.0, 0.0, 0.0),
point2: (0.0, 0.0, 0.0),
point3: (0.0, 0.0, 0.0),
}],
},
// ...
],
}
}
Advantages & disadvantages
I think the main advantage of generics is that they are very flexible and you can define complex type constraints without a lot of effort. They really encapsulate the spirit if polymorphism in the sense of “I don’t care what type this is, just so long as it does X”.
Generics also don’t incur any performance costs.
The main disadvantage of course is that it does not work in all situations, like
we saw with our Scene
type.
Another minor disadvantage is that they can increase your binary size. This is because the Rust compiler monomorphizes generics at compile time. In most cases though this is probably not a concern.
impl Trait
impl Trait
is another feature of Rust that is very similar to generics, also
using compile time monomorphization. It can be used to represent a type that
implements a given trait much like generics can. It can only be used for
function parameters and return types however. Here is an example with our
get_ray_color
function:
fn get_ray_color(ray: &Ray, object: &impl Hittable) -> Color {
// ...
}
The concrete type is inferred by the compiler, and unlike generics you cannot use the turbofish syntax to specify a particular type.
The main use case of impl Trait
is to return types that cannot be named like
closures (impl Fn(T)
) and iterators (impl Iterator<Item = T>
).
Next post: Part 3: Trait objects