Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Postfix match #3295

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

conradludgate
Copy link

@conradludgate conradludgate commented Jul 23, 2022

Rendered

An alternative postfix syntax for match expressions that allows for interspersing match statements with function chains

foo.bar().baz.match {
    _ => {}
}

as syntax sugar for

match foo.bar().baz {
    _ => {}
}

@truppelito
Copy link

My initial reaction after reading the title was that this was unnecessary. However, after thinking about it for longer, I realised how useful this actually is, and how many times this would've helped the code I'm writing.

I think the RFC already justifies this feature quite well. I would just like to add two related points:

  1. One of my initial concerns was: "So if we have postfix match, are we going to have postfix if, for and while (ignoring loop because it doesn't take a value, although it has been mentioned before)? This seems like a slippery slope." Actually, I don't think it is:

The conversion from

while foo.bar().baz {
    do_something();
}

to

foo.bar().baz.while {
    do_something();
}

should probably not be attempted. From my point of view, function chains represent pipes where flow starts at the top, only moves down, and finishes at the bottom. foo.bar().baz.while { do_something(); } hides the fact that foo.bar().baz can be called multiple times. Not to mention that, in order for the chain to continue, while has to return a value. Seems too complex and unintuitive for very few use cases.

The conversion from

for x in foo.bar().baz {
    do_something(x);
}

to

foo.bar().baz.for |x| { // Some made-up sytax here
    do_something(x);
}

is redundant. This is what iterators do. Also, same caveat about continuing the chain.

Finally, if and if-else. In order for postfix if to make sense, I think the result of foo.bar().baz must either be a boolean, or the if expressions must be doing direct comparisons on the given value (if foo.bar().baz == val1 { ... } else if foo.bar().baz == val2 { ... } else { ... }). Anything more complex and we wouldn't know how to desugar it. But this can be achieved with match just as well, so postfix if is redundant.

  1. One of my syntax gripes with C/C++/Rust is that the very common operations of referencing (&x) and de-referencing (*x) are prefix only. Don't get me wrong, I think that prefix & and * in simple expressions like do_something(&a_value) and let a_value = *a_ref definitely works. It's simple, clear, concise and everyone's used to it. But in more complex expressions it's a chore to look left and right to parse a chain of method calls with * and & thrown in. This happens less in Rust but I still run into it occasionally, and every time I do I just wish I could write something like (from here):
&mut guess  -->  guess.&mut
*guess      -->  guess.*

Currently I cannot, but postfix match allows

&mut guess  -->  guess.match { v => &mut v }
*guess      -->  guess.match { v => *v }

in function chains which is not as clean and simple as guess.*, but at least it's better than the prefix versions.

@conradludgate
Copy link
Author

I tried to avoid mentioning any other postfix keywords because I don't want this RFC to turn into arguing about other postfix alternatives. However I understand it's always the natural progression "what's next?".

Postfix for/while don't make sense to me since they can't currently evaluate to a value other than (). Postfix loop doesn't make sense as it doesn't take a value.

Postfix let isn't a terrible idea (also recently discussed as an is keyword), but some of these uses can be covered entirely by this proposal using .match { x => ... }.

Postfix if/else seems like very messy syntax so I also don't want to bikeshed that here.

Postfix ref/deref would be nice, but this is also doesn't seem to fit related to this proposal.

Postfix yield is probably a decent idea, but yield is still unstable so also not worth arguing over here.

@truppelito
Copy link

truppelito commented Jul 23, 2022

I tried to avoid mentioning any other postfix keywords because I don't want this RFC to turn into arguing about other postfix alternatives. However I understand it's always the natural progression "what's next?".

That's a fair point. As far as this RFC is concerned, I'm fully in favor of it. And regardless of what decisions we make about those other postfix cases you mentioned, we don't need to worry about them now. Either they clearly don't make sense, or they are covered by this, or we can look into them later in their own RFCs if we feel like it without hampering the progress in this one.

I think postfix match is one solid idea that stands on its own and doesn't necessarily require other postfix options to exist (or even be considered) as well. Plus, it already has the precedent of postfix await.

@ehuss ehuss added the T-lang Relevant to the language team, which will review and decide on the RFC. label Jul 23, 2022
@scottmcm
Copy link
Member

scottmcm commented Jul 24, 2022

are we going to have postfix if, for and while

Here's my previous thoughts on this: https://internals.rust-lang.org/t/half-baked-idea-postfix-monadic-unsafe/10186/11?u=scottmcm

Basically, Rust is pretty good about "there's a warning up front" when things affect everything in a block, but that's not necessary when something only deals in the resulting value.

That's why foo().await is good, but { … stuff … }.async is bad. That's why foo()? is good, but { … stuff … }.try would be bad. That's why it's unsafe { foo() }, and not foo().unsafe.

And thus { … stuff … }.loop is unacceptable to me, because the fact that the stuff in there is going to run multiple times is important for understanding it. For example, the loop { header tells your brain "hey, look out for breaks and such in here". If it were postfix }.loop, then it'd be possible for you to be reading the code and go "wait, a break? Oh, this must be a loop".

But .match is fine, because how that value is computed doesn't matter to it. You can usually replace match some().complicated.thing() { with let x = some().complicated.thing(); match x { and it's fine. (Whereas of course loop { some().complicated.thing() } and let x = some().complicated.thing(); loop { x } are very different.)


There are some other things that could work ok as postfix.

For example,

foo()
    .zip(bar)
    .whatever()
    .for x {
        call_something(x);
    }

