A final proposal for await syntax

I don’t really agree with this. To put it simply, I think await $block is just too verbose, if not the most verbose syntax of all await syntaxes hitherto. Compare this with ? which is an unobtrusive syntax.

I suspect we would commonly get things like:

let foo = await {
    bar_computation()
}?;
5 Likes

I disagree that we’d be happier with it, because it’s inconsistent with the other prefix keywords:

  • return(foo) is actively linted against
  • return(foo)? is return (foo?), so I think it’d be weird for await(foo)? to be (await(foo))?
  • and break works the same as return here

I think that’s easy, because it’s the first one which both takes only a value (not a block) and returns something interesting (not () or !).

(To elaborate, even if you { ... }.await, the await is still getting a value, not a block, because it doesn’t treat the block as a closure and doesn’t have scopeful effects on the block, etc.)

Also, it’s an unsubstantiated leap from “in the near term” to “we don’t want”. C# will have had switch foo { ... } for 19 years before it gets foo switch { ... } – could I see rust getting .match in the next 19 years even though I agree that it won’t be “in the near term”? Absolutely. (I could easily see it happening in much fewer than that, plausibly for the next edition. But not this year.)

7 Likes

If await(foo)'s inconsistency with other prefix keywords is a problem, then so is foo.await's inconsistency with other postfix stuff, IMO.

So the comparison I’m making here is a bit more nuanced- await(foo) is much closer on the whole to the constellation of other syntax, even though it still doesn’t fit perfectly:

  • It’s prefix like other keywords.
  • It matches the expectations of two mental models- “await is control flow” (all prefix) and “await is a call” (all parentheses).
  • It has parentheses like other things that bind tighter than ?- function calls and macro invocations.
  • It also behaves like function calls in terms of borrowing, because it evaluates to a temporary.

On the other hand, foo.await is quite a bit different, carving out a whole new space in almost every aspect:

  • There is currently no such thing as a postfix keyword at all, with or without a dot.
  • It’s closest syntactically to field access, but differs significantly in semantics from it.
  • It’s closer semantically to a method call, but differs in syntax by lacking parentheses and not having an equivalent prefix form.

This feels like it’s twisting my words. You can add “in the near term” to both halves of my sentence and get the same meaning- I’d strongly prefer we approach any dot-postfix syntax holistically, and avoid a situation where we can’t make them all consistent.

It’s a fair point that await is the first keyword to take a value and evaluate to a value. But I’ve seen little-to-no consideration of what else might land in this category and how that might interact with our choice of syntax here.

For example, suppose we get generator arguments. While await can arguably be seen as an operation on the value, the same cannot be said of yield, which a) does its work completely independent of the value or its type, and b) evaluates to a value completely unrelated to its input.

Maybe in the end we won’t care whether await and yield feel at all similar. But I’d rather we decide that on purpose than stumble into it, and any other unforeseen dilemmas, without giving ourselves the chance.

7 Likes

I think this is indeed an interesting argument and I think worth adding to some summary (I am trying to create a “master summary”, but I’ve not had time to finish up yet, and I would include it there).

I don’t agree with your conclusion, though: let me try to elaborate why. I guess perhaps it’s an uncanny valley argument – to me, having await(foo) be so close to other keywords and yet with this critical difference is quite surprising. It differs in two respects:

  • It requires a (non-block) delimiters
  • It has unusual precedence

That is, return(foo)? would be parsed as return ((foo)?). But await(foo)? would be parsed as (await (foo))?.

That seems bad and quite surprising to me – more so that foo.await. I guess it’s like this: foo.await feels like something where you might say “oh, that’s unusual”, but you’d have no real doubt as to when it takes effect. I can’t say the same for await (foo)?, which I consider quite visibly ambiguous.

If we’re going to use mandatory delimiters, then, this leads us to either await[foo] – which to me is quite “foreign”, this is not indexing, and indeed shares many of the same concerns as foo.await – or to await { foo }. Here, I agree with @Centril that this feels clumsy and verbose, and … kind of surprising. We don’t expect you to “await” a long block of stuff.

I think you can see a similar tension in unsafe { } today. The original intention, I believe, was that one would use unsafe { ... } on a long block of instructions. But it has become a more prevalent style to push unsafe { } down to the actual operation that requires unsafe, and in that case, the { } feel unnatural – we’ve kept them because it feels important to be clear about the scope of unsafe (though I’m not sure that’s a good argument, I might be inclined to reconsider that point).

15 Likes

