A final proposal for await syntax

Yeah, no duh it didn't stop sigil based syntax from being suggested, because the only thing they mentioned was:

We have consensus that this argues strongly against using a sigil syntax, or any selection which does not contain the string "await” as a part of its syntax. For that reason, we have excluded sigil based syntaxes like @from further consideration.

Which is basically "We agreed not to use sigil syntax" but has no justification for it. It doesn't touch on why they came to a consensus. And guess what that means? With out that justification, we're going to have to go through the same arguments they likely made all over again if we are going to come to the same conclusion. The same thing happened with virtually every syntax that was mentioned as being rejected with out a stated reason.

You know what changed with the final proposal? The reasons for sigil syntax were invalidated, dot syntax does not pose a parsing issue, and dot syntax was decided explicitly with possible generalized pipelining/ UMCS in mind which can then use the same syntax, and reasons against sigil syntax where shown, if dot syntax can do everything sigil can with no technical downsides, then we are left with choosing a "noisy" syntax unnecessarily vs dot. Similarly they fleshed out arguments for macro syntax.

But regardless I should have been more clear. When I say "log" I mean document your arguments for and against and why you came to those conclusions.

I'm not sure if you meant it to come off this way, but in the context of what you just said, this comes off as incredibly hostile and dismissive.

1 Like

Dot can not be used for functions pipelining, and also I would say that perceived sigil "noisiness" as much a technical downside as potential for confusion between dot separated keywords and fields.

Language and especially syntax design can’t be always expressed with hard reasons. It’s more about weighting different things in a more subjective manner and different people will have different weightings.

It’s really more about taste and experience, which are quite hard to formulate without sounding wishy-washy.

I think the real heat in such discussions comes from the unawareness of your own subjective likings, assuming they’re objective.

5 Likes

I beg to differ; dot can be used for pipelining in some limited cases.

struct Foo {}
struct Bar {}

impl Foo {
    fn into_bar(self) -> Bar {
        Bar{}
    }
}

fn main(){
    // Pipelined
    let bar1 = Foo{}.into_bar();
    // Not pipelined
    let bar2 = Foo::into_bar(Foo{});
}

Edit: I misread the argument. I take it you’re arguing that an argument that a new sigil that can be used universal piplining, but it’d be impossible to do that with the dot.

One implication of using . is that we normally expects auto-deref to happen. This is true for both .field and .method() syntax.

#![feature(async_await, await_macro)]

use std::sync::Mutex;
use std::future::Future;
use std::task::{Context, Poll};
use std::pin::Pin;

struct Never;

impl Future for Never {
    type Output = u32;
    fn poll(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Self::Output> {
        Poll::Pending
    }
}

pub async fn foo() {
    let mutex = Mutex::new(Never);
    let mut guard = mutex.lock().unwrap();
    await!(&mut *guard);
    // await!(guard); // ← won't work
    // guard.await;   // ← should it work? 
}

Note that we could have the same effect by extending the Future impl to everything DerefMut to another Future + Unpin, without implementing auto-deref into the lowering.

This also raise a question on further generalization of .keyword, like whether (&&&&true).if {} should be an error (but this is way off-topic anyway).

11 Likes

I'm sorry it came off that way. Part of the reason that the governance WG exists is because improving processes is explicitly one of the things we want to do this year, so I honestly do hope that you have great ideas that can help avoid the widely-acknowledged friction we have on controversial topics. (I just don't want to discuss such changes on this thread.)

5 Likes

This is an argument I haven't seen before. My knee-jerk opinion is that this aspect of the specialness of await is adequately communicated by calling it await, and is not sufficient reason to describe it as "not a method call." However, I'm not sure exactly what scenario you are thinking of. I might change my mind if the ways that await can affect shared mutable state are sufficiently different from the ways that an ordinary method call, running code you don't control, can.

How complicated would an example be? Is it possible to construct an example using only safe code? Is it possible to construct an example whose overall effects could not happen in a single-threaded program that makes no use of async (possibly a very different program, but still)? Is it possible to construct an example whose overall effects could not happen in a program that doesn't use async but is multithreaded (again, possibly a very different program)?

I strongly support this.

I believe introducing an await syntax in combination with a new .keyword-syntax-style in one go or just for .await? seems rushed. Stabilizing (await future)? first (which is unambiguous for the reader, even for rust novices) and then discussing to add .keyword (which just so happens to also include .await) as syntax sugar seems way more reasonable.

