Await Syntax Discussion Summary

Multiple people have already talked about the persistence of await chaining, and where it would be applicable in real code. People already inclined to use it found in their projects one could use it a lot. For people who want it, it now is clear they would use it enough for it to be worth it.

Unfortunately if you want to get the communities collective conclusion on this, you're going to have to read 162 posts here, some more posts in the poll thread and related new threads (like pipelining) plus some reddit comments in the reddit sister thread and poll thread.

If you haven't at least read everything here, you've undoubtedly missed a significant amount of discussion. The big issue is that bringing these issues back up with out back-referencing what has already been discussed is counter productive and just brings us back in circles. Please do not post in these threads with out reading the full context, I just went through the entire thread again to find these links, all of them pertain to this issue. You are not the first one to bring this up, and it is entirely unfair to bring them up again, disregarding previous discussion, and previous counter points.

This is a list just from this thread alone. There are others on reddit and in the other related threads that are also related.

5 Likes

Thanks for posting this. I hadnā€™t seen https://github.com/inejge/await-syntax before (and I was looking for something like that). Thatā€™s a testament to just how hard it is to keep up with this discussion.

Looking at that code does push me more towards a postfix syntax, particularly given the prevalence of the context method from failure to customize errors. That is something I perceive will become more and more common (not just from failure), and prefix await (even with await?) doesnā€™t really address that. e.g., Even if we have await?, if youā€™re writing (await foo).context("an error")? a lot, then the await? syntax doesnā€™t really help.

20 Likes

I do want to commend @inejge for preparing that repository as well. I've been following along with this thread and I've noticed that, along the way, a number of people have made real efforts to try and help people step back and help everyone gain overall perspective, whether that be through the straw poll, summary posts/gists like this one by @HeroicKatora (which also lists a ton of summary posts itself!), or even just careful exposition of examples. I just wanted to say a big :heart: "thank you" :heart: to such folks. (As someone who hadn't been following the debate in detail until recently, these kinds of things were also super helpful to me.)

22 Likes

Iā€™ve written a reasonably sized, non-trivial codebase that currently uses futures on stable thatā€™s somewhere in the 5000 SLOC range. Async/await is something Iā€™ve been very eagerly awaiting because in my own experiments in converting that codebase over to nightly with the current await!() macro, I dropped around 600 SLOC off the total size of the project and wound up with code that I feel is much easier to follow and reason about. So Iā€™ve been mulling over the options presented and after looking at the repo that contains code converted to four of the proposed syntaxes, Iā€™ve come down to being torn between prefix with mandatory delimiters and postfix method syntax.

For prefix with mandatory delimiters, I feel like itā€™s more obvious to me immediately upon glancing at the code that something special is going on. It definitely feels like a control-flow construct and I know my function is going to be doing something special at that point (yielding execution until some time later). I also like the potential that it becomes easier to nest arbitrary expressions inside the await block, such as a match statement that could be selecting from one of several boxed futures that need to be awaited upon, but Iā€™m not entirely sure how useful that would be. All-in-all I like the way it stands out to me as a reader.

As for postfix method syntax, I find it incredibly ergonomic and it flows well with the typical pattern of the language. It was mentioned that it flows well with error combinators that many error handling libraries currently provide and may provide more of in the future. It feels like the most ergonomic option for the way rust is typically written with long chains of methods. It feels less obvious upfront that something special is happening, but at the same time I would argue the fact that the function is marked with an ā€œasyncā€ modifier already indicates something special is going on to the reader and would act as the launch point for someone wishing to search for more information on the topic of async/await. So I feel my concern over obviousness is alleviated mostly by this fact.

When I went into thinking about this discussion and my own codebase I was very heavily leaning towards a prefix syntax, especially given prior experience with C#, JavaScript, and Pythonā€™s async/await support. With more recent experience with Kotlinā€™s coroutines, which have a method called await() to suspend the coroutine, I donā€™t feel like postfix method syntax is all that weird anymore.

In the end I would say that Iā€™m leaning more in favor of postfix method syntax now because of the way it fits the language ergonomics better and alleviates the need for a special await? operator just for error handling. It provides one way to do something that fits with the language and as mentioned earlier, the more I reason it through, the less I feel itā€™ll be a discoverability issue for people new to the language thanks to other aspects of the async/await design in Rust.

10 Likes

Iā€™m wary of wading into a big discussionā€¦ Iā€™ve read the whole discussion so far, watched the presentation @withoutboats linked to, and still want to contribute, sorry.

First, put me in the postfix-await camp. Prefix-await currently exists predominantly (exclusively?) in languages that embrace exceptions as part of their flow-control. The drawbacks of prefix-based await ā€“ in particular around error handling ā€“ are not as much of a problem when using exceptions. We donā€™t use prefix try, anymore, even though thatā€™s more familiar to most users than postfix ?, because we use more explicit error handling than exception-oriented languages, and we think about errors more in the context of what generates them, so keep the related construct (the thing that raises the error, and the thing that allows us to read past the error) syntactically close. The same argument applies to await (IMHO): we think of the thing weā€™re waiting for when we decide to wait, so keep those constructs lexically close.

Second, I think postfix-await can work differently with for loops than what Iā€™ve seen discussed so far, and provides a reasonable approach to handling ? against iterators:

for await foo in bar

would be consistent with postfix-await if you consider await to modify the for loop, rather than the item being awaited. That is, the parse tree would be more like: (("for await") (foo) "in" (bar))) than it would ("for" ("await" (foo)) "in" (bar)).