meets all my requirements for where postfix would be fine (though that doesn't imply desirable).

But procedurally I think considering things one at a time is for the best (unless they're deeply connected).

@sunfishcode
Copy link
Member

An alternative I don't see mentioned yet is to use a let to split the multi-line expressions into its own line, like this:

    // prefix match, but using `let` so that the `match` goes at the end
    let favorite = context
        .client
        .post("https://example.com/crabs")
        .body("favourite crab?")
        .send()
        .await?
        .json::<Option<String>>()
        .await?;
    match favorite.as_ref() {
        Some("") | None => "Ferris",
        x @ Some(_) => &x[1..],
    };

That works today, and puts the match visually at the end.

@truppelito
Copy link

truppelito commented Jul 25, 2022

@sunfishcode I guess the argument there is that postfix match is on the same level of usefulness as method chaining. We can also break method chains into let x = f(); let y = x.g(); let z = y.h() ..., yet we still prefer f().g().h() sometimes. So the same could be true for postfix match (especially if we want to do multiple matches in sequence like with method chaining).

@T-Dark0
Copy link

T-Dark0 commented Jul 27, 2022

Another advantage of postfix match is that it would reduce the need for more Option/Result helper methods. For example, it makes map_or_else redundant, as it doesn't have the argument order issue and it allows moving a local variable into both match arms (while it can't be moved into both closures).

It also means that code that would need async_ versions of all those adapters can more easily match instead, which alleviates the problems of combining effects.

@cuviper
Copy link
Member

cuviper commented Jul 27, 2022

FWIW, I was playing with an implementation of this a while ago. It's rather simple to handle this entirely in the parser, although I didn't add stability-gating or anything like that. (edit: I just rebased and it still works!)
rust-lang/rust@master...cuviper:rust:dot-match

@Cassy343
Copy link

Please note that all of the following is just my opinion. This is a request for comments, so I'm commenting from my perspective.

Similar to @truppelito, my initial reaction was that this was unnecessary. Unfortunately after thinking about it on my own for a bit that is still my opinion. I don't feel like this feature is sufficiently motivated.

I spend a fairly considerable amount of time in the rust community discord. If this were added, I see the following scenario playing out. Baby rustacean Cassy shows up and asks the question: "what's the difference between match foo().bar() { .. } and foo().bar().match { .. }?" Me or someone else would answer something along the lines of "foo().bar().match { .. } is just syntax sugar for the former."

I feel dissatisfied with that answer. It is not immediately clear to me (or I imagine to budding rustacean Cassy) what the motivation for the difference is. At a glance, it seems like two ways to do the exact same thing. I think this is different from option/result utilities like map because those substantially reduce the size of the code in many cases, and are well-known transformations.

But the thing I really dread is the follow-up question of "when should I use which?" or "which one is better?" After reading the RFC I don't think I could tell you a non-opinion-based answer. I think the proposal to have rustfmt choose it for you based on the span of the scrutinee is a reasonable idea, but I wouldn't be surprised if we start seeing stuff like "we only use prefix/postfix match in this repository" in contributing guidelines, which I don't think is good. I genuinely feel like the decision to use prefix/postfix match will end up being a matter of preference over utility the majority of the time.

More on that point, if your chaining is so long that it wouldn't fit on a single line in a prefix match I'd argue you need to break that chain up. Function chains are great - I love them and write them all the time - but when I look at my old code after a few months I find I spend more time than I'd like figuring out what the intermediate values are. Taking this example from the RFC:

context.client
    .post("https://example.com/crabs")
    .body("favourite crab?")
    .send()
    .await?
    .json::<Option<String>>()
    .await?
    .as_ref()

I have used HTTP libraries that look just like this but I found my self subconsciously asking "wait what is that second .await awaiting on again?" I honestly think that there's a decent argument for breaking this up into:

let response = context.client
    .post("https://example.com/crabs")
    .body("favourite crab?")
    .send()
    .await?;
let json = response.json::<Option<String>>()
    .await?
    .as_ref();

For instance, nothing in the original code snippet indicated that the expression evaluated to the response of the request, now that's fairly obvious for this particular example, but in the general case I think it's easy to get lost in the chains. I think that encouraging further extension of such chains with .match is a step in the wrong direction.

I would also like to note that I have never actually wanted or thought of having a feature like this before I saw this RFC today. When I saw this I didn't (and still don't) identify any problems it's solving for me. Now because this is my experience I don't feel like the small ergonomics improvements outweigh the duplication of a language feature, but your opinion may differ, of course.

@steffahn
Copy link
Member

&mut guess --> guess.match { v => &mut v }

@truppelito careful, this will make a difference since the v binding moves the value whereas the original case had a place expression context. What you'll actually want is guess.match { ref mut v => v } to get the same behavior as &mut guess. (I'm not certain whether they're 100% the same this way, but at least it's a lot closer.)

@afetisov
Copy link

@T-Dark0 No one would be happy from that half-solution. Match, postfix or not, still involves a lot of boilerplate for standard combinators. People will want to eliminate it, so it's just a stopgap and redundant syntax. The only thing it would save from is giving an explicit name to the match scrutinee, which isn't a good enough reason to add new syntax.

Overall, this RFC doesn't seem to be solving any practical issues, give any significant ergonomic or conciseness benefits. It's almost the same amount of code, apart from an explicit variable binding, which is more of a downside than a benefit. An explicit match scrutinee name helps to document the transformations for future readers. While the pipelining makes the dataflow slightly more obvious (slightly, since consecutive variable bindings give almost as much information, apart from a possibility of using the bindings in other places), it obscures the state of the pipelined variable. You know all the small step transformations, but to learn the state at any step you need to mentally simulate the entire pipeline up to that point. This means that long pipelines can only be read as an indivisible whole, and require keeping the entire pipeline in your head, which is a downside for readability.

Currently, the match expressions are minor sequence points, which require the code author to give some, hopefully descriptive, name to the state. This is a benefit of the standard match expressions rather than a downside.

I also wonder where should we stop if this RFC is accepted? The same reasoning can be applied to any other syntactic construct (if and if let, variable bindings, loops...). Where would it stop? And why would anyone want to eliminate the visual structure of the code and turn Rust into a Forth-wannabe?

@steffahn
Copy link
Member

steffahn commented Jul 28, 2022

I don't understand this section talking about “Method call chains will not lifetime extend their arguments. Match statements, however, are notorious for having lifetime extension.” I thought that temporary scopes are essentially the same between the scrutinee of a match and a prefix of a method chain: in either case, temporaries are kept alive until the end of the surrounding temporary scope, typically the surrounding statement.

@kristopherbullinger
Copy link

