A final proposal for await syntax

As a follow up to @ekuber’s comment, their PR has been merged:

2 Likes

You can create a defer in user-space. I explored that in this thread:

let x = async {
    let foo = Defer::new(async_foo()).await;
    let bar = Defer::new(async_bar()).await;
    qux(foo.await?.baz(), bar.await)
};

Bit heavy-weight, but you can use it today, no need to wait for changes to the language.

With a macro it becomes more manageable:

let x = async {
    let foo = defer!(async_foo());
    let bar = defer!(async_bar());
    qux(foo.await?.baz(), bar.await)
};

Theoretically you could even create a proc macro which implements your syntax:

let x = defer! { qux(async_foo().defer?.baz(), async_bar().defer) };

I also need to point out that there also exists join and try_join functions which allow you to run Futures in parallel:

let x = async {
    let (foo, bar) = join(
        async_foo(),
        async_bar(),
    ).await;

    qux(foo, bar)
};

They’re not as flexible as defer, but they’re faster, and in my opinion easier to understand.

2 Likes

Isn’t this a use case where the prefix await syntax can shine?

let (foo, bar) = await (async_foo(), async_bar());

This is independent from whether you use prefix or not, for the most part. It comes down to whether we introduce an IntoFuture trait and implement it for tuples. I believe we can decide to do this at some later point? (But I've not thought too hard about it.) I understand there was such a thing but it was removed for being confusing.

(It is true that if we had await with mandatory parentheses, it could take a comma-separated list of futures, though this would require building the "join" operation into std -- well, so would IntoFuture I suppose.)

I think the present plan of building this out in libraries (at least that's how I understand it) makes sense to start, anyway.

6 Likes

I have an experience report to share.

Yesterday, at work, I wrote some async/await code. I had been struggling to write the code without it, and then it fell into place quite easily with it, so I gave a small cheer. My nearest co-worker asked if I finally got things to work, and wanted to see what the code looked like. I’m not sure what his level of Rust proficiency is (It’s a new job!), though he at least knows some stuff. The project he works on is mostly TypeScript, and uses async/await there. I haven’t participated much in this thread, but prefix await is my preference.

We proceeded to go through about two years of the debate in about five minutes. Here’s the relevant part of the code:

future
    .await
    .expect("promise didn't resolve")
    .as_string()
    .expect("JsValue wasn't a string")

There was of course some more, but this was the most relevant bit. Here’s a from-memory re-enactment of our conversation. Any mistakes are due to my memory :slight_smile:

Him: Wait, is await a… field?

Me: No, it’s a keyword, just postfix.

Him: That seems a bit odd.

Me: Yeah, but let me show you why. (I opened up the playpen) imagine we used the JS syntax:

let value = await future;

Okay so, this is easy. But what about when the future can error? You’d need

let value = await future;
let value = (await future)?;

Him: Oh, because of the precedence of ?? Why not change the precedence of ??

Me: Well, that is an option, but remember, this conversation started off with “oh that looks like a weird special case.” This would also be a weird special case.

Him: That’s fair.

Me: Also, let’s look at the code that motivated this conversation, and what it would look like with both syntaxes:

future
    .await
    .expect("promise didn't resolve")
    .as_string()
    .expect("JsValue wasn't a string")

(await future)
    .expect("promise didn't resolve")
    .as_string()
    .expect("JsValue wasn't a string")

Him: Ah yeah, that does look worse, and also, it gets exponentially worse if you need to chain.

Me: Yep:

let value = future
    .await
    .foo()
    .await
    .bar()

let value = (await (await future).foo()).bar()

Him: Exactly. We run into this in our TypeScript codebase a lot, and it is really unpleasant.

Okay, you’ve sold me. It’s a little weird at first, but I do think it’s better. And besides, you just know that tomorrow, VS: Code will highlight .await in like, neon blinking red or whatever, and it won’t even look like field access.

(He’s got a good sense of humor.)


Anyway, this is obviously just one conversation, and who knows how said conversation would have gone without the other person being me; that is, without someone who can explain the context.

As a side note, at the start of this post, I mentioned that I’m personally more pro-prefix syntax. I still think ultimately that’s true, but at the same time, I’ve made my peace with the postfix syntax. More than anything, I just want this feature to finally ship. .await is still reasonable, even if it’s not my preference.

40 Likes

Is there any plans on how or why .await is supposed to interact with type deduction?

struct Foo<X> { .. }
// Only implemented for one specific X
impl Future for Foo<u8> {
    ...
}
impl<X> Foo<X> {
    fn new() -> Self { ... }
}
async fn foo() {
    // This type deduction works for methods, but usually not fields
    Foo::new().await;
    //        ^^^^^^ will this complain about 'type must be known'
    // Note: It doesn't on nightly, but is this intended?
}

Wow, I thought this was a joke too. Don't do this, note this will not work for us with US keyboards, we don't have US international layouts in the US generally without requiring the user to configure something special IE physically changing the layout of our keyboards. None of the suggestions worked for me that would be simple, a lot of keyboards in the US don't have altgr (which is often NOT the right alt key) and on laptops its even worse. Alt + 1 doesn't work on a standard US keyboard on either side, alt + ctrl doesn't work for either side, and often corresponds to odd shortcuts. Indeed the only way I was able to type ¡ was with ALT + 173 only on the numpad (or using another number code).

Note I have a full five US layout keyboards all different where there was no other way to type out ¡ with out ALT + 173 only on the numpad, on linux It gave even stranger results when I tried to do ALT + 1 (not a random symbol, but printed (arg:1) in terminal [didn't matter the side that alt was on], and in IDEs it selected weird things).

It is also available using a standard US keyboard by switching to the US-International keyboard layout.

READ THIS! and please stop suggesting this! Basically anything that isn't just shown on a standard US keyboard is not going to be easy to type for standard US keyboards, and as you've seen its even worse for non US keyboard layouts.

7 Likes

¡If only I knew enough of compiler internals even to prototype it! :wink:

Fear not. There is rumor of compose software for Windows and any real editor should be extendable to enter ¡await with minimal contortions.

Okay, so I'm a typography nerd and just like how it looks. You've outed me. But this topic is about language design and syntax.

If ASCII is essential, the main arguments remain for @await, @wait, or #await as a strict improvement over the current .await, which will be confused as field access.

1 Like

With my language team hat on: I find this argument really compelling and worth exploring more.

What would code that awaits multiple futures in parallel (rather than in series) typically look like?

If we want to make it easy to await multiple futures in parallel (and in particular, not await a future until the function can't proceed without the result), what syntax could/should we provide to do that?

EDIT: just caught up with the rest of the thread, and read the notes about "defer". That seems to largely address the issue, and I like that there's enough room to experiment with higher-level structures in the crate ecosystem.

2 Likes

I just wanted to note that I created a dedicated thread for experience reports – I’m paricularly interested in that sort of feedback, so I thought it’d be good to collect it specially.

8 Likes

In particular, I personally would like a combinator like join or something that works like so:

let ((a, b), c) = future_a
  .join(future_b)
  .join(future_c)
  .await;

You could trivially build this on the join function. The nested tuples aren't ideal of course, but it scales to arbitrary numbers. But that's I guess more of a "futures library" discussion than anything.

7 Likes

So I just saw a tweet that shows a piece of code that uses .await today and I find that that .await part is not only barely visible, it is also completely indistinguishable from method calls. And this is from a code block that has syntax highlighting.

One of the impressions I have about Rust is that it forces you to explicitly perform operation that are expensive and/or time-consuming (for instance, you must explicitly call .clone() to clone an object, unlike C++). And in my opinion, that is a good thing.

Regarding await, while I do not consider it expensive, it can potentially be time-consuming (the program have to wait for the future). Having an await operator that is indistinguishable from field access might encourage programmer to write code that unnecessarily awaits independent futures sequentially:

get_future_1().await + get_future_2().await

while the correct (more performant) code should be:

let (a, b) = get_future_1().join(get_future_2()).await;
a + b

I don't know how can Rust make the "correct (more performant) code" easier to write and read, but I think Rust should make the "wrong code" more explicit by making await operator more visible. So that whenever I use await twice or more, the source code will scream to my face that "Hey, you have 2 awaits! Care to join them together?".

Ironically, this also rules out postfix sigil (one that I supported) as it is even more subtle than dot-await Actually, I take that back, because since I find ? postfix quite noticeable, I think @ postfix must be visible as well

5 Likes

We had discussed whether it should "auto-deref" or not. At present it does not and it's not clear that one would ever want it to -- in particular, it consumes the thing being awaited, and that would only work for Box<T>, which implements future anyway. But it'd be nice to retain the option in the future (I expect the . operator to generally "auto-deref").

Methods currently error on an uninferred type variable, it's true, but that's largely an implementation defect. If we improve rustc's inference algorithm, I think I would prefer for type-checking to continue and only error if we indeed cannot figure out what method is being invoked. In general, we do not insert an auto-deref unless there is no possible resolution for the method at a given level.

So, if we applied the same logic to futures, it ought to be forwards compatible with our current behavior. Basically, all cases where an auto-deref might've been an option would currently error, so we could add it later (and, because we have no DerefMove-like trait, I don't believe any such cases presently exist, but I could be overlooking something and I'd like to know if so). =)

5 Likes

That looks like syntax highlighting that hasn't been updated to know about the await (or async) keyword. await should use the same color as let and fn and use do.

15 Likes

That seems easily fixed with a trait; not hard to make a join of Future<Item=(A, B)> and Future<Item=C> produce Future<Item=(A, B, C).

On the other hand, I think I'd also want a syntactically lightweight mechanism that allows code like this:

baz(foo().await, bar().await);

to be automatically transformed to join the awaits because they don't depend on each other.

1 Like

This is not about auto-deref. This is about type inference. In the code example, the generic argument X for Foo<X> needs to be inferred. Only Foo<u8> implements Future and thus allows application of .await. In the current version, this type argument is thus inferred to u8 and .await can be applied. The question is then if we can rely on this behaviour and what is done to ensure that this will stay consistent across language versions. Specifically since the reason the current code enables this is the hidden, eventual call to poll_with_tls (or something similar) is called directly on the argument of the defacto-macro, and it requires impl Future.

I see, thanks for clarifying. That situation is actually simpler than what I thought you were asking about. The short answer is that, for better or worse, we do the same sort of deduction for methods, as you can see in this example here:

struct Foo<X> { x: X }
trait MyTrait: Sized {
    fn my_method(self) { }
}
impl MyTrait for Foo<u8> {
    
}
impl<X> Foo<X> {
    fn new() -> Self { panic!() }
}
fn foo() {
    Foo::new().my_method();
}
fn main() { }

If we ever adopt the fields-in-traits RFC (as I hope we do at some point), I presume we would use the same basic logic there too.

At present, the compiler is sometimes conservative when Self is fully unknown, but it is willing to infer other trait parameters on the basis of the set of existing impls. In this case, X is unknown in the trait, but Self is at least partially known, and that’s enough to narrow things down to 1 impl.

1 Like

So it really does behave like a method then :wink:

1 Like

It shouldn't have required syntax highlighting in the first place. I want to read a raw piece of source code on my browser, Notepad, git diff, or via cat without overlooking a crucial piece of information.

8 Likes

Until very recently, join was indeed a method call, not a function.

Right now we have the join and try_join functions, and also the join! and try_join! macros.

So it makes sense to me that we should have join and try_join methods as well.

If anybody feels strongly about this, they should create an issue on the futures repo.

2 Likes