Pre-Pre-RFC: async methods & bounding async fns

I don't have time to write a carefully-crafted post right now, so please forgive me jotting down a bunch of scattered thoughts!

Relationship between async fn and fn -> impl Future

I very much agree with @MajorBreakfast.

The "synchronous preamble" pattern (using -> impl Future with an inner async block) is likely to play a nontrivial role in async programming in Rust. For that reason alone, I think it's very important that you're able to move completely smoothly between async fn and fn -> impl Future.

One way we can make this smoother is to introduce a special lifetime, 'input, that bounds all input lifetimes (even if elided). Thus we'd have:

async fn foo(x: &Type1, y: &Type2) -> i32 { 
    /* body */ 
}

// equivalent to

fn foo(x: &Type1, y: &Type2) -> impl Future<Output = i32> + 'input { 
    async { /* body */ }
}

// equivalent to 

fn foo<'a, 'b>(x: &'a Type1, y: &'b Type2) -> impl Future<Output = i32> + 'a + 'b { 
    async { /* body */ }
}

As a general rule, treating a new feature as pure sugar for existing features has a ton of benefits. It means there are no truly new feature interactions to consider, since each interaction is explained by the desugaring. It improves interoperability, since you can use the "explicit" and sugary forms interchangeably. And it provides a multi-level understanding of the feature, allowing you to think about it as a first-class concept most of the time, but "drop down" into the desugaring when helpful. (Closures are probably the best example of this today, although you cannot implement the closure traits in stable Rust yet.)

One other thing I would add: I think it's important that you usually be able to use the most idiomatic (i.e. sugary) signature style when defining methods within traits. That means using e.g. impl Iterator and async fn. As things stand, there are limitations that prevent these forms from being "fully expressive" when using in trait definitions, but we should work to remove those limitations. Which brings us to the next topic...

Naming anonymous types

I understand @rpjohnst's perspective that async fn can be viewed as a type introduction form, but I have several reservations to treating it specially in this regard:

  • Every fn is a type introduction form, and over time I expect we will expose that fact more explicitly. Given that we want to be able to view an async fn as an fn, that means that async fn really introduces two types: the type of the function, and the type of the future it returns. I think it's important that any design for naming the type take this into account, to avoid confusion in the future (if and when we expose the fn type more directly).

  • My perspective has always been that we will have abstract type for truly tricky cases, but in the common case just using impl Trait should suffice. (In particular, as I mention above, I want to be able to unreservedly recommend people use impl Trait in trait definitions, rather than having idioms vary by context). My assumption was that we'd have some ergonomic means of projecting the entire return type of a function, which would suffice for async fn and the majority of impl Trait returns (given that they usually encompass the entire return type); I think it's fine to recommend abstract type only for the nested case (Vec<impl Trait> which I expect to be less common. This has already been discussed earlier on the thread with Output -- I think we should continue exploring this space to find something that works for both async fn and impl Trait more generally.

  • More concretely, we could e.g. consider outputof(path_to_fn) as shorthand for <typeof(path_to_fn) as FnOnce>::Output. I think there's a lot of design space here.

Edit: as per @MajorBreakfast's earlier points, this should probably be TypeOf and OutputOf, just like we have Self as a keyword in the "type namespace".

async at the end

Finally, I want to take up @lordan's suggestion of writing async closer to the return type; I think it's worth some serious thought. Some observations:

  • We could probably enable a slightly more precedented syntax this way: fn foo(&self) -> async i32 + Send. That is, we could allow + to be used within an async return type to impose additional bounds.

  • Writing it in the return type has a stronger connotation that this is a function returning an async value (sorry @rpjohnst!) And in particular, the jump from that to putting async immediately in the body feels very natural to me (and I wonder whether something similar holds for try).

  • If we did go this route, I think we'd want to strongly consider calling the trait Async rather than Future, for obvious reasons :slight_smile:

@withoutboats, I'm sure you've thought about this space before -- what are the pros/cons as you see them?

9 Likes