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

I don’t think that the returning the outer return type is advantageous. @MajorBreakfast has done a good job enumerating the arguments for the outer return type, here is the argument, from my perspective, for using the inner return type (which is mostly not to do with the number of characters).

It puts complexity front and center

Looking at this signature:

async fn foo() -> impl Future<Output = i32> + 'in

This signature is quite complex - framing it as “more characters” is in my opinion doing a disservice to how complex this is:

  • It uses impl Trait
  • The trait has an associated type
  • It has multiple bounds conjoined with +
  • One of those bounds is an explicit lifetime

This is an advanced signature to understand, and I think it would be very intimidating for new users. Claims about how users need to understand all of this to know how async works anyway are ignoring the fact that users can start with a very fuzzy model (“it returns a future”) and only gradually, as they gain a better grasp of Rust’s type system, fill in the precise definition.

It appears configurable, but isn’t

Looking at that return type again:

impl Future<Output = i32> + 'in

This has several components that appear incorrectly to be configurable; that is, based on Rust’s grammar, users would imagine that they could modify many parts of this signature, but they can’t. I’ll enumerate.

The lifetime is not meaningfully configurable

Users might imagine they could drop the + 'in, or otherwise specify a different set of lifetimes. This is incorrect. Well, maybe not, because we haven’t specified our requirements - if we allow any lifetime signature that “captures all input lifetimes,” it becomes configurable but in an even more confusing way: what lifetime you can put in the future (or if you need to put one at all) depends on what the input lifetime is. That is, all of these signatures are valid, syntactically different, and semantically equivalent:

// no input lifetimes, so no output lifetime required
async fn foo() -> impl Future<Output = 32>

// only one lifetime, so you can capture just that instead of 'in
async fn foo<'a>(x: &'a i32) -> impl Future<Output = i32> + 'a

// Lifetime elision would have the same meaning as 'in here
async fn foo(&self) -> impl Future<Output = i32> + '_

// Because 'a and 'b both outlive 'r, using + 'r is fine
//
// (I might have gotten this wrong and it might be supposed to be:
//     'r: 'a + 'b
//  I can never remember..)
async fn foo<'a, 'b, 'r>(x: &'a i32, &'b i32) -> impl Future<Output = i32> + 'r
     where 'a: 'r, 'b: 'r

In contrast, these very similar signatures are invalid:

// There's an elided input lifetime, and no output lifetime
async fn foo(x: &i32) -> impl Future<Output = i32>

// You've used '_, but the elision defaults dont capture
// the lifetime of the x variable
async fn foo(&self, x: &i32) -> impl Future<Output = i32> + '_

// Even though you used the only named lifetime,
// there is an elided lifetime here
async fn foo<'a>(x: &'a i32, y: &i32) -> impl Future<Output = i32> + 'a

Ultimately, none of this configurability is valuable either: the only valid signatures are all functionally equivalent. We could sweep away configurability by mandating that only the 'in signature is allowed, but that only mitigates, not eliminates, the underlying problem that you must specify, every time, that the return type has a particular lifetime, unlike any other kind of function.

The return type is not meaningfully configurable

You might imagine that you could replace impl Future with something else, in a few directions:

// Maybe you want to use a trait object:
async fn foo() -> Box<Future<Output = i32> + 'in>

// Maybe you think you'd be able to return a stream:
async fn foo() -> impl Stream<Output = i32> + 'in

// Maybe you think you can add any trait:
async fn foo() -> impl Future<Output = i32> + Copy + 'in

None of these work, and I want to pause on the last one. Given that the whole point of this change is that impl Future<Output = i32> + Send + 'in would work, you have to know that the only additional bound you can add is an auto trait. By using the normal bound syntax, we introduce an expectation that any trait will work here. But they won’t, because only the auto traits can be inferred for the anonymous futures.

Well, that’s not quite true: if there’s a blanket impl of the trait for all futures, you would (according to the rules of our type system), return that, since your type will implement it. For example:

// It is the case that every Future implements IntoFuture
async fn foo() -> impl IntoFuture<Output = i32> + 'in

Presumably, with the return type of this function, you can’t treat it as a future, only as an IntoFuture. This wouldn’t be useful at all, since given that you have an impl Future, the compiler can figure out that that type implements IntoFuture.

Once again, there is some configurability, but you have to deeply understand both the language and the Future API to know what you can and can’t do, and the things you can do are overall not useful - that is, the only useful thing is adding + Send.

It is less ergonomic in several respects

Originally, we leaned heavily on the unergonomics of losing lifetime elision to justify the internal return type. @aturon has introduced the special lifetime syntax variably called 'in or 'all or 'input in this thread, which mitigates, but does not completely eliminate, that unergonomics - that is, even with this feature, you do have to write + 'in, which is less ergonomic.

However, in my opinion, the 'in does not carry its weight. Its primary use case would be to be the mandatory lifetime you have to write when writing out an async fn. I think it would be better to use a syntax in which you don’t have to write a lifetime at all than coming up with a special lifetime to make writing the lifetime easier. Outside of this use case, it doesn’t have a very compelling motivation.

Then there’s the argument that can be reduced to character count, but the truth is just that -> impl Future<Output = ?> + 'in is a lot of additional code to add to the signature in addition to the async keyword. Rust function signatures already often run to multiple lines, we don’t have a lot of real estate to spare in the function signature.

There’s a last point of ergonomics that I don’t think has ever been brought up: omitting the return type. If you wrote:

async fn foo() -> impl Future + 'in

You might reasonably imagine that this works just like omitting the return type of the function normally: it defaults to (). But that’s not true: any interior return type will be accepted here, so you could return an i32 or anything else and it will still compile. Moreover, when polling this future, the compiler won’t know what type it actually returns, so calls to this will ultimately not typecheck.

This is both a potential pitfall for users expecting a different behavior, and less ergonomic even if you have the correct expectation. If you want to write an async fn that returns (), you have to write:

async fn foo() -> impl Future<Output = ()> + 'in

It renders inoperable a compelling mental model for async

@aturon has emphasized to me the importance, for him, of supporting a mental model in which you can understand async fn as sugar for fn -> impl Future with an async block inside of it. For me (and I think similar for @rpjohnst), the compelling model for learning how async works is rather different.

I see async as a modifier which can be applied to different syntactic forms. Those forms look the same as they did before, but have two differences:

  • Instead of evaluating to T, they evaluate to a future of T
  • You can await other expressions inside of them.

That is, the desugaring of async fn to something with async block inside of it is not an important early point of understanding: the initial, intuitive mental model, is that you stick async on the front of a block, a function, or a closure, and instead of evaluating normally, it has a delayed evaluation.

This mental model relies on the function signature looking like a normal, synchronous function signature, and matching the interior return type. You can take any function you have, add async to the front of it, and the resulting code will still be valid Rust, only now you can await futures inside of that function. That’s a very powerful tool for understanding in my opinion, and it becomes diminished by instead returning the outer return type.

Conclusion

This is the same reasoning I used when writing the RFC, and the only new problem introduced in this thread since the RFC is the problem of bounding an async function in a trait definition. The outer return type provides an obvious way to solve that problem (though I’ve argued above that that solution introduces more confusion), but there are also several syntaxes proposed in this thread that solve the problem for the inner return type syntax. I don’t think that the bounds in traits problem is enough to shift the balance away from the decision we made in the RFC thread.

22 Likes