Specifying bounds on Futures returned by async functions

The biggest pain point of using the async fn syntactic sugar in traits is the fact that there is no way of specifying Send bounds on its future. As @nikomatsakis wrote in its Return type notation (send bounds, part 2) · baby steps blog, one way of solving this problem could be to add support for adding bounds to the target generic, so that only types that a) implement Foo, and b) its desired functions implement the traits can be used. However, there is no way of specifying this at the trait level without desugaring, so the bounds are left to be added by the downstream users if they're needed (which often, they're either always needed, or never). Thus, projects like Tokio and its dependents might find themselves writing that where clause with said bounds on every generic, and even though Niko proposes trait aliases as a solution, my opinion is that this is a sub-par solution. My proposal tries to move the generic bounds from the user to the trait definition, by moving the new syntax to the trait, rather instead of the user.

For context, this is what Return Type Notation (RTN) would look like:

trait Foo {
    async fn bar();
}

fn baz<T>(_foo: T)
where
    T: Foo,
    T::bar(): Send
{}

Every function that needs bar() to return a Future + Send will need to desugar what could be an impl Foo into an explicit generic and a 2-lines where clause (unless we stabilize trait aliases).

My proposal is to allow these to be at the trait level without having to desugar into a manual impl Future.... We could easily expand this feature to regular function in structs and modules, and it would serve as a bound to ensure that the Future returned always meets some invariants, which would be excellent for avoiding accidental semver breakages. I will attach some syntax bikeshed, because it is always easier to see it that way.

async fn foo() -> Bar: Send {}
async fn foo(): Send -> Bar {}
async: Send fn foo() -> Bar {}
async<Send> fn foo() -> Bar {}

It's the exact same amount of characters on all of them, so the discussion boils down to what we feel like fits better into the existing rust grammar. I ordered them according to my personal preference, from better to worst.

RTN is not mutually exclusive to this, and tackles a different problem. However, they overlap a bit and that is why I decided to mention it in this post and compare my proposal against it. RTN is still useful for traits that did not add these bounds because they are not necessary, but the user needs them to be there. I even have found that they would be very useful in a project of mine, but I digress.

In my mind, implementing a form of this proposal would be excellent for having async fn syntax not be an antipattern in public interfaces, and would allow more resilient APIs against accidental semver breakage.

(NOT A CONTRIBUTION)

If you want a method to always be Send, you can do one of these things:

  1. You can define the method using impl Future + Send instead of async (this doesn't require RTN).
  2. I assume you would be able to add RTN bounds on the trait definition, as in where Self::bar(): Send

RTN is specifically for the case where you want to be able to have a trait that for which the async methods are sometimes Send and sometimes not, and allow users who require Send to further bound their use of that trait. When Niko Matsakis talks about a trait alias, he is talking about a convenience for those users who require Send when the trait also supports !Send implementations. There's no need for RTN if you can only apply it at the trait definition.

Adding more sugar for the always Send use case may be justified, but I think it's better to find out if that's needed from practice before introducing novel syntax for it.

2 Likes

I hadn't thought about that, though it feels very redundant. I think this is a strong proponent against what I described, but maybe we could have my proposal be syntactic sugar for it.

Indeed, I tackled this on my conclusions

We opened that Pandora box when we added async fn, this is a consequence of its shortcomings in practice (just like keyword generics).

With the recent talks of having gen fn syntax for coroutines, I was thinking that it would be great if the impl Trait they desugar to also supported what we're discussing.

1 Like

The case:

trait Foo {
    async fn bar();
}

fn baz<T>(_foo: T)
where
    T: Foo,
    T::bar(): Send
{}

We need:

  1. have trait Foo to be able to force Send bound on implicit return type
  2. be able to bound future from T::bar() in a use place
  3. be able to conditionally bound the inner futures based to what we are bound with (this is for combinators)

For 1. and 2. we need to be able to name the implicit return type, and for that we have almost all:
T::bar::Output is legal expect it need an explicit cast to an Fn trait (aka <T::bar as Fn>::Output)?

  • perhaps we can have those traits in prelude?
  • current suggested syntax is also good for that

Example for the 3.

trait FutureExt: Future {
    async fn map<O,F>(self,f: F) -> O
        where F: Fn(Self::Output) -> impl Future<O>
    {
        f(self.await).await
    }
}

Here we have 2 input futures and one resulting, so bound result with Send we need to bound both self and return of combinator:

I think the best way to do that is to support trait parameters, so the signature becomes

async fn map<trait B = (),O,F>(self,f: F) -> O
        where F: Fn(Self::Output) -> impl Future<O> + B,
              Self: B

then a call would look like fut.map::<Send>(async |o| ...).await

However that mechanism is pretty big...

IMHO the FutureExt::map case specifically is better served by "final" methods on traits, where impls of the trait are not permitted to provide a different method impl. Then standard autotrait inference can kick in.

Future also names its return value Output, even if that wouldn't an issue for the compiler, it'd be a super confusing thing to expose to the end users.

Trait generics are definitely one of the top elements in my Rust wish list for a lot of reasons, but I don't know if they're the right solution for propagating bounds here. Can't we just silently propagate whatever traits the user bounded on the call via RTN? It's not like it would really compile otherwise...

Even if that's not possible/the case, I propose something that requires much less work than higher-kinded generics (trait generics):

trait FutureExt: core::future::Future {
    async fn map<O, F, R>(self, f: F) -> O
    where
        F: FnOnce(Self::Output) -> R,
        R: ~Self::map(),
        Self: Sized,
    {
        f(self.await).await
    }
}

The only new thing here (except for RTN) is what I will name the trait inheritance (not OOP inheritance) operator, something that we kinda already see in nightly when dealing with trait stuff (specifically, ~const Traits). What this does is basically turn a type (generic or not) into all the traits it implements in-place; what we would be saying is: "whatever traits this thing implements, this one's having them too". All this is information that the compiler has already gathered, so it's trivial to implement. Higher-kinded generics would require as much (if not more) time and effort as GATs. We can't wait for that to happen, this is due edition 2024.

Mind the turbofish!

1 Like

Does this "trait inheritance" operator (we need a better name for it ASAP) deserve its own pre-RFC?

Alternatively, there could be a way for the trait definition to require that any override of the default has to provide at least as many auto-traits as the default itself would.

Maybe. The thing is, as I was saying before, there is not a chance of this compiling without what we're proposing, and silent propagation is rather easy. Without RTN auto-trait bounds being applied on these calls, there would be no way of the code compiling in the first place, as the values produced would not be guaranteed to meet these auto traits. My suggestion is to silently propagate the bounds; so that for every RTN auto-trait bound, calls to RPIT functions of generic arguments also inherit these. That way, generic arguments would be constraint to what the RTN bounds previously allowed.

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