Type erasure with `impl` type

I'm building an event-driven abstraction which can be minimized like this

use std::marker::PhantomData;

trait OnEvent<M> {}

trait SendEvent<M> {}

impl<E: SendEvent<M> + ?Sized, M> SendEvent<M> for Box<E> {}

struct Sender<S>(PhantomData<S>);

// type inference is somehow broken if I don't include this into the example
struct Session<S>(PhantomData<S>);

impl<S> Session<S> {
    fn sender(&self) -> Sender<S> {
        Sender(Default::default())
    }
    
    fn run(&self, state: S) {}
}

impl<S: OnEvent<M>, M> SendEvent<M> for Sender<S> {}

struct State<E> {
    sender: E
}

struct Event;

impl<E: SendEvent<Event>> OnEvent<Event> for State<E> {}

I would like the state type S instead of event type M to show up in the sender so I can perform type erasure on the event type (which is not the type erasure I'm discussing in this post). The state holds a sender, which may or may not send to itself, and it may send Event through this sender while handling an incoming Event. In this post we focus on the case when it is actually sending to itself. In practice there are also cases where states send to each other in a circle, which leads to similar situation.

Obviously if I naively put these pieces together, cyclic type will fail the compilation (although the confusing compilation error starts with error[E0308]: mismatched types

fn main() {
    let session = Session(Default::default());
    let state = State { sender: session.sender() };
    session.run(state) // cyclic type of infinite size
}

The go to solution would be breaking the circle with trait object

fn main() {
    let session = Session(Default::default());
    let state = State { 
        sender: Box::new(session.sender()) as Box<dyn SendEvent<Event>> 
    };
    session.run(state)
}

(And add the corresponded blanket implementation of SendEvent for Box<_>.) In this case state will be State<Box<dyn SendEvent<event>>>. This has been what I am doing.

Just now I came up with the idea of use impl _ to replace Box<dyn _> so I can save some nonsense runtime overhead. Sadly it does not work

fn main() {
    let session = Session(Default::default());
    // cannot write `State<impl SendEvent<Event>>` for now so use this to workaround
    fn into_impl(sender: impl SendEvent<Event>) -> impl SendEvent<Event> {
        sender
    }
    let state = State { sender: into_impl(session.sender()) }; // cyclic type of infinite size
    session.run(state)
}

Interestingly the error is reported earlier, though I don't know why.

I don't see a reason why impl type theoretically not works here. As long as I can erase the concrete type into its behavior compiler should be happy, since all it cares for resolving trait implementation is the behavior. However, this error seems to indicate that impl type is only hiding the concrete type to me, not to compiler.

Should I expect impl type to work for type erasure soon or later?

Rust has two different features that share the impl Trait syntax.

  • In the argument position it doesn't do anything new, it's only a syntax sugar for T where T: Trait. The caller still chooses one specific type for it, and the exact type is known when the function is compiled (monomorphised).

  • In the return position -> impl Trait is not erasing the underlying type away like dyn Trait. It still returns one specific type that is known inside the function. Outside of the function the compiler still knows exactly what type it is, only stops users from making too many assumptions about the type, but it's still a sized type, and the auto traits implemented by the type are implicitly disclosed.

4 Likes

To put the answer more bluntly: No, you shouldn't expect that. impl Trait isn't about type erasure.

1 Like

Thanks, that is very clear.

By the way, are you aware of any zero cost solution for this (either exists or proposed)? The box indirection is not for the run time, just to make it type checks, so it feels theoretically unnecessary assuming we have an ideal compiler.

There are two cases when it comes to infinite types:

  1. (not your case, mentioned just for completeness) type with infinitely sized values like:

    enum List{
      Empty,
      Cons(i32, List)
    }
    

    The cycle be broken by adding an indirection:

    enum List{
      Empty,
      Cons(i32, Box<List>)
    }
    
  2. (your case) type with an infinite name, like Wrapper<Wrapper<Wrapper<...>>>. Consider the following example:

    enum GenList<T>{
      Empty,
      Cons(i32, Box<T>)
    }
    type List = GenList<List>;
    

    The type alias List expands to GenList<GenList<...>> which is infinite, although it seems to be equivalent to the previous example. (the error message is different because your case is a result of type inference, but the same principle applies)

    In cases like this the cycle can be broken by adding a type erasure like:

    type List = GenList<dyn SomeListyTrait>;
    

    ... or by making the type nominal like:

    struct List(GenList<List>);
    

In your particular example the type of variable state is inferred to State<Sender< State<Sender<...>> >>, which is an infinite name. A possible fix (besides the type erasure which you already considered) might be making the type nominal like this (Rust Playground):

struct MyState(State<Sender<MyState>>);
let state = MyState(State { sender: session.sender() });

The downside of the 'type erasure' approach is an additional runtime cost (as you already noted), the 'nominal type' approach should be zero-cost, but it may require you to name the type, which may be impractical or even impossible.

Thanks for the complementary writeup! Yes I have also considered the CRTP-style newtype solution. It has the desired zero cost, but the downside is that it is a newtype, so (e.g. in your example) whatever implemented on GenList<_> must be forwarded to List, in order to make it a drop-in replacement.

Putting aside whether that is always possible (it was possible as far as in my case), since I have this actix-style OnEvent<_> design, you may already guess out that there's usually tens of impl OnEvent<_> for the State<_>. The newtype introduces too much boilerplate which renders the solution infeasible.

Anyway, thanks for write it down so I am more confident that I have exhausted the language features:)

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.