It is well worn territory that the rust team is already aware of (I have discussed it at the SF Meetup, this is an acknowledged issue) that users do not participate in RFCs because they are extremely noisy and hard to follow, among many other reasons (self esteem, lack of familiarity with the process, etc).

I have no interest in an argument to the contrary since it’s proven every single time a syntax feature comes out.

How should they fix it? No idea, but it’s their responsibility to do so.

As for how someone may not have seen the conversation, I absolutely do not think that .await was a widely published option before the last month. I read rust news every day, I’m pretty active in the community, and it was new to me.

5 Likes

Over the last few days I have written some code with about a 300 occurrences of .await (with the somewhat decent compat features of futures 0.3) , and I’d like to share some personal observations.

I’ll cover ease of use, observability, oddity factor and language switching cost.

Utility and ease of use

My opinion that a postfix syntax is very desirable has been reaffirmed. .await is very easy to type and makes writing code really fluid and convenient. It prevents the need for introducing unneeded locals, and also avoids falling back to combinators.

With .await chaining, applying await becomes really effortless and is very nice to write. Something that’s usually not exactly a strength of Rust, with it’s tendency for boilerplate.

For example, in Javascript I would sometimes use Promise combinators to prevent locals.

// Use of combinators.
async function load() {
  return fetch(...)
    .then(r => r.json())
    .then(response => response.data);
}

// With locals
async function load() {
  const res = await fetch(..);
  const json = await res.json();
  return json.data;
}

The equivalent comparison in Rust would be:

// With combinators
async fn load() -> Result<Value, Error> {
  reqwest::get(..)
    .and_then(|res| res.json())
    .map(|value| value.data)
    .await
}

// With locals.
async fn load() -> Result<Value, Error> {
  let res = reqwest::get(..).await?;
  let json = res.json().await?;
  Ok(json.data)
}

// With chaining.
async fn load() -> Result<Value, Error> {
  reqwest::get(...)
    .await?
    .json()
    .await?
    .data
}

One might argue that it’s almost too easy to use. (See below).

Observability

Combined with how easy it is to apply, I noticed that I really stop thinking about if I really want to suspend here or not. I just slap a .await at the end and move on.

This is often fine, but it also can cause problems. I stumbled over two such examples:

  • I introduced a bug because I was incrementing a AtomicU64 after a suspension point, which really needed to be incremented before hand because I appended a .await on to a function call without thinking about it
  • I made a particular function too slow because three things that were supposed to happen in parallel happened one after the other, again because I just took the convenient route of .await without thinking about it.

You could of course argue that this is just my mindlessness at fault.

But I do think a noisier syntax that looks less like just a field access causes a bit more of a mental stopgap that makes you consider if suspending right there is really what you want.

The noiselessness of the syntax can therefore both be seen as a feature and as a detriment , IMO.

Also, without syntax highlighting, .await becomes very easy to miss. This is real annoyance in eg git diff. .await looks just like a field access and doesn’t stand out at all. This is alleviated a bit by the fact that .await? will be very common, which stands out more, but it’s still an issue for me personally.

Even with highlighting, it still is weird for me to associate it with control flow rather than just a cheap field access. This is something that could become better with time, if you are writing a lot of Rust code.

Oddity factor

I did notice that .await becomes natural to use very quickly. I stopped thinking about it quickly when writing code.

Yet, when reading the code rather then writing, it still looks very odd to me that value.await is not just a field access but actually suspends the function.

I just have trouble separating field access from the control flow that await introduces, and I don’t think that is something that would go away with time.

As I mentioned in a previous post, I’m really worried about this syntax creating a permanent weirdness factor in the language, since DOT can not be a general pipeline operator (unless we also get universal function call syntax). I still hold the view that DOT is a very questionable choice for this regardless, and it’s doubtful to me that this syntax could be accepted for other language constructs like .match, .if, …

Future consistency of the language should be an important concern.

Also, the syntax will be very odd for users not familiar with Rust or who write little Rust code. (The same would be somewhat true for any kind of postfix syntax though, I reckon) This leads to my last point.

Language Switching

One very real detriment I discovered is switching between languages and familiarity concerns.

While writing code over the last few days, I was often switching between Javascript and Rust, and this was a real headache for me. I kept trying to apply prefix await in Rust, and after a while, then started to use .await in Javascript, which the obvious results.

I believe this will be a real issue for developers using multiple languages, because you constantly need to readjust and move code around.

Almost every language that supports async/await has the same prefix syntax for it, and Rust will be the odd one out. This could become a real annoyance for devs that don’t write Rust often, or write multiple languages with async await on a regular basis.