Regarding code style, I think that in the case of "stacked" matches, a postfix match is likely to be more easily understood. Consider something like:

match match some_enum {
  E::A => F::A,
  E::B => F::B,
  E::C => F::B,
} { 
  F::A => "FA",
  F::B => "FB",
}

could be re-written as:

some_enum
.match {
  E::A => F::A,
  E::B => F::B,
  E::C => F::B,
}
.match { 
  F::A => "FA",
  F::B => "FB",
}

The first example is so ugly that the author would likely have the sense to separate the operations across multiple statements or abstract the matches into methods and write this logic as a method chain some_enum.into_f().to_str(). Readability (and writeability!) is otherwise too poor. With postfix match, the operations may be able to continue without sacrificing readability or necessitating an abstraction.

Prefix match places the operand in the middle and the result on the right, whereas postfix places the operand on the left and the result on the right, which strikes me as similar to the difference between + 5 3 and 5 + 3.

I sympathize with the scenario described by Cassy in which a beginner might be confused by having two ways to use match. We already have some similar ambiguity in our module system. It is a common question for beginners to ask "should i make a mymod/mod.rs or mymod.rs", and they are always instructed to use the latter. Beginners are inconvenienced by having to seek the answer to such a question. However, I believe that the answer is simple to explain ("they are the same, but this one is considered idiomatic") and does not serve as a strong deterrent.

await is decidedly better in postfix, and I believe match would be better in postfix too. If we could switch away from prefix match to postfix match across an edition, I would wholly support it, as there would be no lack of clarity regarding how to use the keyword.

@ChayimFriedman2
Copy link

Regarding code style, I think that in the case of "stacked" matches, a postfix match is likely to be more easily understood.

In case of stacked match I almost always think it should be separated into two statements, and I don't think postfix match will solve that.

If we could switch away from prefix match to postfix match across an edition, I would wholly support it, as there would be no lack of clarity regarding how to use the keyword.

I think this is way too much churn than we can allow ourselves. And if both are here to stay, then this will indeed create a lot of confusion IMHO.

@conradludgate
Copy link
Author

I thought that temporary scopes are essentially the same between the scrutinee of a match and a prefix of a method chain: in either case, temporaries are kept alive until the end of the surrounding temporary scope, typically the surrounding statement.

I didn't investigate it too deeply, but I remembered that match had a seemingly unintuitive approach to temporaries.

But if you're correct that it makes it more consistent with method chains, that's great for this RFC!

[1]: https://internals.rust-lang.org/t/idea-postfix-let-as-basis-for-postfix-macros/12438

# Prior art
[prior-art]: #prior-art
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something else you could add here:

C# has had C- and Java-style switch (foo) { … } since the very beginning. But C# 8.0 added postfix switch expressions that use foo switch { … } instead. That's very much like how Rust has had match foo { … } since the beginning while this RFC proposes adding foo.match { … }.

It's fun to see just how rusty that feature is. To use the example from the csharp_style_prefer_switch_expression style rule (roughly the equivalent of a warn-by-default clippy lint), instead of

switch (x)
{
    case 1:
        return 1 * 1;
    case 2:
        return 2 * 2;
    default:
        return 0;
}

It allows doing

return x switch
{
    1 => 1 * 1,
    2 => 2 * 2,
    _ => 0,
};