This suggests using space as an await separator, rather than .? (Makes it much clearer that something important is going on than the field-access-syntax of ..) Or else for.await foo in bar if you stick to .? But either idea extends naturally to

for? var in iterable {
}

and indicates more clearly that the novel syntax is modifying the loop itself, rather than modifying the variable bound in the body.

4 Likes

What I like about await in other languages is how it stands out so you know exactly where asynchronicity is happening (note that this is different from control flow). I find the postfix notation loses this distinction and itā€™s only by noticing that the function is async that I know to scan for postfix await.

4 Likes

My hope is that with the safety checks we have in Rust, and a good async/await implementation, that asynchronicity becomes ā€œjust another conceptā€ thatā€™s part of the language.

It has no need to stand out, because its usage ā€” and more importantly, its potential surprises ā€” doesnā€™t stand out.

Having said that, while I agree with @Mr_Byte that the postfix syntax has grown on me, I am still troubled by the special place it would have in the language either as a special field, or method (something the prefix with required delimiters would not have, except that it also requires await?, which isnā€™t great either)

1 Like

Contributing because I still see a lof of arguments in favor of a sigil.

No budget for sigils

Rust is already busy with ', &'a,<'_>. Contrast this with the most popular languages out there, like JavaScript, which has none of that. They are very discouraging for novices taking a glance at the language, as they require googling or reading the documentation to understand, which is very undesirable. Given that Rust's mission is to empower everyone (coming from those languages) to build efficient and safe software, I think we have no budget left for more sigils. We need to keep Rust's appeal to increase our mere 3.2% market share.

Prefix

If we go with prefix, I believe the most approachable syntax is mandatory delimiters (await { future }?). It avoids all the pitfalls of await future and has no ambiguity for people who have not read Rust's documentation (unlike await?, which I see as a crutch that does not work well for nested futures and still requires a documentation lookup, like a sigil would). Here is https://github.com/inejge/await-syntax/ with modified syntax highlighting:

Postfix

I do like the arguments favoring a postfix syntax and actually prefer it, as long as it is not a sigil.

  • f.await{}?
  • f.await()?
  • f.await?
  • f.await!()?
  • f.await!?
  • f..await?
  • f.await{}?

All look acceptable to me. My preferences indicate control flow shenanigans:

let result1 = future.await{}?.do_more().await{};
let result2 = future.await!()?.do_more().await!();

Personally, await{}? at the end of the line is more visible to me than the prefix version.

I wrote a little further up, but want to circle back as itā€™s less of an impulsive response given Iā€™ve had a few days to think about it. Thereā€™s a lot going on in this thread and people are making good arguments in favour of every suggestion, however there are some specific suggestions which I strongly disagree with.

The first is the sigil form suggested. This is not specifically because I am against the idea, but because there is no obvious symbol to use for this form. The ? operator for something which could have failed is a logical choice. I have (so far) seen @, ! and # suggested in this thread and none of those make any sense with reference to async/await (at least to me). Regardless of if Iā€™m missing something or not, the fact I might be represents that it will be very non-obvious to other users as well. If we do decide on sigils, it should only lead to yet another discussion of which symbol to use - it should not be chosen lightly. This extra overhead makes me against going this route (at least for now).

The other form is the field syntax. If you look at the code foo.await, itā€™s entirely non-obvious that this could take potentially forever to return. Itā€™s too much of a deviation from what that syntax already represents. Conceptually, once someone has explained it to you, itā€™s not complicated. However I imagine most people will leave with a ā€œthatā€™s kinda grossā€ feeling. Do not forget that this is meant to be friendly for everyone, including newcomers and intermediates, not only those who are deeply involved with Rust. Not only do I feel it bad in this case, it would also set precedence for a pattern we might not want to continue with.

My initial response was to simply see the syntax of foo.await() come into play. I donā€™t see why async/await should be considered anything other than a function call from a developer perspective. The existing Future in the external crates literally has a foo.wait() which simply polls continuously. The biggest argument Iā€™ve seen for this (disregarding arguments against postfix in general) is that itā€™s non-obvious that itā€™s doing something asynchronous. I understand this point of view, but would like to point out that just because you have a Future does not technically guarantee youā€™re doing something asynchronous.

I still agree with all of this, except that the extra couple of days have given me more time to reflect. As such, I have changed my mind to favour foo.await!(). It provides a very obvious syntax that can be used for similar features in future, as itā€™s familiar as itā€™s a combination of two existing things - macros and functions. Rather than caring that ā€œitā€™s obvious itā€™s doing something asynchronousā€, I now prefer this syntax because itā€™s obvious that itā€™s builtin, in the same way we all use println! rather than println. I believe that this syntax strikes a balance between familiarity and ergonomics, and has the added advantage that weā€™re also deciding the syntax for other features down the road. There are no new symbols required in the language, and thereā€™s no real new concept that you have to learn to move to asynchronous programming.