The mental and familiarity cost is very real and should not be dismissed lightly.

Conclusion

I assume that, with the lang team having consensus and the syntax already being in master, the decision is pretty much made.

Personally, after writing a pretty decent amount of code and extensive consideration, I have to say that it is a decision I can live with, but not one I would personally make.

I still believe introducing the standard prefix syntax, and then working on a postfix pipelining would be the wiser long term choice, due to three primary reasons, mostly outlined above.

  • long term language consistency
  • clear distiction of concepts (field access vs control flow)
  • cross language familiarity (With prefix await available, devs could always use the familiar and common syntax, with a more rusty a postfix variant being available for those that want it)
47 Likes

Thanks for the feedback. Quite interesting.

3 Likes

We have universal function call syntax (UFCS). That’s what allows you to do Type::method(this). What we don’t have is universal method call syntax, which would allow you to do this.module::free_fn().

I can vouch for language switching being a big deal. I wrote primarily Kotlin for a good two years or more, and it always takes be a long time to switch back to writing Java. And now that I’ve written primarily Rust for about two years, I’m actually in a similar position for Kotlin.

Language switching has a cost. It’s a decent goal to minimize the cost when you’re advertising a new language, but it’ll always be there, so optimizing for it seems like a goal of little importance.

1 Like

Thanks for trying it out!

This is a great point, and also a similarity to ?. We’ve seen a variety of PRs for try_fold which often end up having subtle before/after-the-return bugs.

Any thoughts on how hard it is to tell based on the surrounding code whether you’re in a place where “just slap await or ? on everything” is probably fine, or where it’s a more complex situation that needs careful inspection? I’ve found ? way easier than exception-safety because there is that marker, even though it’s subtle.

The example in there reminded me that I was wondering whether formatting .await like other postfix operations will help emphasize that it’s not a field access. Something like

async fn load() -> Result<Value, Error> {
  reqwest::get(...).await?
    .json().await?
    .data
}

Where the .data is distinguished by the layout too.

5 Likes

I always found this naming convention wrong/up side down, since “UFCS” gives me a way to call a method (or “associated function”) with a different syntax, while “UMCS” let’s me call functions with a different syntax.

For example D also calls “UMCS” UFCS aka uniform function call syntax.

The Wikipedia article defines it this way, and even criticizes Rust for it’s naming. (which is a bit weird actually)

I disagree that it is of “little importance” because await is a popular feature in other languages and not being compatible has a considerable cost for learnability and can play a role in a language choice for adopters. I’m not saying that it should be the deciding factor, and I did mention that postfix is very desirable. But it is a decision with consequences. The problem is somewhat exacerbated by being the same keyword, but used differently.

The situation with ? is different to me because ? was new, dedicated syntax with no previous equivalent in the language (post 1.0). It’s also a noticeable sigil so it stands out even w/o highlighting.

The equivalent comparison would be introducing postfix try.

Eg:

async fn load() -> Result<Value, Error> {
  reqwest::get()
    .await
    .try
    .json()
    .await
    .try
    .data
}

I find this really cryptic.

Let’s go one step further:

async fn load(key: String) -> Result<String, Error> {
  is_cached(&key)
  .try
  .if { from_cache(&key) } else { from_db(&key).await }
  .try
  .data
  .field
  .match {
     Enum::A(x) => x + 1,
     Enum::B(x) => x * 1,
  }
  .to_string()
}

My main objection is really about mixing together two distinct concepts in the same syntax.

5 Likes

(This isn’t exactly on topic, but:

UFCS (unified function calling syntax) allows you to call anything like a function, as if it were a function, with function calling syntax. Thus it being UFCS.

The same for UMCS: calling things that aren’t methods as if they were methods.)

2 Likes

I might agree with the uncanny valley argument, if we intended there to be a space between the keyword and the delimiters. But some_function (foo)?, with a space, is just as visibly ambiguous and something we avoid. It’s unidiomatic in f(x) languages, and rustfmt doesn’t even have an option for it.

For that matter, return(foo)? looks nearly as ambiguous in the other direction. While some styles in other languages (e.g. C) do use return(, they never have any reason to put anything after the ), and so this relatively rare style doesn’t seem to cause problem there like it would for us.

So to me this is really a question of whether people will expect there to be a space, simply because await is a keyword. I suspect that if they are introduced to it without, as await(foo)?, that problem simply won’t come up.

I also suspect this will help when scanning code, a situation when you mostly see spacing and punctuation rather than text. For example, &mumble(foo)? behaves consistently across await, yield, and some_function. In contrast, &foo.mumble? does not.

