Return type bounds for async fns

I just got done reading @nikomatsakis's latest blogpost: http://smallcultfollowing.com/babysteps/blog/2019/10/26/async-fn-in-traits-are-hard/.

This reminded me of an idea I had a while back but never really wrote down, which might go some way to solving Niko's "how do I require the future that this async trait function spits out is e.g. Send". A while back I proposed adding some syntax like MyFn::return as a primitive for getting the return type of a function (imagine that MyFn is a closure type, fnptr type, or the ZST associated with a fn item); I figured I'd sketch some responses to some of the problems listed in the blogpost, mostly "Complication #2".

I'm thinking of something like this: introduce new type syntax return which, within the context of a fn item (i.e., within its argument types, return type, bounds, body etc) behaves like a type alias for the function's return type (compare one of Centril's named existentials). This isn't a particularly novel idea, but consider the following:

async fn futz(x: impl T) -> usize
  where return: Send;

I think the natural use of return here is to refer to the synthesized impl Future return type, which gives us a comparatively natural syntax for ensuring the future is Send[1]. This is also separately nice, because it gives us a nice name for the impl Future that doesn't involve typeof silliness. I certainly would prefer this mechanism to not be async specific, for the benefit of generators and similar quasi-functions. Mind, this also isn't completely novel, since I remember some noise about writing async(Send) fn or similar.

The key thing that I'm interested in, though, is the particularly painful GAT version[2] of this problem you get when you decide you need to bound the return type of an async trait method. While I imagine you could do something silly like

where <DB as Database>::get_user::return: Send

Ignoring all of the hilarious syntactic ambiguities, for the moment, I think a not-insane alternative is to require the trait to specify the Send-ness and whatnot, i.e.,

trait Database {
  async fn get_user(&self) -> User
    where return: Send; // This bound is implied for all 
                        // implementors of the trait, as usual.
}

This seems to solve the letter of Niko's problem, but certainly not the spirit of it. Now the trait declaration cares about Sendness, which feels like the wrong choice somehow. This makes me wonder if instead we really do want Niko's async Send bound? One could imaine something really silly like

where <DB as Database>::return: Send

where Trait::return: Send is trying to say "for all return types of functions in Trait, make sure they're Send". Mind, this needs some way to specify the bound just for the async functions, not to mention that Trait::return can't corrspond to any real type.

This also doesn't address Complication #2b. I don't really have any reasonable solutions other than a rough sketch. If we decided that "Send is default", then

async fn foo<A, B>(&self);

should, presumably, implicitly carry where for<A: Send, B: Send> return: Send. We can then imagine that where return: Send upgrades this to where for<A, B> return: Send, while where return: ?Send deletes this where-clause altogether.

Looking back on this, I don't think I succeeded in addressing the problems as much as I might have hoped, and I got a little rambly, but hopefully something in this post inspires someone to say something a bit more intelligent than what I said.

[1] Mind, this is kind of obnoxious boilerplate, and what we really want is Send by default with a return: ?Send opt out, but that's kind of out in the rhubarb.

[2] From this point on, just assume we have GATs with all the semantics Niko typically uses for them.

6 Likes

Does it work for regular functions with impl Trait return type?

The return keyword works, but it's another piece of syntax I don't think I'd have guessed. It feels weird, because it's "unsugaring" a syntax sugar. That's an indirect manipulation of an expression that is there, implied.

I'm not sure if this is the right solution for async fn in traits, but I very much like the ::return syntax to refer to a return type.

3 Likes

That's the desire, at least... but I don't immediately see the value of writing

fn foo() -> impl T
  where return: U;

instead of

fn foo() -> impl (T + U);

I guess you could ascribe meaning to the former "to typecheck, return: T + U, but caller does not get to use return: U to prove theorems." I guess this could be useful?

I guess? I think the choice of return falls out of "this should be a keyword, so that we can write path::kw; return isn't valid in type position and means the right thing from context".

Me neither. I think there are other, much more important sadnesses to address, such as the dynamic GAT problem, that I have no clue how to begin to approach.

Really, this was me seeing Niko saying "ah yes, the return type of get_user can be synthesized as GetUser" and being a bit grossed out. =P

An interesting step is to take the example further and combine async fn and impl Trait:

trait User { ... }

trait Database {
  async fn get_user(&self) -> impl User;
}

fn foo<DB: Database>(_: DB) where <DB as Database>::return: Send {}

Would this apply the extra bound to both voldemort types in Database::get_user, essentially requiring the return type to be like impl Future<Output = impl User + Send + '_> + Send + '_?

Well, that's kind of the sadness of Trait::return; it's not a real type. If you cared about a single function, you'd get to say Trait::func::return::Output: Send or Trait::func::return: Send and you wouldn't be sad. Now, if we had variadic types...