6 Likes

Has anyone considered the pipeline operator |> from F# / OCaml / Elm as a syntactic delimiter between the expression and postfix keyword? For instance:

future |> await?
pattern |> match {
    ...
}

I think this is much less line-noisy than #await or @await and less confusing than .await. It’s visually iconic and even has a ligature in some popular programming fonts already!

3 Likes

Several times, most recently in semantics primarily but also in sigil in Idea: universal pipelining (a.k.a. making @await generic). The main non-obvious backwards compatibility hazard is that introducing a new multi-character operator means that macro_rules! macros will consume it as one token tree rather than two that it does today.

3 Likes

I don't think this is actually a problem. Something can be made of two tokens from the lexer's perspective but still parsed as a single operator. Currently we have the reverse, where >> can be initially lexed as BinOp(Shr) but parsed as two closing delimiters, and that also works fine.

However, I dislike |> because...

...well, because I'm used to typing | one-handed, with thumb reaching over to the right shift key, and getting > from that position is rather awkward. But now that I think about it, I don't know why I expose myself to such an awkward stretch when I could use left shift instead, in which case typing |> is easy.

...I feel born-again. So much hand pain from Unix pipelines could have been avoided...

3 Likes

If the postfix syntax can be a thing, then what’s the rule to determine what can be “postfixed”? (In short: How many existing syntax in Rust can be “postfixed”?) Example:

x.is_true { }
x.switch { }
x.is(....) { }
x.equals(....) { }

If the problem is about symbol, then maybe try :?

This would apply only to existing keywords, and only the subset of those that accept an expression as an argument.

Things that I think would make sense, in descending order of sense:

expr.match { ... }
// equivalent to match expr { ... }

expr.if { ... }
// equivalent to if expr { ... }

expr.while { ... }
// equivalent to while expr { ... }

expr.for value { ... }
// equivalent to for value in expr { ... }

(You could theoretically also use expr.return but that seems like a bad idea.)

5 Likes

I was told above in no uncertain terms that return will not work:

The "let's use existing keywords as fields/methods" idea seems like it would enable lots of new syntax, but nobody seems to be very explicit about that it actually does. I asked about it above:

While universal pipelining or chaining sounds like a powerful construct, my impression is that we're talking about enabling three existing keywords:

  • expr.if predicate { then_branch } else { else_branch }
  • expr.match { pattern1 => branch1 | pattern2 => branch2 | ... }
  • expr.loop { loop_body }

There can be no .for since for-loops aren't expressions, the same applies to .while and .return. Let's look at them one by one:

If expressions as a postfix keyword

To the best of my knowledge, we're talking about changing existing code that uses if-statements as an expression. When if-statements form expressions, they must have the full form with an else:

let result = if foo() > 10 { this() } else { that() };

This can afterwards be written using the chained style:

let result = (foo() > 10).if { this() } else { that() };

Chaining seems to be the main selling point for this new postfix keyword style. I tried to come up with a bigger example that would use two .if { ... } else { ... } constructs in a row. However, it wasn't really clear to me when this would actually make sense.

The main problems that I see with this syntax is how you're limited to operating on the value flowing through your pipeline. Consider a case where you want to do an else if:

let value = some_big_expression();
let result = if value > 100 {
    "too much"
} else if value < 10 {
    "too little"
} else {
    "just right"
};

I believe you cannot do that with an .if syntax, and as I understand it, the Boolean expression will always be the value "passed down" though your expression pipeline. I think this is very limiting in practice.

Match expressions as a postfix keyword

This syntax is more promissing. We're talking about turning

let value = some_big_expression();
let result = match value {
    v if v > 100 => "too much",
    v if v < 10 => "too little",
    _ => "just right",
};

into

let value = some_big_expression();
let result = value.match {
    v if v > 100 => "too much",
    v if v < 10 => "too little",
    _ => "just right",
};

Since the patterns can include arbitrary if statements, we now have the ability to pull in extra information into our expression.

Loop expressions as a postfix keyword

Finally, the loop keyword can be used as an expression:

let x = loop {
  if condition_holds() { break 42; }
};

Very well, but observe that loop does not transform an input in the same way that if and match does. In other words, the value of some_computation() is ignored here:

let result = some_computation().loop { ... }

Based on this, I don't think loop as a keyword has much utility.

Conclusion