2 Likes

Just a thought experiment on designing with preventing these problems as the main consideration, something like an await rebinding statement would force a very explicit consideration of when you want / need to await futures.

async fn async_foo() -> Result<i64, Error> {
    let a = async_bar();
    let b = async_baz();
    await (a, try b);
    // use a and b
    ...
}

This forces you to await futures in a separate statement from where they are created because they have to be assigned to a variable first. Await takes a list of bindings, where the new binding is assigned the result of awaiting the previous binding with the same name. I’m using an invented try binding modifier as a way to apply ? to the result of awaiting the future before binding it to the variable. By using two bindings, I’m essentially joining the futures so they can execute concurrently, This construction also means that await is always the first token on a line under normal code formatting.

I think this definitely goes too far the other way, I just wanted to think about what the feature might look like if the intent was to help you avoid the problems you encountered as much as possible. Obviously, as you’ve shown in your examples, there are good use cases for being able to chain awaits.

1 Like

Maybe this is a dumb idea, probably already considered, but what about someVar:await ?

That is, just use a single colon instead of the double colon usually used to denote associated types etc. This does mean it’s replicating the manual type specification operator, but imho the behavior of await would end up being closer to that than a field or a method (as suggested by a .)

EDIT: Another couple ideas I could think of that seem within hailing distance of reasonable while trying to preserve the meaning of . for fields/methods would be someVar#await or someVar!!await.

someVar await would at least have the upside of being consistent with other keyword denotation but I can see how this would get confusing.

To me the most confusing thing is that the proposal seems to require memorization of which connecting character is used for which associated struct entities. Methods and members use ., associated types use ::, etc.

If everything used ., that’d be easier to remember than mapping the type of the entity to the operator you need to use. It’s also easier to type.

But Rust has seemed to opt for distinguishing what syntax type that entities are, and I’m used to there being a very strict expectation that things that look like fields are only fields (no properties like other languages).

Sorry for the imprecise jargon and minimal formatting, typing this on my phone atm.

2 Likes

See:

It was discussed many times and was mentioned in the final proposal (though very briefly) and in the @nikomatsakis's summary.

1 Like

New revolutionary idea for await syntax:

foo...await...?bar()

You even read this with pause!

2 Likes

That is very much not a revolutionary idea.

13 Likes

I can second this from my own (limited) experience. Writing .await? felt very natural to me, perhaps because I’ve become accustomed to writing ? – it feels like “discharging” the extra details from calling the function so I can get at the thing I want. I think that going back to insert an await at the start of the line would have felt rather different.

I find this interesting and i’d like to drill into it a bit:

  • First, I’d be curious if you are able to point to the code in question (is it public?).
    • What is this atomic increment for? What data is being communicated here?
  • Second, I’d like to know the order of events.
    • Did you add the increment before or after the suspension point?
    • When did you find the bug and how long did it take you to diagnose the problem?

In asking about the point of the code, it’s because I’m trying to compare this to my own experiences in writing blocking threaded code. It seems like the .await was some kind of synchronization point that might read this counter, and I’m trying to understand how that came to be.

With regard to the order of events, I’m trying to separate out two scenarios:

  • If you had to interrupt your editing to insert an await in prefix position, you might have noticed the order vis a vis the increment at the time;
  • or, you would have seen it earlier when debugging?

I have not used async fns a lot in practice, but my assumption is that – over time – one gets quite accustomed to inserting an await and it becomes second nature, in which case these same sorts of errors might occur. It seems like you do have a lot of such experience – can you think of times, say when writing JavaScript code, that you detected subtle ordering bugs early as a result of having to interrupt your flow and insert an await?

Might it be possible that stylistic guidelines, such as writing .await into a distinct let binding, would be helpful here too?

I’d like to drill a bit into why it is important for it to stand out in git diffs. One thing I can imagine is that, during code review, it might help reviewers to spot ordering conflicts like the one above? This seems like it might be true, although it’s very hard to judge the “magnitude” of such an effect – if the reviewer knows that ordering is sensitive in that function, and in particular that the ordering is important with respect to an await, it seems likely that they can look for it either way. If they don’t know, they don’t know. So I guess the difference might be in some case where they are not actively thinking about it but the visibility of an added await makes them aware.

This is an interesting point I personally had not considered before. I’m not sure how to “weigh” it overall but I’m glad you brought it up.

7 Likes

(Note though that, given that JS code’s run-to-completion semantics, the situations are not entirely comparable.)

1 Like

This is an interesting idea for readability, though I think it would get frustrating to type