How to branch on generic at compile time?

Hello fellow Rustaceans! :slight_smile:

I don't know how to formulate the question so here is my problem:

In a simple Entity Component System, I want to get a component calling its type.

let pos = &mut ecs.component<Position>(entity_id);
pos.x = 3.0;

A pseudo code would be this:

pub fn component<T: ComponentTrait>(&self, id: EntityId) -> &mut T {

    match T {
        Position => self.component_pool_position.get(id),
        Velocity => self.component_pool_velocity.get(id),
        _ => compile_error!("Unknown component type."),
    }
}

With, of course, match T {} branching done at compile type. I know something like this can be achieved in C++ but I don't remember the name of this concept.

So:

  • What is the name of the problem I am trying to resolve?
  • How can I achieve this in rust?

Thanks in advance! :slight_smile:

The fancy word for this is "compile-time polymorphism", and it's pretty similar to C++. In particular, both languages share the fact that there's nothing remotely resembling a "match on type" feature (unless you count stuff like Any and TypeId, but those are likely extreme overkill here); you have to use multiple functions and let the generics system dispatch to the right function. The biggest difference is that in Rust every type parameter must have an appropriate trait bound for whatever you want to do with it, while in C++ concepts are not only an optional feature but a bleeding-edge one.

In Rust, 99% of the time you should just use a trait method for this. Most directly, you can make Position and Velocity both implement a get_from_component_pool method on the ComponentTrait, so component() can simply call T::get_from_component_pool(self, id); without caring what the concrete type of T is.

The interesting case is when you really care about the _ => branch, i.e. having a "default implementation" for all types that don't provide one of their own, while still allowing some types to provide their own. Given what you put in that branch I'm guessing you do not care about it, but for completeness: In general that is only possible with specialization, which C++ has and Rust currently does not.

4 Likes

This cannot be done directly. You can use the TypeId from any, or change the match into a function call on the trait.

1 Like

The fancy word for this is "compile-time polymorphism", and it's pretty similar to C++.

Thanks!

Most directly, you can make Position and Velocity both implement a get_from_component_pool method on the ComponentTrait , so component() can simply call T::get_from_component_pool(self, id); without caring what the concrete type of T is.

Interesting, but as you guess, the component pool is not owned by T. T is Position and Velocity and those are simple x, y, z in f32, so you have no reference to the pool inside them.

With that in mind, what Position::get_from_component_pool(self, id); is supposed to be?

This cannot be done directly. You can use the TypeId from any , or change the match into a function call on the trait.

Thanks! That's maybe what I'm looking for.

Is TypeId a compile-time feature? Will this create branching at runtime?:

match TypeId::of::<T>() {
    TypeId::of::<Position>() => &mut self.component_pool_position.get(id),
    TypeId::of::<Velocity>() => &mut self.component_pool_velocity.get(id),
}

Or is this branching be evaluated at runtime?

Thanks in advance! :slight_smile:

"compile-time" vs "runtime" gets kind of subjective in that example. The TypeId of a type is always known at compile-time, but match is generally a runtime feature (since we're not in a const fn), however this is a generic function that will get monomorphized, after which the optimizer can likely tell only one of the match branches is possible.

The interesting point is probably that "match is still runtime" in the sense that the Rust language has no way of knowing (or more precisely, it deliberately does not do the whole program analysis to find out) which types you might call that function with, and therefore it cannot know that Position and Velocity are the only type ids you might be matching on, so it will insist you have a _ => arm in that match block.

With that in mind, what Position::get_from_component_pool(self, id); is supposed to be?

The same as whatever self.component_pool_position.get(id) was supposed to be? I'm not sure I understand this question. It's true a few fields or methods may have to increase their visibility to make this work (and I probably should've written &self instead of consuming self), but fundamentally it's just moving the code you already have to different impl blocks.

The same as whatever self.component_pool_position.get(id) was supposed to be?

Here, self is ecs the whole entity component system (like a manager). It contains various component pools. So let pos = &mut ecs.component<Position>(id) looks for the pool storing the given component type.

The other approach is to have multiple functions:

pub fn component_position(id) -> Position {};
pub fn component_velocity(id) -> Velocity {};
...

The only difference being the pool where the components are gets, this add some code duplication.

Is there a way to provide an implementation of fn component<T>(id) ?

Something like:

fn component<T where T: Position>(id) {
  self.position_pool.get(id)
}

fn component<T where T: Velocity>(id) {
  self.velocity_pool.get(id)
}

Etc.

You can't hard-code the list of types like that. You have to use traits, and you have to provide a single function that works for every instance of that trait. One approach I've seen is using the TypeId as the key in a hashmap.

Thanks, after digging I realize what I'm trying to do is “function specialization” and it's not doable (yet?) in rust.

So I will go for the fn component_position(id) -> Position solution.

Thanks all!

I don't understand why you're not putting the ComponentTrait-specific behavior inside the ComponentTrait impl. I interpreted @Ixrec's suggestion to mean something like (signatures slightly tweaked to compile):

struct Position {}

struct Velocity {}

struct Whatever {
    component_pool_position: Vec<Position>,
    component_pool_velocity: Vec<Velocity>,
}

type EntityId = usize;

trait Component {
    fn get_from_component_pool(ecs: &Whatever, id: EntityId) -> &Self;
}

impl Component for Position {
    fn get_from_component_pool(ecs: &Whatever, id: EntityId) -> &Self {
        ecs.component_pool_position.get(id).unwrap()
    }
}

impl Component for Velocity {
    fn get_from_component_pool(ecs: &Whatever, id: EntityId) -> &Self {
        ecs.component_pool_velocity.get(id).unwrap()
    }
}

impl Whatever {
    pub fn component<T: Component>(&self, id: EntityId) -> &T {
        T::get_from_component_pool(self, id)
    }
}

Position and Velocity don't need to contain references to the pools because you can pass that in to the (compile-time-dispatched) trait function.

Using a HashMap<TypeId, _> is a runtime solution that might be more appropriate, but Rust also lets you do this stuff at compile time. Only specialization is missing.

(C++ also has if constexpr, which you could use to implement a feature like this, but it's a somewhat different approach than using principled generics.)

3 Likes

Yes, @trentj's post is exactly what I was suggesting. As I said, you do not need specialization here unless there's a "default" behavior you want to impose in addition to the Position-specific and Velocity-specific behaviors. AFAIK a default component behavior doesn't make much sense in an ECS.

Thanks for the example, now I catch it!

Being new to rust and used to OOP, it looks convoluted to me as, in this solution, Position and Velocity implementation would have to know the ecs manager internals (Whatever in your example) but I noticed traits often invert the structural approach (aka: How the rest of the code interact with the type).

That's sort of true, but it really depends on what you're comparing to what.

For example, yet another thing you could do here is make an enum Component with variants carrying Position and Velocity values, then you could trivially write the match block you originally thought of writing. Of course, that would no longer be "compile-time polymorphism", so all the component values but be carrying around a variant tag and doing a match at runtime (which may or may not be negligible in your program).

IMO, the difference that's really conceptually important here is that an enum is a closed set of possible variants, meaning all of the options are declared exactly once and none can be added from elsewhere after that declaration. On the other hand, a trait is an open set of possible types, so there could be any number of impls of that trait all over your code, and even in your users' code, so in principle the compiler may never get to see all of the impls at the same time.

That openness is precisely why this "inversion" makes sense: Adding a new trait impl only requires writing only the one impl block, without touching any of the other impls or the generic call sites. Adding a new enum variant typically requires touching every single match or if let using the enum.

This feels like a difference from C++ primarily because C++ does not require concepts on type parameters, while Rust requires traits on its type parameters. The tradeoff here is that C++ is less typesafe and has less helpful error messages. In Rust, if you try to std::sort an unsortable type T, you're going to get a fairly straightforward "T doesn't impl Ord" error. In C++, you either get silently wrong behavior (maybe even UB!), or if you're lucky you get a "post-monomorphization error" such as "on line 42 of the std::sort implementation it says x > y which we can't compile because there's no operator> for types X and Y, which you've never heard of before, but that's because X was implicitly derived from your T through..." and so on and so forth. This is why C++ concepts are often marketed as "improving template errors".

Hopefully that helps make it all seem a little more deliberate and not just an arbitrary difference from C++.

6 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.