Await Syntax Discussion Summary

That might be true for some people, but I personally don't really mind typing out the former sometimes either if that's the cost I have to pay to e.g. avoid adding special cases or a whole new feature to the language.

In fact in some situations I prefer composing combinators. I couldn't yet come up with a good rule of thumb as to when or why, but for me, Result/Option combinators and try!/? are certainly two equally good ways to write code. They just have that appeal of composability to them. (And I'm also pretty sure I'm not alone because I still see code in the wild heavy on combinators while also, of course, using ?.)

And I also see that several people like to argue about temporary bindings, so let me do that too: if I need the Ok value of a Result-returning function call, the temporary I introduce with a combinator is only scoped over the body of the functional argument, and I don't "pollute" the entire outer scope with it (not that I mind the latter that much either, but it can be convenient if you don't want to think about shadowing).

For example, in a parser I've written, I used map 10 times, map_or 3 times, ok_or_else 8 times, and_then 3 times, map_err once, and custom combinators 13 times, for a total of 38 times. In the same code (926 lines), I used the ? operator 91 times, so a little more than twice as frequently. From this it's clear to me that it is a win to have ?, but not overwhelmingly, and for sure it doesn't make combinators deprecated or useless.

That's not really a good additional argument because it's trivially resolvable by adding another .map_err(Into::into) or similar to the call chain.

2 Likes

Iā€™d prefer combinators to ā€œpipelineā€ the result of future to another function:

await foo.and_then(bar).and_then(baz) 
// instead of 
await baz(await bar(await foo))

But for calling method on result of future Iā€™d prefer to use some unobtrusive ā€œpipelineā€ construct:

foo@.bar()@.baz();
// instead of
await foo.and_then(Bar::bar).map(Baz::baz);
// or
await foo.and_then(|b|b.bar()).map(|b|b.baz());

Because both of them provides the least burden with translation into human language

In case people havenā€™t done so yet, I would suggest everybody to open the following links in three consequtive tabs:

Then try paging back and forth between the tabs (open the tabs in a new browser window and use Ctrl-PgUp and Ctrl-PgDn).

Perhaps the example is bad, but it sounds like this is real async/await code found in the wild (taken from Googleā€™s Fuchsia OS project). What stood out to me is:

  • chaining await calls are rare or non-existing
  • await is rare in real code ā€” there are 43 lines with await out of 527 lines of code
  • there is no ā€œerror handling problemā€ in this code ā€” the dreaded (await expr)? pattern occurs only 15 times

Overall, I think the toy examples above should be taken with a big grain of salt. To me, itā€™s not terribly important if you can chain 3-4 await statements after one another if people simply donā€™t write programs like that. I much prefer code that does one thing per line and uses temporary variables as necessary for readability and clarity.

2 Likes

How many times does (await expr).context(..)? happen? Is that the 15 times youā€™re quoting?

We should also note that while this is less artificial of an example, as this was originally written with await!(expr), itā€™s still artificial in that had it been written with these await styles from the beginning, it might have been written differently.

Most prominently, I suspect that the percent of Future/async/await to increase one the async plumbing stabilizes. Honestly, I could see a future (no pun intended) where everything is either const or async. (The only counterexample I can think of is randomness, which is nondeterministic yet non-blocking.)

9 Likes

To a lot of people (await expr).context(..)? are perfectly good. expr.await.context(..) does not provide fundamental improvement, but is violently disagreed. This is what poll tells us.

7 Likes

Absolutely. When C# added async/await to TPL it became the default way I write code. Before then it was an "if I really need to" -- and that's in a language with a GC and no borrow-checker so combinators are less painful than in Rust.

4 Likes

Chaining await with await isnā€™t the prime motivator for postfix syntax; itā€™s chaining await with other operators like ? and method/field accesses.

15 out of 43 is about 35%, which is statistically significant.

6 Likes

Yeah, good point! I guess I just don't like these long chains of code in the first place... A few well-placed local variables makes it much easier for me to understand what's going on.

Yeah, agreed. As the write up says, one would expect to see error handling applied to many of the await calls. However, I was more comparing the 15 lines of code with the 527 lines of total code.

Now, I don't know if this is a typical example of async/await code ā€” but I see it as a counter-example to the idea that async/await code will be littered with await and with the dreaded error handling problem.

1 Like

This could be await? expr.context(..), if context() were a Future combinator instead of an Error combinator...

But context() is inherently something to do with Fail. It does not make sense for all Futures. Making combinators to Future<Fail> for things that are part of Fail is inelegant and clunky unless this automagically works for every method on any Future.

No, I got the number 15 by searching for .await()? in https://github.com/inejge/await-syntax/blob/postfix-method/bin/wlan/wlantool/src/main.rs. That should give us the number of "await + with ? calls" in the code.

If you instead search for .await().context() you find that this pattern occurs 8 times.

2 Likes

Is any discussion about ? in match patterns. It may solve the operator precedence problem:

...
let conn? = await pool.take();
...
let stream = conn.query("select * from rows");
pin_mut!(stream);

