Confusing syntax of `.await`

Split from:

Wait, what's bad about postfix await? I've always found it way more elegant than e.g. in JS or Python where it's literally just parentheses everywhere.

1 Like

The principal problem is not the postfix position, although I do find that lingustically unpleasant to read, too (for a future waiting Yoda is). The worse problem is exactly the syntactic conflation of different concepts.

Postfix .await looks like a field access – in fact, lexically it's exactly a field access, it's literally dot-then-word. But semantically, it's so far from a field access it's not even funny. It has side effects, it can actually jump between tasks and threads, and it's not even a projection. To this day it still confuses my eye reading a .await, perpetually having to backtrack and re-parse the code when I realize that oh, this is no member access, this is the context switch that they thought was a good idea to disguise as a member access.

And I simply don't buy the "parentheses everywhere" and "chaining is a serious issue" arguments. I've been programming in Python since around 2013 and not once was "too many parens due to prefix await" an issue. If one day I were to write such convoluted code that I need to await on multiple nested futures in a chain, I'll just pull each subexpression into its own variable on its own line, and that on its own will improve readability.

3 Likes

Arguably, field access is “dot-then-identifier” whereas this is “dot-then-keyword”. OTOH, I would agree that - especially on a lexical level - keywords and identifiers are similar, if not the same, which is also supported by how macro_rules macros accept keywords to match $foo:ident patterns, IIRC.

In any case, a more precise characterization of “field access like” syntax should probably also mention that there's no ( following the field name, otherwise it becomes a method call expression.


Trying to think of other examples for comparison where keywords make the difference to otherwise also legal syntax with unrelated meaning...

... I could come up with not quite convincing examples like let (a, b); or return (a, b) looking like a function call, though typically spaced differently. Maybe come someone else can think of anything better.

5 Likes

It's not an issue before python doesn't use foo().bar().baz().qux() fluent style often.

And it doesn't do that because Guido doesn't like it.

Rust does, fluent is very idiomatic here (partially because, unlike python, there are no named arguments for functions) and in this context prefix await would have been painful.

Why fluent style doesn't cause such a reaction? Would it help if rust analyzer would add white parens to the .awat “function call”?

But we have already discussed that in another thread. Some even felt Rust doesn't have enough Postfix things.

I think your trouble comes from the fact that you perceive await as some special thingie not related to function call at all. For me it's sibling of the good old GetMessage function which did the exact same thing .await does today more than 30 years ago (it also delivered messages, yes, but that's additional role). And it was real function back then, why wouldn't it look like a function today? For me just lack of parens is a bit unusual.

In reality it's not postfix vs suffix. The rouble comes when you have both. List only have prefix — and it works beautifully. Forth only have suffix — and that's works, too.

But people don't like regular, uniform, syntax. They want variety… and then they complain that things are different like here, with self!

Well, yeah, self is special! Duh.

2 Likes

Because it isn't syntactically misleading. foo.bar().baz() is literally just calling two functions on each other's return values. It does what it says on the tin.

Because the designers of the language decided that it should have special syntax. That in itself annoys me infinitely, too, given that futures can be handled just fine using a suitable combinator library. But async-await is all the hype nowadays, and Rust hopped on the hype wagon. It would be for the better if this weren't a language feature but a library feature. That ship has sailed, though.

1 Like

It's not just a matter of Guido not liking it. Python is a very different language with very different design constraints, coming from a time where mutable state was the norm. It's also dynamically typed in principle, and very loosely typed in practice (lack of type enforcement encourages many people to store whatever wherever).

The chaining syntax is prevalent in Rust because Rust focuses on immutability, and when your objects are immutable, your transformations return a new object instead of modifying the old one. While Rust is not dogmatic about immutability, its strong typing and the abundance of wrapper types mean that the transformed object is of a different type anyway, and can't be stored in the same variable, even if it's semantically very close (like with various iterator and future combinators). The rich type system means that even if you want to modify the object, you would often use a chainable signature fn (&mut Self) -> &mut Self, which doesn't exist in Python.

On the other hand, in Python there is little motivation to return a new object instead of modifying the old one, without an AoT or JiT compiler it could make performance even worse, and you can't rely on immutability and types to let you compose methods anyway. You need to read the function's docs, learn how they work and what they modify.

