1

I have the following Rust Playground permalink

which is from my following Ray Tracing in a Weekend.

At the point of implementing materials I chose to create a trait Material.

My thinking was that objects in the world would have a material attribute and upon a ray Hit the Hit can look at the object and ask for the material on demand. This is working fine for my Normal trait which follows a similar thinking. I implemented this all with dynamic dispatch although I think I grasp enough to have done it statically with trait bounds as well.

In the linked code, you see line 13 I'm requesting those which implement Normal have a method which will return a Material. This then suggests that now Normal is no longer eligible to be a trait object error[E0038]: the traitNormalcannot be made into an object

If I understand correctly from such questions as this it seems that since generics are monomorphized the Rust runtime could no feasibly leak up the appropriate method for material given there could ostensibly be one a different one for each type implementing Normal? This doesn't quite click with me as it seems that, put in the same position as the Rust runtime, I would be able to look at the Normal implementer I have in hand at a moment, say Sphere and say I will look in Sphere's vtable. Could you explain where I am wrong here?

From there, I tried to simply fight with the compiler and went to static dispatch. lines 17-21

struct Hit<'a> {
    point: Vec3,
    distance: f32,
    object: &'a dyn Normal,
}

became

struct Hit<'a, T: Normal> {
    point: Vec3,
    distance: f32,
    object: &'a T,
}

and from there I am left trying to plug hole after hole with what seems like no end in sight.

What design choices can I do differently in addition to learning what is fundamentally wrong with my current understanding?

CircArgs
  • 570
  • 6
  • 16

2 Answers2

3

put in the same position as the Rust runtime, I would be able to look at the Normal implementer I have in hand at a moment, say Sphere and say I will look in Sphere's vtable

Except there's nothing the Rust runtime can do here.

In fact, Rust don't have the runtime in the sense of "something executing the code". Rust runtime only performs the setup and cleanup task, but as long as the control flow is somewhere inside your main function, you're on your own (and in no_std environments, even this won't exist). So, every dynamic dispatch must be baked into the type, by placing the vtable pointer next to data pointer - see this documentation for a bit more details.

But, since the generics are, as you've already stated, monomorphized, there won't be one fn material for every implementation of Normal: there will be an unknown, potentially infinite family of these functions, one for each type implementing Material. Note the "unknown, potentially infinite" bit: since you can't leak private parts, if the Normal trait is public, the Material trait must be public too - and then nothing will prevent the user of your code to add another implementation of Material, not known to your code, which simply cannot be baked into the vtable of dyn Normal.

That's why generic methods are not object-safe. They can't fit into the trait object vtable, since we don't know them all when trait object is created.

Cerberus
  • 8,879
  • 1
  • 25
  • 40
  • thanks for the clear explanation. I definitely see now in the case where Normal is public how it'd be impossible to bake the vtable in as it can't be definitively determined, but is there not a case where the number of implementations would be fixed at compile time and so the lookups would be fixed? Does the compiler still keep this to guard against the case you mention? – CircArgs Apr 25 '20 at 05:33
  • AFAIK, in theory this can be special-cased, there simply isn't any work in this direction. – Cerberus Apr 25 '20 at 06:09
  • @CircArgs If the number of implementations is fixed, you should usually be using an `enum` instead, which works fine here. – trent Apr 25 '20 at 11:44
  • @trentcl the number of implementations is actually intended to be fixed at least for my toying here. In other words, I intend to only have a fixed number of `Materials`. Are you saying have an Enum of the materials and them impl the Enum? If so, how would different materials have different behavior? – CircArgs Apr 26 '20 at 16:33
  • @CircArgs Yeah, something like [this](https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=34ddca1fd1c7294188ac374f42a6c71d). You can further break down the `match`es and factor the arms out into separate functions if having one big one is too much. The point of my comment is that if you're writing code where the compiler could "bake in" a vtable, you're basically writing an `enum` with extra steps. – trent Apr 26 '20 at 18:09
2

I may be missing something, but I think you could - at least from what I've seen - follow your path further.

I think you could change this function:

fn material<T: Material>(&self) -> T;

As it stands, it says: Any Normal offers a function material where the caller can specify a Material that the function will return.

But (I think) you want to state is: Any Normal has a material that can be requested by the caller. But the caller has no right to dictate any Material that will be returned. To translate this to code, you could state:

fn material(&self) -> &dyn Material;

This tells that material returns a Material (as a trait object).

Then, Sphere could implement Normal:

impl<'a> Normal for Sphere<'a> {
    fn normal(&self, point: &Vec3) -> Ray {
        Ray::new(point, &(point - &self.center))
    }
    fn material(&self) -> &dyn Material {
        self.material
    }
}

Link to playground.

phimuemue
  • 34,669
  • 9
  • 84
  • 115
  • In this case, it might be better to use an associated type (which may well be the `dyn Material`, if some implementation requires this). – Cerberus Apr 25 '20 at 13:58
  • This technique worked in my attempt of it and coupled with @Cerberus answer above I understand why, but is there some advantage to this as opposed to using an associated type as he mentions here? – CircArgs Apr 26 '20 at 16:28
  • It's a trade-off: This technique allows you to invent new `Material`s without having to recompile `Sphere` et al. - Generics tend to percolate through the codebase (that's not bad per se - it is, as said, a trade-off). – phimuemue Apr 26 '20 at 18:36