I do still feel itā€™s a shame that weā€™re likely to go postfix rather than prefix, as I do like await foo(), but at this point I havenā€™t seen anything close to compelling for the prefix case which still solves the initial problem.

4 Likes

@ has a lot of sense:

  1. It looks like spiral which could be associated with looping which on fact is done under the hood
  2. It has letter "a" inside which could be seen as short for "await"

I find it funny that in https://github.com/inejge/await-syntax/, all four variants end up with exact 568 lines of code, despite the claim that prefix awaits are verbose and postfix awaits save you temporary variables, more clean, etc. In my opinion, this is the evidence that postfix awaits are not much too useful in practice.

6 Likes

Lines of code do not totally indicate verbosity in this case, as

get_client_sme(wlan_svc, iface_id).await()?;
                .disconnect().await()
                .map_err(|e| format_err!("error sending disconnect request: {}", e))

has the same number of lines of code as

let sme = (await get_client_sme(wlan_svc, iface_id))?;
let disconnected = await sme.disconnect()
         .map_err(|e| format_err!("error sending disconnect request: {}", e))

but the former is much clearer and less verbose.

8 Likes

This is exactly the argument that was made about try!(foo) vs foo?: That the former stands out. However, in retrospect, that seems to not to have been an issue. Is there something about await foo vs foo.await that would make this more of a problem than exists with ??

I'll note, also, that you already commonly need to scan postfix for ? in async, so I'd rather scan postfix for await too, rather than need to scan both prefix and postfix.

3 Likes

Phew, I honestly donā€™t see how the former is clearer then the last. The first one has a whole complex chain in a function call, which Iā€™d avoid in any case.

Iā€™d agree though that number of lines are not a good metric here, they should be spent for clarity and more LoC might be better and more lightweight if weā€™re talking about 2 separate actions.

6 Likes

I agree, it also has very little chaining situations and they would not suffer from a temporary variable. (like in the await! version).

As an example: Comparing master...postfix-method Ā· inejge/await-syntax Ā· GitHub

2 Likes

First, please donā€™t assume other people donā€™t understand the context as you do. These statistics were calculated back when the syntax issue was still discussed on Github. Iā€™ve been closely following this problem.

Second, my two points are misunderstood. Iā€™m not saying there isnā€™t opportunity to use await chains. Iā€™m arguing if you favor postfix await, you can always find some code examples and show its benefits. However, these examples are not necessarily representative. Iā€™m showing statistics for ? chains, which people can freely use chain or not. The result suggests there is only very rare usage of such feature in many popular Rust project. I doubt the same story may go for await chain, if postfix await is chosen.

Second, when we pick some code examples to show await chain advantages, we act as if the code is forever fixed / wonā€™t be changed in the future. Apparently, this is untrue. Todayā€™s nice await chains could be more inconvenient than prefix await if you refactor the code or the demand changes.

This would only be a problem if someone also implemented Future for Result. Otherwise the following is entirely unambiguous:

let result = Result<StructContainingBoxedFuture, ā€¦>;
await result?;

And even if you have a type which implements both Future and std::ops::Try, it could still be decided that operator precedence breaks the tie before you have to put parenthesis.

Just to clarify, I'm not saying this because I favor prefix, I just feel the implications of prefix are not very clear, and it's problems seem overestimated. Just like the compiler can automatically deref, it can check several ways of combining the operates in a statement before bailing with an error. If there is only one possible way to combine them into a valid expression, we're good!

The method call on a future outcome is a bigger problem. That's why I do think postfix is a good idea, but not for ? operator.

As a member of wg-grammar (but not speaking on behalf of the wg), Iā€™d just like to insert:

Iā€™d very much like it if we donā€™t consider making the parse tree depend on type checking. It doesnā€™t matter if itā€™s trait or concrete types; making parsing depend on types is a bad idea. Itā€™s the reason for ā€œthe lexer hackā€ in C and that parsing C++ is undecidable, even though the feedback there is ā€œjustā€ whether a name is a type or not.

Sure itā€™s ā€œpossibleā€. But then e.g. syn has to include a full type checker to parse, you need full crate dependency graph context to parse, etc. Parsing should, as much as is reasonable, stay a separate stage from type checking, and the parse tree shouldnā€™t be rewritable based on type checking either.

23 Likes

By the way, this seems to be the only mention of future combinators in this entire thread, so I want to bump it for visibility. I think that combinators provide a fine way of chaining awaitable actions.

1 Like

Part of the reason (though obviously not the entire reason) of having async/await is to move away from having to use a whole tangle of combinators and the ownership issues that imposes. Iā€™m not saying that theyā€™re never a good way to write the code, but Iā€™m pretty sure most everyone would prefer foo()?.bar()? to foo().and_then(Foo::bar)?. (And that doesnā€™t even work if foo's error type and bar's error type arenā€™t the exact same, and merely are ?-convertable to the same type.)

2 Likes