Python's significant whitespace also means that, in the Ancient Era before widespread IDEs, correctly formatting a method chain would be a huge pain, and even more likely to break during copy-pasting.

Overall, while Python is slowly progressing towards an immutable-first mindset and adopting chaining transformations, it is really often easier and more clear to compose functions, rather than write method chains.

I consider this so-called fluent style to be overused in the design of Rust. It emphasizes small-step transformations instead of the states which you move between, and homogenizes the syntactic structure of the language. If you think that's good, go read some Forth, because that's the language built around those principles.

But for await in particular, I consider it a correct choice.

No, lexically it's not a field access. It's a postfix operator, which just happens to consist of a keyword, for better readability. Being applicable to fields and variables means that a keyword postfix operator needs a separator token before it, and dot is the easiest unambiguous well-known way to make that separation. You could use a different token, but without language precedent it would just be confusing. You could use whitespace separator, but that would mean that parsing foo await.bar is now confusing and potentially ambiguous, so you would need parenthesis, which would negate any benefits of using a postfix operator.

Syntactically, .await isn't in any way different than ?. Currently Rust doesn't have any other postfix operators, but it could get them. We could denote .await as !?!, and then your objection would be entirely moot, but it would be less readable, less searchable and less discoverable for people already familiar with async-await in other languages.

The postfix form is much better than prefix one exactly because of ? operator. Compare:

// prefix
let _ = (await  foo.bar())?.baz();
// postfix
let _ = foo.bar().await?.baz();

The latter is easier to both write and read, since it doesn't have any redundant parentheses, weird expression nesting and jumping around to write or understand the line. The dangling ? after the parenthesis in the former example also looks weird, while await? reads like a single token.

Compare with how you could write it in Python:

# prefix
baz(await bar(foo))
# postfix
baz(bar(foo).await)

The difference is negligible, and the error propagation is handled implicitly by exceptions, which are discouraged in Rust.

In the ideal world (which we are far away from), async code would also be essentially the same as sync code, just with possible suspension point explicitly annotated. Migrating code between sync and async with postfix await is often as easy as mechanically adding or removing the required keyword. With prefix await, such a migration would require significant code restructuring. If you are maintaining both sync and async API, it would be much harder to track that they don't get out of sync. If you are trying to write an await-agnostic macro, with prefix await it is unreasonably hard to do.

3 Likes

Better for whom, specifically? If you want clever, clean and powerful language (which almost nobody outside of research are using) then there's always Haskell.

Rust is always trying to keep balance between clear design which would send people away, reeling in horror (see ML, Haskell, etc) and ugly warts which would, nonetheless, bring people in droves (see JavaScript, Perl, PHP).

Viewed from that perspective choice of async/await is pretty good. And postfix syntax is better than prefix, most people like it after using it for awhile.

I quite like Forth since it was 2nd language I've learned (that ancient 8-bit thingie I had in school couldn't deal with Pascal well), and it's GIGO approach leads to really small and really powerful programs… when you work alone.

Sadly, working alone is not something I can do often nowadays thus FORTH is not an option.

Maybe in some different universe FORTH descendants would have been all the rage… but in our universe curly-bracket languages won.

That means that in spite of all that usability advantages postfix operations are considered “weird” thus Rust uses them sparingly.

Better for those who prefer conceptually clear syntax over an actively misleading one, or a smaller surface language over a bigger one.

By the way, Haskell is anything but "clean". It has many inconsistencies and warts, much more than Rust. The equivalent of associated types and type projections doesn't exist unless you turn on a bunch of {-# LANGUAGE #-} pragmas, for example; the gazillion ways to write function calls (e.g. `infix` ) are annoying to read; import statements bringing everything into scope by default causes a massive pain with unintentional name clashes (and import qualified is just plain too ugly and verbose); the several incompatible build systems layered on top of each other are painful to use. Haskell is very clearly an academic language in the sense that they apparently paid zero attention to issues that come up in everyday software engineering practice, and this makes an otherwise powerful and useful type system artificially less accessible than it intrinsically could be.

2 Likes

You are confusing lexical conventions with semantics. The problem is that it looks like a field access even though it means something else.