Using => and _ exactly the same way Rust does here. (Not that Rust invented it; it's also the same as many other languages.)

This isn't a perfect analog, since C#'s change does include a bunch of pattern syntax cleanup. But it's still another language in which it was decided to add a postfix matching operator even though everything it does could still be done (sometimes less nicely) with the existing prefix matching operator.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes of course! I even use them all the time at work 😅

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In C# the non-postfix switch is not an expression, in Rust it is.

@scottmcm
Copy link
Member

Currently, the match expressions are minor sequence points, which require the code author to give some, hopefully descriptive, name to the state. This is a benefit of the standard match expressions rather than a downside.

Since postfix match needs the same patterns, I assume this isn't referring to bindings in those.

Current match expressions don't require that. It's possible to nest them (as mentioned in #3295 (comment)). Now, certainly people rarely (if ever) do that. But "the current grammar form is so horrifically ugly that people never do it" doesn't say "and thus the current grammar form is better" to me.

Basically, it's certainly true that there are various places where it might be a good idea to introduce a name. #3295 (comment) above mentions that in the context of method chaining and ? and other such things as well, for example. But it's not clear to me that the grammar is the right way to enforce that. "Give every single step a new name" is clearly not acceptable, even if we threw out the stability guarantees.

To me, the place for "hey, that chain got too long, can you break it up?" is code review, not the grammar. I don't think we need match-after-match to be so ugly that people don't do it any more than we need to make method chaining uglier so that people stop doing it. I'd much rather a name show up in the place where it's most meaningful, rather than where it makes the code look prettier.

I also wonder where should we stop if this RFC is accepted? The same reasoning can be applied to any other syntactic construct (if and if let, variable bindings, loops...). Where would it stop? And why would anyone want to eliminate the visual structure of the code and turn Rust into a Forth-wannabe?

See #3295 (comment) earlier where I discuss that in depth. No, the same reasoning cannot "be applied to any other syntactic construct". loop, at the very least, will never be postfix.

And even where it would be possible to do it, it does still need to pass a "is it worth bothering" check. For example, there's nothing fundamentally horrible about foo.bar().return. But I'd be extremely surprised for it to ever happen in Rust, because it's just not helpful to have it -- break (return x).foo() is technically possible, but it's useless at the type level because the return expression is -> ! and thus being able to write it as x.return.foo().break doesn't help anyone.

@truppelito
Copy link

truppelito commented Jul 28, 2022

@steffahn Yes, of course 😂. This is a mistake I make regularly, but the compiler catches it for me. Naturally, here... there was no compiler.

@ChayimFriedman2
Copy link

Basically, it's certainly true that there are various places where it might be a good idea to introduce a name. #3295 (comment) above mentions that in the context of method chaining and ? and other such things as well, for example. But it's not clear to me that the grammar is the right way to enforce that. "Give every single step a new name" is clearly not acceptable, even if we threw out the stability guarantees.

I don't claim that we should enforce that in the grammar. Perhaps postfix match was the right thing to do if we would be designing Rust from scratch. But I don't think it is worth to introduce a new way to do the same thing, just to be able to chain matchees.

@truppelito
Copy link

truppelito commented Jul 28, 2022

@ChayimFriedman2 "But I don't think it is worth to introduce a new way to do the same thing, just to be able to chain matches."

I understand and agree your point in general. Having two ways to do the exact same thing is probably unnecessary. Where we differ is that I consider the ability to chain matches useful, and thus not just "a new way to do the exact same thing".

I also consider using postfix match for quick/small/simple matches in a sequence of operations useful. Examples:

guess.match { v => *v }.etc()

or

val
    .something()
    .match {
        Operation::Multiply(x, y) => x * y,
        Operation::Divide(x, y) => x / y
    }
    .something_else()

A precedent:

I don't know what came first, for x in iter { ... } or iter.for_each(|x| ...), but given that we have the for construct, why is for_each also useful?

Certainly, any time we write:

val.iter().map(...).etc.map(...).chain(...).for_each(|x| ...)

We can also write:

for x in val.iter().map(...).etc.map(...).chain(...) {
    ...
}

(I think this is true, correct me if not). So for_each is a "new way to do the same thing" in relation to for (assuming for came first). In fact, for allows for break and return, which for_each doesn't. So if one of these is redundant, it's clearly for_each. However, we still have for_each in Rust, and to me this makes sense. Personally, there are cases where I prefer for and cases where I prefer for_each. For example, in this case:

for x in val.iter().map(...).etc.map(...).chain(...) {
    *x += 1
}

val.iter().map(...).etc.map(...).chain(...).for_each(|x| *x += 1)

I prefer for_each. It's a bit terser and flows better if the operation on each element is small. This is the same case as I mentioned above with guess.match { v => *v }.etc().

However, if the *x += 1 code is in fact much larger and/or important, I will use a for. It avoids the extra indentation if the code spans multiple lines, and normally requires you to stop and think about what the loop is doing, which is useful to indicate "this code is important, pay attention to it". Of course, this is personal preference. I don't expect everyone to view this in the same way. However, I do have use for both for and for_each. By the same token, both prefix and postfix match are useful to me.

@truppelito
Copy link

truppelito commented Jul 28, 2022

Regarding the comments of: "naming the match scrutinee is a good thing and/or it should always be required"

I agree, but not always. For example:

@afetisov "Currently, the match expressions are minor sequence points, which require the code author to give some, hopefully descriptive, name to the state."

I think we can apply the same clarity rules to both function chains and prefix/postfix match. If the chain of operations (functions or match) is long, we should break it up and provide a name (and maybe type info) to the intermediate value. There is, however, no need to enforce that these breaks must happen every time we use a match. Why would we? We can choose where to break the chain if we're using functions. Why not for matches?

In fact, YMMV but I find that matches tend to be quite explicit with what they're doing/matching against. For example:

let x = val
    .something()
    .match {
        Operation::Multiply(x, y) => x * y,
        Operation::Divide(x, y) => x / y
    }
    .something_else()

I find that the match here is pretty clear because we're matching against an enum and we wrote the enum name (in fact, this match already provides all the type info we need). The equivalent of breaking the chain in the match I guess would be:

let operation = val
    .something();

let x = match operation {
    Operation::Multiply(x, y) => x * y,
    Operation::Divide(x, y) => x / y
}
.something_else();

which looks a bit redundant to me.

Of course, the argument then is that not all matches are this clear. But that's my point. We get to choose. If the match is clear, postfix is ok. If not, use prefix with a descriptive variable name.

@T-Dark0
Copy link

T-Dark0 commented Jul 28, 2022

(replying to a specific post because I was pinged, but I'm making these points in a general manner)

@T-Dark0 No one would be happy from that half-solution.

@afetisov No, of course not. I said alleviates, not solves, the problem.
The only way to make people happy would be to add a combinatorial explosion of combinators, or an algebraic effects system. As much as I really want the latter, even a poor stopgap is better than the status quo

An explicit match scrutinee name helps to document the transformations for future readers.

This is only true on paper. 99% of code I've seen (even the snippets in this very thread!) doesn't add meaningful names for the intermediate "match variable". Instead, it names it after its type, or after the function it comes from. Hardly useful documentation. After all, can we expect a programmer to think about a good name when they didn't really want to introduce a name, and know that the variable is only used once, in the very next expression?

Importantly, I think postfix match would make the situation better: if people aren't forced to add names by the grammar, the addition of a variable name becomes a deliberate process, done by choice. A programmer that chooses to name a variable is much more likely to take a moment to decide where the name should go and what it should be carefully.

While the pipelining makes the dataflow slightly more obvious (slightly, since consecutive variable bindings give almost as much information, apart from a possibility of using the bindings in other places)

Emphasis added. The pipelining makes the dataflow easier to read, and prevents readers from having to worry about other potential uses of the variable. Win-win.

It obscures the state of the pipelined variable.

Iterators work completely fine, and aren't generally accused of unreadability, and so do transformations like map_or_else (except for the fact its argument order is backwards, which is a problem this RFC wouldn't have). Yes, there is potential to use this feature to write unreadable code, but I don't think we have any feature that couldn't be used for that. People usually stop before writing abominable horrors, and if they don't we can have code review or clippy stop them.

You know all the small step transformations, but to learn the state at any step you need to mentally simulate the entire pipeline up to that point. This means that long pipelines can only be read as an indivisible whole, and require keepfiing the entire pipeline in your head, which is a downside for readability.

This is also true if you use intermediate variables. Knowing the state of a variable at point X requires knowing how it was created, which requires knowing how the variables used in that computation was created, and so on backwards.

let iter = vec![1, 2, 3].into_iter()
let iter = iter.map(|x| x + 1);
let iter = iter.filter(|x| x % 2 != 0)
//How would you know what the state of `iter` here is without reading the iterator "chain" that it comes from?

Currently, the match expressions are minor sequence points, which require the code author to give some, hopefully descriptive, name to the state.

Emphasis added. This doesn't happen in reality.

I also wonder where should we stop if this RFC is accepted? The same reasoning can be applied to any other syntactic construct (if and if let, variable bindings, loops...). Where would it stop?

It stops at match, in this RFC. Please keep complaints about the slippery slope to RFCs that actually try to establish a slippery slope.

And why would anyone want to eliminate the visual structure of the code and turn Rust into a Forth-wannabe?

Forth had many great ideas. Concatenation was one of them. The real issue was that Forth only had concatenation and a stack to express things. Nobody is proposing to abolish variables, we won't become Forth.

@ssokolow
Copy link

ssokolow commented Jul 28, 2022

It stops at match, in this RFC. Please keep complaints about the slippery slope to RFCs that actually try to establish a slippery slope.

It could be argued that, unless we say "It stops at .await, in that RFC. Please keep complaints about the slippery slope to RFCs that actually try to establish a slippery slope.", then this RFC is proof that we're already on a slippery slope.

What makes this one so special that it gets privileged "here's where it stops" status as opposed to what comes next, or the one after that? ...because, a lot of rejected RFCs had a similar "Let's just add this one thing I want to the language and stop there. We don't need what other people want." feel to them.

@kennytm
Copy link
Member

kennytm commented Mar 26, 2024

I just grep a few code that I have a copy of for some real world use cases which may be benefit by postfix match. (I did not include cases that are served better by combinators like .map_or_else(), or if let with early return, or let else, or requires postfix macros for a significant improvement.)

  1. https://github.com/tikv/tikv/blob/3acb9dd72916c4d15145fbb6e1635f7c1688f873/components/cloud/gcp/src/client.rs#L83 Introducing the intermediate variable token_or_request does not make the code any easier to read.

    let token = token_provider
      .get_token(&[scope])
      .map_err(|e| RequestError::OAuth(e, "get_token".to_string()))?
      .match {
          TokenOrRequest::Token(token) => token,
          TokenOrRequest::Request { request, scope_hash, .. } => { /*snip*/ }
      };

    https://github.com/tokio-rs/axum/blob/6bd6556385c7f6bf1ee7ea64e3a8fbe2076a1506/axum/src/routing/path_router.rs#L347 is also similar that the endpoint intermediate variable can be elided without impacting readability (not going to look for other similar cases).

  2. https://github.com/tikv/tikv/blob/3acb9dd72916c4d15145fbb6e1635f7c1688f873/components/raftstore/src/store/snapshot_backup.rs#L165 Existence of break means .tap()-like functions can't be used as replacement.

    while v < new_lease {
      self.before
          .fetch_update( /*snip*/ )
          .match {
              Ok(_) => {
                  metrics::SNAP_BR_SUSPEND_COMMAND_LEASE_UNTIL.set(new_lease as _);
                  break;
              }
              Err(prev) => { v = prev; }
          };
    }
  3. https://github.com/tikv/tikv/blob/3acb9dd72916c4d15145fbb6e1635f7c1688f873/src/coprocessor_v2/raw_storage_impl.rs#L91 Existence of .await means Result::map can't be used.

    self.storage
        .raw_put( /*snip*/ )
        .match {
            Ok(_) => f.await.map_err(PluginErrorShim::from)?,
            Err(e) => Err(e)
        }
        .map_err(PluginErrorShim::from)?;
  4. https://github.com/smoltcp-rs/smoltcp/blob/4c27918434fde9b7ca6266d1eaee6da15ba8c4e8/src/iface/interface/igmp.rs#L157 The .map() can be moved inside the .match. The addr is already part of the pattern, repeating the intermediate variable doesn't make the code more readable.

    self.inner
        .ipv4_multicast_groups
        .iter()
        .nth(next_index)
        .match {
            Some((addr, ())) => { /*snip */ },
            None => { /*snip*/ },
        };

@janriemer
Copy link

So I have read through all the comments, but I'm still not convinced by how this should help in:

  • make Rust more approachable to newcomers
  • make Rust more readable (if I understand correctly, this is the main argument of this RFC - I'll come back to it later)
  • make it possible to express something in Rust in a more meaningful, simpler way

Examples and missing objective reasons

I'd like to see some objective reasons, why postfix match is better than prefix match other than subjective taste. Unfortunately, this is still not clear to me.

Let's iterate (no pun intended) on some of the examples given in the RFC, where postfix match is supposed to help:

Async context with long method chains

In the following example it is argued that postfix match would allow using .await within the match - otherwise, when using combinators on Option, like ok_or_else, this is not possible, due to closures. Yes, this is true, but we shouldn't throw closures into the mix and say:

Combinators with closures don't allow this, therefore we need postfix match (at least this is how it sounds to me).

I can perfectly write the example that is given in the Async section with existing prefix match:

match context.client
    .post("https://example.com/crabs")
    .body("favourite crab?")
    .send()
    .await {
        Err(_) => Ok("Ferris"),
        Ok(resp) => resp.json::<String>().await,
    } // I can even chain further after the match block, no problem at all
    .map(...);

Here is another equivalent (async) example (with longer method chains at the end and short circuit ?):
https://gist.github.com/rust-play/0c4ff262df30fa1397a3e1682e67aea2

let x = match Foo.bar().baz().await {
        Ok(Baz) => Some(Baz.await),
        _ => None,
    }
    .ok_or("Failed".to_string())?
    .map(|res| format!("{:#?}", res))?;

Be honest - have you looked at it and immediately thought: "Nawww, I wish they'd used postfix match instead of prefix match - it would be so much more readable."

(Method) chains vs. procedural blocks (or: keywords set expectations on control flow)

Keywords set certain expectations about the control flow that is going to be introduced.

When I see (method) chains being used, I can be sure that there is mostly a linear control flow top to bottom. I only have to scan for the short-circuiting operator ? on the outside to know, whether I might exit early.

When using postfix match way down some long (mostly linear) chains, I see this procedural match block very late in the chain when scanning the code, which now can do whatever with the control flow (continue, return, break etc.).

When using prefix match instead, there is some expectation set right at the beginning: the code that follows is not necessarily pure/linear/functional style, but could be of a rather procedural style (including using continue, return etc).

For me, this is the most profound difference in prefix match vs. postfix match and why I think (as of now) postfix match will make code even harder to read.

Conclusion by looking at the Rust survey results

As others have noted, the Rust survey results' conclusion has been:

  • a fear of Rust getting too complex
    • note that this can mean complexity on multiple levels (I think this RFC accounts for both points below):
      • complex to write or maintain new or existing Rust code
      • complex to introduce new, impactful features to the language (keyword: weirdness budget)
  • people are still struggling in some areas in Rust, namely
    • Async
    • Traits and Generics
    • Borrow checker

Note, that there is not a single mention of pattern matching that people are struggling with (which this RFC promises to simplify).

My conclusion is that this RFC goes completely against the results of the survey, the actual needs of Rust users and should therefore not be prioritized.

I appreciate the authors time and investment in formulating this RFC, though. Without constantly questioning the status quo, we wouldn't have the excellent postfix .await or ? (or even Rust for that matter 😉 🦀).

@truppelito
Copy link

Be honest - have you looked at it and immediately thought: "Nawww, I wish they'd used postfix match instead of prefix match - it would be so much more readable."

Yes, I can understand the order of execution more easily with postfix match. The verbs in the code should be read in the order of "post body send await match", not "match post body send await". To me this is an advantage that postfix match provides (I'm aware you provided counter arguments later in "Keywords set certain expectations", I just though I'd answer your question).

@janriemer
Copy link

@truppelito Yes, I can see your argument and reasoning now - thank you for the quick reply.

Even if we only look at it from that perspective (and ignore other arguments), I just don't think this is impactful enough to require a whole new language feature.

@tmccombs
Copy link

If I could go back in time, and change it to be infix, I would. But I don't think it is worth supporting it as both a prefix and infix keyword. I'd rather have support for postfix macros, and a implement postfix match as a macro, probably in a community crate, at least at first.

@novacrazy
Copy link

Having two versions of the same syntax does open up for having prefix await, at least. Might as well, right? If the goal is to enable other coding styles that read how the individual dev wants to write it. Do we really want code that reads like an English sentence, complete with run-on sentences, anyway? The code exists to perform a job, a command or instruction, not express poetry; we already have Perl for that.

@Lokathor
Copy link
Contributor

Personally, I would expect the other way: for people to use postfix match more and more as they get used to it, until prefix matching is just a legacy option.

@ssokolow
Copy link

Personally, I would expect the other way: for people to use postfix match more and more as they get used to it, until prefix matching is just a legacy option.

I can give a data point that I wouldn't follow that curve... but then I choose not to use languages like Haskell because I find that going too expression-oriented makes code hard to read and maintain.

@Lokathor
Copy link
Contributor

even if you choose to bind some value to a name, there's basically no intrinsic benefit of match x { over x.match {. People are just used to one and not the other.

let x = some(long, call, here);
// completely readable if the language did this in 1.0
x.match {
  Ok(a) => println!("{a}");,
  Err(e) => eprintpn!("{e}),
}

@ssokolow
Copy link

ssokolow commented Mar 28, 2024

I think we'll have to agree to disagree on that. I see it as mildly more work to parse, even in that simplified "not enough value to justify engaging in disruptive change" example.

Not as bad as languages that use keywords instead of punctuation for delimiting blocks, but still worse. (Same for currying-centric languages where you don't need parens to make function calls.)

Even if for no other reason, I don't want to see period-delimited postfix non-method special cases proliferate without a degree of justification I'm not yet convinced exists here.

@JamieH01
Copy link

JamieH01 commented Mar 29, 2024

If I could go back in time, and change it to be infix, I would. But I don't think it is worth supporting it as both a prefix and infix keyword. I'd rather have support for postfix macros, and a implement postfix match as a macro, probably in a community crate, at least at first.

this is exactly how i feel about this rfc, and why i think having this while also discussing postfix macros makes this pointless. this would be an utterly trivial macro to make if you really wanted it, and it would turn the first class support of this one specific keyword into a useless c++-itus remnant.

in my honestly blunt opinion, this is a pointless feature that adds weird specific complexities and will be shadowed by a far more useful feature being developed in parallel

@Monadic-Cat
Copy link

If I could go back in time, and change it to be infix, I would. But I don't think it is worth supporting it as both a prefix and infix keyword. I'd rather have support for postfix macros, and a implement postfix match as a macro, probably in a community crate, at least at first.

this is exactly how i feel about this rfc, and why i think having this while also discussing postfix macros makes this pointless. this would be an utterly trivial macro to make if you really wanted it, and it would turn the first class support of this one specific keyword into a useless c++-itus remnant.

in my honestly blunt opinion, this is a pointless feature that adds weird specific complexities and will be shadowed by a far more useful feature being developed in parallel

Honestly, I'd agree with this. If I had any confidence at all that postfix macros will ever exist. Which... will they?

@T-Dark0
Copy link

T-Dark0 commented Mar 29, 2024

On the topic of postfix macros, it's worth mentioning that this very same point was brought up during the discussions for let else: let Some(x) = foo else { break } is little more than foo.unwrap_or!(break), for example. (and the fully general form is foo.match_or!(Some(x), break), or even let_else!(Some(x) = foo, break), which can be written on today's stable). Making it syntax "adds weird specific complexities and will be shadowed by a far more useful feature being developed in parallel".

The feature made it in anyway, in no small part thanks to the IDE support it can boast.

Sure, .r#match! can be made, but it will never be as good as .match. As a supporter of postfix match, even I have to agree that it's not hugely better than the alternative (though I still think it's worth it). Sacrificing IDE integration as well would make it just plainly worse, whereas adding it as a language feature can at least be argued to be an improvement

@T-Dark0

This comment was marked as duplicate.

@janriemer
Copy link

Frequently requested changes | Fundamental changes to Rust's syntax (emphasis mine)

These also include proposals to add "alternative" syntaxes, in addition to those that replace the existing syntax. Many of these proposals come from people who also write other languages. Arguments range from the ergonomic ("I don't want to type this") to the aesthetic ("I don't like how this looks").
[...]
The established Rust community with knowledge of existing Rust syntax has a great deal of value, and to be considered, a syntax change proposal would have to be not just better, but so wildly better as to overcome the massive downside of switching.

Has this been considered for the syntax change in this RFC?

  • if yes: in what way specifically? Have there been discussions about it that someone can link to?
  • if no: I think we should reevaluate the proposed change under this guideline

Thank you ❤️


x.match {
Some("") | None => "Ferris"
x @ Some(_) => &x[1..]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this should be

    Some(x) => &x[1..]

Same mistake appears two more times later in the RFC.

Also previous line lacks , at the end of line (and three more occurrences).

flip1995 pushed a commit to flip1995/rust-clippy that referenced this pull request Apr 4, 2024
Experimental feature postfix match

This has a basic experimental implementation for the RFC postfix match (rust-lang/rfcs#3295, #121618). [Liaison is](https://rust-lang.zulipchat.com/#narrow/stream/213817-t-lang/topic/Postfix.20Match.20Liaison/near/423301844) ```@scottmcm``` with the lang team's [experimental feature gate process](https://github.com/rust-lang/lang-team/blob/master/src/how_to/experiment.md).

This feature has had an RFC for a while, and there has been discussion on it for a while. It would probably be valuable to see it out in the field rather than continue discussing it. This feature also allows to see how popular postfix expressions like this are for the postfix macros RFC, as those will take more time to implement.

It is entirely implemented in the parser, so it should be relatively easy to remove if needed.

This PR is split in to 5 commits to ease review.

1. The implementation of the feature & gating.
2. Add a MatchKind field, fix uses, fix pretty.
3. Basic rustfmt impl, as rustfmt crashes upon seeing this syntax without a fix.
4. Add new MatchSource to HIR for Clippy & other HIR consumers
@scottmcm
Copy link
Member

scottmcm commented Apr 7, 2024

I'm not quite sure having this feature landed on the main branch when there has not even been a motion for FCP here.

Since I bors'd the PR, I wanted to respond here.

One of the things that I at least am trying to do (and I think lang as a whole) is to not be a blocker unnecessarily for easily-revocable decisions. That means that even if some members are at "probably not, but I guess it's not impossible that I could be convinced", that's not enough to say that things can't land as experiments on nightly -- subject, of course, to T-compiler oversight about whether the maintenance burden is worth it given the extra uncertainty.

As I said when I signed up to liason it,

[…] under the expectation that this is absolutely controversial, so any work done on it should expect a very real possibility that the final answer ends up being "no, tear it out".

Since it turned out to not be a big deal in the compiler code and it didn't trip any of my "clearly no way" flags -- for example, { … }.loop would be unacceptable even though I like postfix -- I think it's fine to have in nightly for now.

You should think of that as having less weight than even an initial disposition on an RFC, and anyone with new arguments for or against doing this should absolutely post them here. Hopefully having it in nightly makes it easier for both sides to demonstrate their posts more concretely, since one can make runnable examples.

@scottmcm
Copy link
Member

scottmcm commented Apr 7, 2024

Keywords set certain expectations about the control flow that is going to be introduced.

I wanted to address this point specifically because I agree with that sentence, but not the later conclusion.

To explore this via a short diversion to a different feature from the one in this RFC: Why was it that, despite a bunch of people who really wanted .await, nobody was asking for .async?

I think it's exactly what you said: async is a prefix, and takes a block, because it affects that whole block and gives you a warning that there's (almost certainly) going to be .awaits in there to watch out for. This is the same reason that loop { … } is prefix: it tells you, as forward reader, that the code in the block is (almost certainly) going to be executed multiple times, and that breaks and continues might show up that weren't possible before.

That's not true, however, of await. If you're in a chain, the things that happen before the await make sense even if you don't know that they're going to be awaited soon. The .await operates only on the value that was produced by those things before it. The same is true of ? -- you can read code that does something that will create a Result without needing to know beforehand whether it'll get .and_thend or unwrap_or_elsed or ?d.

So jumping back to this RFC, which is match more like? Well, there's no new control flow that's only possible in the what-you're-going-to-be-matching part. That code isn't run multiple times (like the condition in a while is, for example). And when you match something, it's just the end result that's matched; how you got there doesn't matter.

That's why I consider .match at least plausible. It fits the properties of things that we've seen work well as postfix in Rust before, and doesn't have the properties of things that are much better as prefix.

That's not to say we should do it, necessarily. The TMTOWTDI counter-arguments are still strong.

(But they're also not nearly as strong as for .return, which while it also fits my criteria for postfix, doesn't have the chaining justification to make it potentially worth bothering to offer.)

@janriemer
Copy link

Keywords set certain expectations about the control flow that is going to be introduced.

Thank you, @scottmcm, for referring to this argument specifically. ❤️

That's not true, however, of await. If you're in a chain, the things that happen before the await make sense even if you don't know that they're going to be awaited soon. The .await operates only on the value that was produced by those things before it. The same is true of ? -- you can read code that does something that will create a Result without needing to know beforehand whether it'll get .and_thend or unwrap_or_elsed or ?d.

So jumping back to this RFC, which is match more like? Well, there's no new control flow that's only possible in the what-you're-going-to-be-matching part. That code isn't run multiple times (like the condition in a while is, for example). And when you match something, it's just the end result that's matched; how you got there doesn't matter.

I totally agree with all of this! Thank you for dissecting it so clearly! However, this is not my main argument.

I'm not interested in what happens before the .match, because prefix match doesn't give me additional info here either. My main concerns are:

  • what happens within the match block
  • how postfix .match could suggest a code flow (when looking at the lines before .match) that is not necessarily there

To illustrate these points further, here are some more examples, so that we can have a basis for further discussions:

Example 1 - Postfix match - function chain that isn't any

fn do_something_special(quux: &mut Quux) {
  foo
    .bar
    .map(|x| x.to_baz())
    .filter(|x| x.is_baz())
    .map(|x| x.whatever())
    .match {
       Some(Baz(n)) => quux.fill(n),
       _ => {}
    }
}

This example looks like we chain multiple values together in order to return something from the function... (can easily be assumed, because of missing let binding) - with the difference that we actually don't return anything - it's totally procedural. This is not obvious, when looking at the first few lines before the .match block. If there was a prefix match directly at the beginning, one could much more likely assume a procedural control flow (which would be in accordance with the return type of the function).

The only other construct in Rust today (that I can think of) that looks very similar to the above example is .for_each (whose usage I'd discourage over a for loop in this example for the same reason).

Example 2 - Return from a function chain within match block

fn do_something_special(quux: &mut Quux) -> Result<()> {
  foo
    .bar
    .map(|x| x.to_baz())?
    .filter(|x| x.is_baz())
    .match {
       Some(Baz(n)) => if n > 0 {
          quux.fill(n)
       } else {
          return Err("Failed!")
        // ^^^ we jump out of this chain - as of today, Rust has not the ability to jump out of a
        // (non-prefix-keyword introduced) chain within a block; with prefix `match` one will be aware
        // of this possibility _from the start_, not until reading four lines down the line
       }
       _ => {}
    }
    .map(|x| x.whatever())
}

Please see the comment in the code block for the main argument in this example.

Summary

I think the style of code postfix .match enables, will be unprecedented in Rust. IMHO, it makes code harder to read, because we'll get two styles of code (functional vs. procedural) that, at first glance, look the same, but could turn out semantically very different.

@kennytm
Copy link
Member

kennytm commented Apr 7, 2024

@janriemer

Rust has not the ability to jump out of a (non-prefix-keyword introduced) chain within a block

The ? you have used at line 4 in the very Example 2 has exactly the ability to jump out of the chain.

@janriemer
Copy link

@janriemer

Rust has not the ability to jump out of a (non-prefix-keyword introduced) chain within a block

The ? you have used at line 4 in the very Example 2 has exactly the ability to jump out of the chain.

@kennytm The important bit in my argument is within a block. ? is outside blocks, return, continue etc. are within blocks.
This is consistent with the argument I've made a couple posts before:

When I see (method) chains being used, I can be sure that there is mostly a linear control flow top to bottom. I only have to scan for the short-circuiting operator ? on the outside to know, whether I might exit early.

@cuviper
Copy link
Member

cuviper commented Apr 7, 2024

Argument expressions in your chain can contain control flow too, like .foo(cond || break).

@janriemer
Copy link

janriemer commented Apr 7, 2024

Argument expressions in your chain can contain control flow too, like .foo(cond || break).

This is true - good catch! 👍 One could also introduce a block (where a value is expected), supporting your argument, like:

foo
   .bar({
      if !cond {
         break;
      }
      my_val
   })

But if I'd encounter such code (which is rare!), I'd think about restructuring it for better readability (with "such code" I mean using break return etc in a block that is used within a chain to produce a function arg value).

With postfix match, using break, return within chains could become much more common than in today's Rust (which is not good, IMO).

@tmccombs
Copy link

tmccombs commented Apr 7, 2024

The only other construct in Rust today (that I can think of) that looks very similar to the above example is .for_each

What about any other function with a return type of unit? The match could be factored out into a separate method with a return type of unit, and it wouldn't be any more apparent that the expression doesn't return a value.

@janriemer
Copy link

Yes, this is true, good point! 👍

But if "factoring out match part into own function" was a common theme in regular code, we wouldn't need postfix match in the first place.
Therefore my conclusion is that it is currently more common to use match in chains, rather than in it's own function.

github-actions bot pushed a commit to ytmimi/rustfmt that referenced this pull request Apr 8, 2024
Experimental feature postfix match

This has a basic experimental implementation for the RFC postfix match (rust-lang/rfcs#3295, #121618). [Liaison is](https://rust-lang.zulipchat.com/#narrow/stream/213817-t-lang/topic/Postfix.20Match.20Liaison/near/423301844) ```@scottmcm``` with the lang team's [experimental feature gate process](https://github.com/rust-lang/lang-team/blob/master/src/how_to/experiment.md).

This feature has had an RFC for a while, and there has been discussion on it for a while. It would probably be valuable to see it out in the field rather than continue discussing it. This feature also allows to see how popular postfix expressions like this are for the postfix macros RFC, as those will take more time to implement.

It is entirely implemented in the parser, so it should be relatively easy to remove if needed.

This PR is split in to 5 commits to ease review.

1. The implementation of the feature & gating.
2. Add a MatchKind field, fix uses, fix pretty.
3. Basic rustfmt impl, as rustfmt crashes upon seeing this syntax without a fix.
4. Add new MatchSource to HIR for Clippy & other HIR consumers
@tmccombs
Copy link

tmccombs commented Apr 8, 2024

Therefore my conclusion is that it is currently more common to use match in chains, rather than in it's own function.

I think currently it is most common to use a temporary variable, because using a match in a chain is currently pretty awkward, since it is a prefix keyword.

@LunarLambda
Copy link

Postfix match makes a ton of sense to me and I would love to have it in the language. It's essentially a universal .map() operator/keyword, which would be incredibly useful.

I think if you see match as a keyword for transforming a value, rather than doing control flow, it makes it a lot easier to see why postfix match makes sense, but postfix if/for/while/loop/let/etc doesn't.

As previously mentioned, it could make redundant, or at least provide alternative to, many different combinators/transformers on Option/Result and in async chains.

The bar for writing "weird" code with it is perhaps lower (is it?), but as pointed out, you can already do this in a handful places by mis-using block expressions, and I think it would be pretty straightforward to add a "control-flow-in-postfix-match" clippy lint, and very easy to spot in code review. I don't think "people might write weird code with it" is a compelling argument against an otherwise genuinely useful potential language feature, at least to me.

This is the most convoluted if true I could come up with before my lunch break :p

if loop {
    break {
        let x = 0;
        (x != 0).then(match break x == 0 { _ => || unreachable!() }).is_some()
    }
} {
    println!("teehee");
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
T-lang Relevant to the language team, which will review and decide on the RFC.
Projects
None yet
Development

Successfully merging this pull request may close these issues.