How to avoid viral Send bounds/guarantees for traits?

Let's say I have a trait like this, for code that accepts some async input and produces some async output:

trait DoSomethingWithFuture {
    fn do_something<F: Future>(f: F) -> impl Future<Output = F::Output>;
}

This abstractly expresses exactly what I want—the ability to transform a future somehow. But it fails when I try to use it in a multi-threaded environment, because even if the input is Send, the output isn't guaranteed to be (playground):

async fn foo() {}

fn bar<DSWF: DoSomethingWithFuture>() {
    // Works
    assert_send(foo());

    // Doesn't work
    // assert_send(DSWF::do_something(foo()));
}

I guess this makes sense—whether it would be Send depends on the particular implementation of do_something. Traits must therefore be an exception to the usual behavior where auto traits leak through RPIT functions.

Except… what do I do about it? Of course it works if I require the input be Send and guarantee that the output will be:

trait DoSomethingWithFuture {
    fn do_something<F: Future + Send>(f: F) -> impl Future<Output = F::Output> + Send;
}

But now I've swung the other direction: the trait defeats the benefits of a non-multi-threaded environment if that's where I want to use it. More relevant to my purposes, for anything non-trivial you wind up needing to write Send bounds absolutely everywhere whenever you have any generic code that involves this trait. It's all over the place, including on input parameters for every async fn that might be involved. Send plus (async?) traits seems to be a very viral combination. It's like we don't have auto traits at all.

Is there a better pattern for this? For example, any way to express "the output is Send if and only if the input is"?

The answer here might be "use GATs", so I guess I should mention a restriction here: I would like the future returned by the trait to be able to borrow &self, but if I do that I think it opens up an entire can of worms where if we do want to require the associated type to be Send then the trait impl must be 'static (?).

It's not really an exception to auto trait leakage. The function body has to compile for all types which can possibly meet the bound, which in this case means it has to compile when the future isn't Send. Rust strives to minimize template-like post-monomorphization errors, where your function compiles when instantiated with a DSWF where the future is Send but doesn't compile when the instantiated with a DSWF where the future is not Send.

The same is true when RPITs are not involved and you're just dealing with passing concrete types to generic functions.

Nothing suitable yet in the type system that I'm aware of even on unstable. Various experiments that do not yet get you there exist.

  • Non-lifetime binders (incomplete and no bounds support)
  • RTN (doesn't work with generic methods)
  • ITPIT as an alternative to RTN (probably the closest to stabilization but probably not useful without bounds supporting non-lifetime binders)
  • try_as_dyn and some unsafe maybe[1] (requires 'static currently, will probably always have some reltaed limitation for soundness)

Outside of the type system, sometimes macros can be used for template-like, compilations-fails-if-called-wrong code.


  1. e.g. conditionally transmute Box<dyn ..> to Box<dyn Send + ..> ↩︎

2 Likes

Thank you for the detailed overview! And for your help in all the other threads. I really appreciate it.

1 Like