Obviously, if it weren't written as .keyword, then I wouldn't complain about it being written as .keyword. This does not refute my point, because it's true of any complaint that "if it weren't so, your point would be moot". Sure, but it is like that currently. I'm not arguing against a hypothetical scenario, I am arguing against a very concrete status quo.

3 Likes

You are coming into this with misconceptions and incorrect prior expectations, and then become confused. That's on you. A dot doesn't denote a field access. Moreover, already existing inherent method calls and dynamic trait methods calls also use the dot notation, and they couldn't be farther off from the simple field access. And even field access is not simple at all, because it includes Deref coercions, DerefMut coercions, and even the "DerefMove" coercion on Box<T>. All of them can execute arbitrary code, including throwing a panic or doing side effects. &self.foo can talk to network, yes.

In the future Rust could also get more postfix operations, including postfix macros, field projections or postfix match.

2 Likes

unsafe { x } is the same syntax as Foo { x }, except that the former is a keyword so means something completely different.

But, as always, it's not just a question of whether it looks similar. After all, x.foo() isn't accessing a field either -- despite containing x.foo which sure looks like a field access -- and that seems to be totally fine. Like your return(a); example, it's all about mitigating factors, like how long a misconception can reasonably last and how much damage it does in that time.

One could write a fair bit of rust code under the (incorrect) mental model that foo().await is a synchronous call to a function that returns a struct Task<T> { await: T }. Will it be optimal? Almost certainly not. But will it work basically fine? Yup. And as soon as they look up await in the docs -- or the async that they also had to see/write for the .awaits to work at all -- you can learn about how it actually works. (If for some reason you weren't already curious about why it's bold under even the most basic of syntax highlighting, even for the wrong language.)

10 Likes

It's also worth noting that the entire point of async.await is to write code which "looks synchronous."

At an implementation level, await leads to a generator style transformation such that async produces an impl Future with a synchronous poll operation.

But at the level of the async context, await is just a blocking (the task, not the thread) operation which can unwind (but without panicking, via dropping the future). It's perhaps interesting that this is a "blocking DerefMove" roughly equivalent to a function call, but to the async block, it does not matter whether await is implemented with OS threads or green threads or stackless coroutines or continuation passing or any other paradigm.

Of course, there's a performance pitfall in async to actually blocking code due to how Futures are expected to behave (poll only does some small amount of O(1) nonblocking work). But from the point of view of the async execution, you can accurately describe the effect of .async as doing a (task) blocking DerefMove to access the magic await member (so long as you allow for task unwinding without setting thread::is_panicking to occur).

It's this isomorphism that makes the "explicit async, implicit await" model work. This is the model Kotlin uses, for example. (TL;DR: calling async fn immediately awaits it. If you want to do e.g. join, you make (essentially) closures to defer execution of the function call.) I stand by that Rust could've used this model, but that explicit .await was the correct choice for Rust, because of the observable effect of holding live bindings across await points (e.g. Send/Sync inference) and the importance of await being a suspend point for writing correct unsafe code managing unchecked lifetimes.

7 Likes

Editors with syntax highlighting have no problem distinguishing .await from field access.

2 Likes

Is this back and forth taking on an unintended energy or is it just me? I’m enjoying how much you both know and have thoughtful, articulate POVs.

2 Likes

The one advantage of the literally thousands of posts (here, IRLO, GitHub, reddit, in blogs, ...) about await syntax back in 2019 is that the arguments got incredibly refined and practised.

(Thought that also massively burnt out a bunch of people, which was very much not good.)

8 Likes

Now I'm trying to figure out what a postfix unsafe looks like :thinking:

I've posted this elsewhere, but much as I pushed for postfix await, I actually don't like postfix unsafe.

To me, the important distinction is between things that work on a value, where postfix is great, and things that work on a scope or a thunk, where prefix is better.

You can, in fact, see this directly in async.await, where despite await being postfix, async isn't! That's because async affects the whole block, not just the value produced by the block, so it's useful to have the "warning" up front about what's coming.

Basically, if foo { STUFF } does the same thing as let temp = STUFF; foo { temp }, then postfix foo is fine. That means things like ? and await and maybe even things like match or if. (And even return, if you wanted, though because that's -> ! it's not terribly useful to make it postfix.)