while let Some(row?) = await stream.next() {
   // code
}
2 Likes

Regarding parsing complexity there are two remarks:

Field access and method call are not competely equivalent in the language with regards the operator precedence. The difference is minute and occurs when the Output of the futures is to be used as a function (i.e. Output: FnOnce() -> usize for example).

  • under await method chaining as expected: future.await()()
  • with field await paranthesis are necessary: (future.await)()

This doesnā€™t look like a clear argument either way to me (both have a tradeoff) but it is maybe interesting and subtle enough to note it here for clarity.

Additionally, method call await could lose all of its magic of ā€˜where does this function come fromā€™ because methods appearing on a type already have a place in Rust: traits. In fact, await is eerily similar to a trait like this:

trait Await {
    type Output;
    #[lang = "await"] // where `await-call` marks a new, imaginary call syntax
    extern "await-call" fn await(self) -> Self::Output;
}

So much, in fact, that I wonder if it could not only be conceptualized that way but also actually implemented. Requiring no parsing changes and also serving as a single point of documentation that fits into all current documentation tools as well, is discoverable by default, etc.

7 Likes

It looks like that thread was never updated. Was this discussed at the meeting?

3 Likes

It was. We also discussed and reached consensus about a path to resolving await syntax as a whole, which I have created a separate thread for. As to for await syntax in particular, we decided that:

  1. We are not certain we want this syntax at all for a couple of reasons.
  2. If we do, we think we likely can make it not a separate syntax, but just treat streams as processable by for loops as a streaming iterator of futures.
  3. No matter what, we think there are acceptable fallbacks and this wonā€™t box us into a corner, so we donā€™t need to resolve this yet. That is, it doesnā€™t block the initial await syntax decision.
6 Likes

After reading the paper, I have to say Iā€™m a pretty strong proponent of postfix foo.await!(), but that is probably because I am a fan of macro-methods in general. I would love to be able to attach macros to modules mod.macro!() and as at least ā€œstatic functionsā€ on classes Foo::macro!(), if for no other reason than that it could help organize them.

Iā€™m not sure about the lang difficulties of generalizing a feature like macro methods, whereby the macro ā€œtakes inā€ the $self:expr, but I am strongly in favor of having this ability as I believe it could open up some crazy-cool orthogonal features for the rest of the language!

Some minor points on ā€œweirdness budgetā€:

  • foo.await: Rust doesnā€™t have property based access (whereby code is executed when accessing properties) and this would effectively add that. Personally I feel that property access obfuscates code and makes it hard to read. Obviously this is less an issue with a keyword, but I expect that if something ā€œdoes somethingā€ it should have () at the end.
  • foo.await(): better, but because it is actually modifying control flow, I feel the macro syntax is better.
  • other syntaxes: I suppose opening up a completely new syntax (like pipe ->) may be valid and havenā€™t put a lot of thought into it. Personally, I would want it to be part of a new orthogonal design space, not a one-off.

I guess an important point is: if we went with ā€œmacro-methodā€, could it be written in such a way that other macro-methods could be written behind a feature-flag, to make sure that it was a generalizable feature?

2 Likes

Iā€™ve read the various docs which have been posted and Iā€™ve skimmed this thread, and it still feels to me like this decision is missing the forest for the trees. I agree that writing (await foo())? and (await foo()).bar() is a little un-ergonomic and noisy, but itā€™s much, much more intuitive to me than foo().await, in that the former follows the general convention of keywords applying to the entire expression following them. Thus I feel the proposed solution here is worse than the problem it is trying to solve. Furthermore, I donā€™t really understand why this is considered to be blocking for async / awaitā€”we had try!() for years before we got ?, and it feels like we could do the same thing with await!() here while more discussion occurs on our preferred non-macro await syntax. Not doing so seems a little rushed, which feels out-of-character for the lang team which seems generally conservative.

Itā€™s possible Iā€™ve missed some discussion which clarifies these points, in which case I apologize for bringing them back up.

6 Likes

This has been said many times. We donā€™t want to support a syntax that we know that we are going to deprecate within 1-2 release cycles. So a tempoary await!(...) is off the table. This is different from try!(...) because with that, there was no indication that we wanted the ? syntax. Here we know that we are going to get a new syntax and soon, having more discussion is not going to move this forward substantially, so deciding on a syntax now is the best way forward.

4 Likes

Thatā€™s understandable, thank you for clearing that up @RustyYato. That said, I still feel as though not enough consideration has been given to (await foo())?. In the discussion Iā€™ve seen so far, (await foo())? is immediately dismissed for what seem to be comparatively minor ergonomics reasons. Iā€™d like to see some discussion of why introducing new keyword behavior with .await is considered preferable to having users type a couple more parentheses. Perhaps Iā€™m not fully understanding the costs of (await foo())?, since the general consensus seems to be that it is untenable.

7 Likes

If you haven't seen it yet, there is a new post about the lang teams meeting last week where new discussion is happening


I don't have much of a stake in async/await so any syntax is fine with me. I prefer the post-fix syntax because it works well with our existing features, like ?.

3 Likes