Of the three keywords that can be used to form expressions, I think only match is somewhat useful:

  • if statements need a Boolean expression, not an arbitrary value. So you can only chain an if statement with something that already produces a bool. Since we have a well-established Boolean algebra already (with && and ||, etc), it's hard for me to see why you would not just use that to express the condition you're interested in.

  • match statements can work on any value and you can include more "context" into the match arms by using if statements as guards.

    Note, however, that every match arm must produce a value of the same type since the .match is used as an expression in the middle of a larger chain (otherwise you could just write the match as its own statement). I believe this implies that you cannot simply add a _ => {} fallback arm, instead you must handle all cases somehow.

  • loop expressions seem to fit poorly into the chaining model since they don't actually transform a value.

Await as a postfix keyword

Finally, we can look at await as a postfix keyword. Notice that the latest proposal is to not include any braces after the keyword. This means that it looks different from if and match:

produce_a_bool().if { ... } else { ... };
produce_a_value().match { ... };
produce_a_future().await;

Braces could of course be added if consistency is desired here:

produce_a_future().await{};

I believe the underlying source of this is that the other keywords primarily deal with blocks of code. They both select between several possible blocks of code based on an input expression. This is the Boolean expression for an if statement and the matched value for a match expression.

The await keyword does no such thing — it doesn't change the control flow in the function where it appears. Instead it works like a unary operator such as ! (Boolean negation) or ? (error unpacking).

4 Likes

This may change in future, plus I think using chained for as a more powerful for_each (you can return and break) is not a bad use-case.

Another keyword which may be used in chaining is yield if we'll get generators with resume argument.

And if chaining may be extended to support if let, which will make it more powerful.

Also I personally wouldn't exclude keywords which evaluate to ! or () right away. It may be worth to keep whole expression in "reverse" order to minimize number of cognitive context switches:

// I will use @ instead of dot in this example
connection
    .handshake()
    @await
    @if let Handshake::Success(body) {
        body
    } else {
        return Err("handshake failure");
    }
    .get_body()
    @await?
    .process()
    @return;

FWIW, if we consider the block as the first “argument”, and the expression/condition as integral to the keyword syntax, then it could also be a more Python-ish:

{ ... }.if expr else { ... }
{ ... }.while expr
{ ... }.loop
{ ... }.for var in expr

Not arguing for it, just throwing it out as another possibility. To be fair, especially the last is iffy, because it would have the variable binding after the block that uses it (although it is similar to Python’s comprehension syntax).

3 Likes

Stepping back a bit and looking at this logically, this proposal seems to be pushing more towards adding postfix keywords to the Rust language under the guise of adding support for async/await. This seems like too big a jump, especially as one of the biggest concerns here is shipping async/await support soon.

I suggest that the logical way to go about this is to put postfix keywords to one side and forget them for the time being. We should continue with await foo() and live with (await foo())? in our immediate future, as this syntax was already thought of as “the solution” and was already regarded as on the roadmap without all of this debate.

If we do decide to add postfix keywords to the Rust language at a later date, it should be added for all keywords at the same time for language consistency and designed with all keywords in mind. In this scenario, await is simply another of those keywords to get a postfix version. There’s no cause for one specific keyword to get a postfix version before anything else, particularly when postfix keywords are already this strongly debated/controversial.

I can understand that the language team might deem this “throwaway” syntax, but I disagree. Adding a postfix version of match as suggested doesn’t make the existing match syntax redundant/deprecated (right?). The await keyword simply follows the same path as all other keywords. Although this puts us back where this discussion started in the short term, I think that this is the best course of action in terms of timeline, consistency and familiarity.

I would hope that the language team seriously considers this as an option. Once again thank you for your continued time in reading these comments!

18 Likes

Coming late to the party, but have you ever considered making ‘@’ the await operator? Assuming it has higher precedence that ‘.’ and ‘?’, it could work like:

let result = @async_func()?.process_data();

@async_func() is equivalent to (await async_func()).

I'm not sure I'd include this one, as to be useful it essentially needs to take a closure, not a value. That runs into all the same problems that arose with post-fix macros around whether foo().bar().while { is while foo().bar() { or let x = foo(); while b.bar() { or let y = foo().bar(); while y {.

(if, on the other hand, does take a value, so is fine. And I like the "an option more like ?: if you want" discussion.)

Technically for and while are expressions that return (), and return is one that returns !.

1 Like