But things like unsafe or loop are much better as non-postfix. { let x = foo(); bar(x); }.loop is super-confusing. Nor would I want (x > y).while { x += 1; }, since that condition needs to run multiple times, not once.

Similarly, the upcoming const and try blocks affect all the code in them, so are still best as prefix-blocks, not as postfix.

7 Likes

Postfix unsafe wouldn't mean just moving the keyword unsafe to a postfix position. It would follow the same design as try or async blocks: you have unsafe { } blocks which denote the scope of invariant-violating operations, and you have some explicit marker (e.g. postifx !!) to mark the places where specific violating operations happen. I.e. your code would look something like this:

pub fn into_boxed_slice(mut v: Vec<T>) -> Box<[T], A> {
    unsafe {
        v.shrink_to_fit();
        let me = ManuallyDrop::new(v);
        let buf = ptr::read(&me.buf)!!;
        let len = me.len();
        buf.into_box(len).assume_init()!!
    }
}

The design logic is that unsafety is an effect, meaning that it gives you access to new operations absent in the base language (operations on raw pointers and calls to unsafe functions). An unsafe { } block marks the scope of the effect. unsafe fn means a function which has (i.e. doesn't handle inside and exposes to the consumer) the specified effect. Effectful functions may be called within an effect block. The specific effectful operation is marked with the !! operator, similar to the .await operator for async blocking calls and ? operator for short-circuiting error propagation inside of a fallible code block.

Unlike async, unsafe doesn't have an explicit effect handler. An effect handler is a function which turns effectful operations into actual execution. An effect handler for async is an async executor. An effect handler for alloc is the allocator which performs the allocations inside of an alloc block (doesn't exist in the language). An effect handler for try is an operation which turns Result<T, E> into a value of T, so most simply it's just a match on Result<T, E>. An effect handler for generators is the loop-driving logic, i.e. basically the for loop.

unsafe doesn't have a specific effect handler. The unsafe operations are directly executed at the point of their call, without requiring a separate handler, and the invariant violations possible within unsafe { } scope are directly asserted to be sound at the scope boundary by that same scope. This makes it more complicated to talk about unsafety as an effect, but it's also nothing unprecedented in Rust. Somewhat similar things happen when you allocate memory, which just happens directly without an intermediate handler (even though it would be useful for ergonomic arena-based allocations), or when you panic (the panic just directly happens, and there is currently no scope annotations for panicking or non-panicking operations, although catch_unwind serves as the effect handler).

Tangent, this is also why I am strongly against the style forced by some people where unsafe { } is shrunk to a trivial marker on unsafe operations. That makes unsafe { } pretty useless. It should mark the scope of invariant violation, where you can assert that all contained operations as a whole preserve soundness. It is almost always useless to talk about the soundness of single unsafe operations, because a single operation would have preconditions and postconditions which are usually hard to dismiss in safe code (an important exception is FFI).

2 Likes

But wouldn't be { x += 1; }.while(x > y)? Then it would work fine IMNSHO.

I agree that it's good decision for our world, where curly-bracket languages won. But I, personally, wouldn't mind “postfix while”.

For me as someone that doesn't use async and await much, it was always more of a holistic issue. The dot notation for await "hides" control flow at the end of expressions. Now, if we had a culture of generally being more pro opt-in defensive strategies in code that wouldn't be too much of a problem.

I could just set avoid_trailing_control_flow = true and have it put a line break before .await[?] automatically, making it much harder to miss while skimming code. But the project and community in general frown on these kinds of customizations. So for me, things do get harder to read whenever we add control flow to more positions where I have to explicitly go looking.

So generally speaking, I'm not fond of foo().bar().await for the same reasons I wouldn't want to use foo().bar().break. I don't want to scan the end of lines for control flow.

I also don't write

match x {
    Some(n) => n,
    None => break,
}

but always

match x {
    Some(n) => n,
    None => {
        break;
    },
}

for the same reason. Can't do much to make ? more prominent without going off-road, but I dislike them in the middle of expressions, so I tend to avoid that as well. Basically ? only goes at the end of expressions for me.

1 Like