Generic infection

Let's say I have some struct State and it's passed around everywhere.

Now I refactor it to State<F: Fn()> because State holds a callback. This little change means I need to fix a ton of compiler errors... for example every function that was like do_something(state: &State) is now do_something<F: Fn()>(state: &State<F>)

Is this just the way it is, or am I doing something wrong?

(note - in the real code it's wasm event handler stuff and I'm actually passing Rc<State> around, these functions aren't just methods that can be declared inside the one impl block)

Yes, generics are infectious. Just like regular functions. Most use of generics are just type-level functions anyway, so there's that. If your type depends on a type parameter, you have to somehow supply that parameter. Either by assigning a concrete type to it, or by passing the requirement (and the decision) up the chain to someone who will eventually be able to supply a concrete type. Similarly, if your function depends on a value passed in as an argument, then any callers of that function will either pass a specific value to it, or they need to delegate that responsibility by including a corresponding argument themselves.

Of course, one "trick" is to replace the parameter with a technically concrete type without being too concrete. That's what trait objects are for. So, instead of making your State generic over F, you could just store a Box<dyn Fn()> in it (or better yet, a Box<dyn FnMut()> in order to allow more kinds of callbacks). This makes the type parameter go away, because dyn FnMut() is a concrete type – even though you can still construct the same trait object from many different underlying concrete types. It will, however, incur the (minor) additional cost of dynamic dispatch on your code.

7 Likes

If you're the one supplying all the callbacks, and they happen to be restricted enough (capture no state of their own), function pointers may also be another concrete type option.

3 Likes

Good point. Unfortunately, in this case they are closures which capture their environment.

One alternative is to make State a trait:

struct StateImpl<F> { cb: F, ... }

impl<F> State for StateImpl<F>
where F: Fn()
{
    fn callback(&self) { (self.cb)() }
    // ...
}

fn do_something<S:State>(state: &S) { ... }

This still requires one pass of making everything generic, but future changes should be able to just update the trait definition.

3 Likes

Does rust analyzer provide tools to help make these changes? Something like spotting that a struct has had generics added to it and offering to update to use those generics where it applies.

@dakom Could you say why a trait object that @H2CO3 recommended doesn't work for you? That's almost certainly what I would do here as well, if only to avoid the infection caused by generics. (But there are potentially other downsides, such as longer compile times.)

Does usage of trait objects instead of generics increase the compile time? I thought that monomorphization of generic functions would take much longer time.

It certainly could work. I guess up until now I've thought of Trait Objects more as an escape-hatch when there's no other choice, but this makes me think developer ergonomics is a pretty compelling reason to go that route too.

Sorry, my wording was unclear. I meant that using generics could increase compile times. (And also bloat the binary.)

1 Like

Ah yeah, I don't think of them like that. To me, they're just another tool. The main problems in my experience with trait objects are:

  1. They inhibit function inlining.
  2. Sometimes it is not easy to make a trait that is object safe, and thus, trait objects are themselves not constructable.
  3. Depending on the use---and likely in your case---they probably require an allocation to create.

As long as you don't need function inlining and can afford one alloc, closures don't suffer from (2), so using a trait object is a fine solution. In general, (1) and (3) are pretty small costs that most code can withstand very easily.

The aho-corasick crate actually has a good example of this. Internally, it uses a prefilter function---of which there are many choices---but monomorphizing the search code for every distinct prefilter would just be madness. And exposing an internal detail as a generic type parameter isn't great either. So the prefilter function is stored as a trait object.

4 Likes