rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

RFC: Postfix match

Open conradludgate opened this issue 3 years ago • 110 comments
trafficstars

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 {
    _ => {}
}

conradludgate avatar Jul 23 '22 08:07 conradludgate

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.

truppelito avatar Jul 23 '22 11:07 truppelito

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.

conradludgate avatar Jul 23 '22 11:07 conradludgate

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.

truppelito avatar Jul 23 '22 11:07 truppelito

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).

scottmcm avatar Jul 24 '22 20:07 scottmcm

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.

sunfishcode avatar Jul 25 '22 20:07 sunfishcode

@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).

truppelito avatar Jul 25 '22 21:07 truppelito

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.

T-Dark0 avatar Jul 27 '22 22:07 T-Dark0

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!) https://github.com/rust-lang/rust/compare/master...cuviper:rust:dot-match

cuviper avatar Jul 27 '22 23:07 cuviper

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.

Cassy343 avatar Jul 28 '22 01:07 Cassy343

&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.)

steffahn avatar Jul 28 '22 01:07 steffahn

@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?

afetisov avatar Jul 28 '22 01:07 afetisov

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.

steffahn avatar Jul 28 '22 01:07 steffahn

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.

kristopherbullinger avatar Jul 28 '22 03:07 kristopherbullinger

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.

ChayimFriedman2 avatar Jul 28 '22 06:07 ChayimFriedman2

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!

conradludgate avatar Jul 28 '22 06:07 conradludgate

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 https://github.com/rust-lang/rfcs/pull/3295#issuecomment-1197618629). 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. https://github.com/rust-lang/rfcs/pull/3295#issuecomment-1197539283 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 https://github.com/rust-lang/rfcs/pull/3295#issuecomment-1193385210 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.

scottmcm avatar Jul 28 '22 07:07 scottmcm

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

truppelito avatar Jul 28 '22 07:07 truppelito

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.

ChayimFriedman2 avatar Jul 28 '22 08:07 ChayimFriedman2

@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 avatar Jul 28 '22 10:07 truppelito

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.

truppelito avatar Jul 28 '22 11:07 truppelito

(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.

T-Dark0 avatar Jul 28 '22 11:07 T-Dark0

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.

ssokolow avatar Jul 28 '22 11:07 ssokolow

@truppelito Your last example actually looks reasonable, but I doubt that it will be useful enough in practice. How often do you need to write such ad-hoc matches which shouldn't be extracted into their own separate functions? Also, enums typically have more than 2 variants, and with more variants that chained match will very quickly become unwieldy. Of course, one can always say "let the programmer exercise their judgement", but in practice people will gravitate towards one or the other style, and a feature which can be properly used in a much smaller number of cases than where it could be misused should arguably not be in the language, moreso when it duplicates an already existing and generally better feature.

I'll note that this RFC is just a special case of what can be done with postfix macros (see #2442). This is exactly the major uses case that people would want to use them for: encapsulate constructions such as

match foo {
    Ok(x) => x,
    Err(err) => { log::debug!("{}", err); return }
}

in postfix combinators unwrap_or!(|err| { log::debug!("{}", err); return }). Having postfix macros would allow to seamlessly use arbitrary control flow operations (await, return, break, yield etc) inside of the else-branch of the unwrap_or!. I assume this is one of the major reasons for this specific RFC.

The postfix macros would allow much more than this simple postfix match, but would also allow people to add postfix match per-crate, if they consider it desirable. It would also move the question of adding it to Rust from the syntax to the library level, which is easier to reason about.

@T-Dark0

Iterators work completely fine, and aren't generally accused of unreadability

You're clearly hanging out among very different people. I have seen many people coming from other languages complain about iterator chains, and I personally consider most iterator chains more complex than 3-4 adapters (excluding into_iter and collect) or with complex nesting an antipattern and a readability nightmare.

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?

Here's a strawman if I ever saw one. If you abuse shadowing to give meaningless repeated names to the iterators (and note that the relevant thing about an iterator is its item, and not that it's just an iterator!), it's squarely on you. No one can prevent you from writing bad code if that's your goal.

Please keep complaints about the slippery slope to RFCs that actually try to establish a slippery slope.

This is an RFC which establishes a slippery slope. Nothing in the reasoning or motivation distinguishes the proposed change from any other similar changes, or provides a strong reason why this specific case deserves a special treatment. Making it only about match makes the RFC weaker, not stronger in this regard. It just means that the solution proposed is ad-hoc, and no thought is given to the integration with any planned or possible language extensions.

What's the end goal and why is this RFC a good step towards it? For me, it looks like the end goal is "postfix everything", so that one can turn all code into method chains. It's not that good of an idea even in Bash which actually implements it.

afetisov avatar Jul 28 '22 12:07 afetisov

@afetisov

  • "How often do you need to write such ad-hoc matches which shouldn't be extracted into their own separate functions?"

I honestly don't know. I can say, for my part, that I would use this feature (meaning I would prefer it over other options such as factoring the match into its own function and using prefix match) in a few places in my code, but cannot speak for the overall Rust community.

  • "Also, enums typically have more than 2 variants, and with more variants that chained match will very quickly become unwieldy."

I agree. But of course, maybe you're matching against only one case of the enum, and the other cases are handled with a default value. In the end, you're saying "but the match could be unwieldy" and I'm saying "but the match could be short and simple", and both can be true in general, so I don't think either side here holds any special weight over the other. All I can say is "let the programmer exercise their judgement".

  • "but in practice people will gravitate towards one or the other style"

I don't know. Maybe that's true. Personally, I think would gravitate towards a style I describe as "use prefix for large matches and postfix for small ones". I don't know if such a general statement about what people do is that easy to say so confidently.

  • "and a feature which can be properly used in a much smaller number of cases than where it could be misused should arguably not be in the language"

This is true of the for_each vs for debate too. Since the improper use is about the size/complexity of the match, clippy can advise here, as mentioned by other people.

  • Postfix macros for .match! { ... }

That is a reasonable argument. However, every time I read "we shouldn't add this because it's already possible with a macro like so: xxx", I recoil. It's a hard argument to go against, because on the surface, it's true. However, macros are just not as nice with code formatting and especially code completion, and I don't know if they'll ever be. It just so happens that those two things alone increase my productivity immensely. I don't know how well postfix match would work as a macro in this respect, but my experience has made me generally prefer features to be first-class citizens rather than macros.

truppelito avatar Jul 28 '22 12:07 truppelito

For comparison, here is the reasoning why await or ? should be postfix (in my interpretation, I don't know the actual thought process of the lang team when those were decided):

Writing async code, or writing code with proper error propagation instead of panics, should be as smooth and as similar to writing standard Rust as possible. In an async codebase, most functions will have to be async, and thus their result must be awaited. In a codebase which properly utilizes error propagation, in particular if one tries to absolutely exclude any possibility of panics (which is a hard requirement in high-assurance applications), most functions will similarly be fallible, i.e. returning Result. This means that most (or at least a very major part) of the function calls in such codebases will use the await and ? operators.

If they are made prefix, then codebases with asynchrony or fallibility cannot use the same idioms as normal Rust code. You won't be able to chain async or fallible methods, and method chaining is a common pattern in ordinary Rust. This means that migration between sync and async code, or adding the possibility of errors to the function, will require a significant restructuring of consumer code. This adds frictions for migration between these effect modes, while we would want such transitions to be as seamless as possible. It would also unjustly penalize the ergonomics of async and fallible code, even though there is nothing they could do to avoid it, and no other way to structure the code.

With async, it would make maintaining simultaneous sync and async interfaces even more difficult, and make async Rust even more different from sync Rust. With fallibility, making error propagation non-ergonomic will encourage poor error handling practices, like panics and error swallowing. Combining the two, or adding nested Result types, will make the code even worse to both read and write.

See how this defines a major advantage of postfix syntax in these cases? It's not just "we want to chain everything", it's "adding these effects should require minimal changes and result in familiar code". It also directly limits the scope of the argument: it absolutely doesn't apply to most other possible postfix operations, including the proposed postfix match, or possible postfix let or postfix for. It does, however, apply to any algebraic effect, and can be repeated verbatim for yield, unsafe call markers (which don't currently exist in Rust), or any other possible algebraic effect. Thus it makes the case that any additional algebraic effects should follow the same approach, which IMHO they should. Discussing whether postfix effect invocation makes sense for more complex effects is perfectly in scope for arguing with that reasoning.

afetisov avatar Jul 28 '22 12:07 afetisov

By the way, I can totally imagine the desire to add postfix for. It would obviate the need for the proliferation of for_each, try_for_each, async_for_each and similar methods. Again, this would likely be bad in the majority of cases and very useful occasionally, which makes it a good addition as a library-provided postfix macro.

afetisov avatar Jul 28 '22 12:07 afetisov

I think I made the mistake of starting the comparison of helper methods to language features. for x in impl_into_iter is a language construct. impl_iter.for_each(|_| ..) is a library construct. I think it is very important to call out the difference here - we added this library construct because in some cases it was useful to not fall back to the more general construct. match x { .. } and x.match { .. } are both language constructs. I don't think "it's convenient sometimes if that's your thing" is sufficient motivation for almost-but-not-actually duplicating an already existing language feature. I want to see evidence along the lines of "here is a problem that can be seen in these crates (insert crates here) that this language feature solves." I just don't see that here.

Now I don't think this is an unreasonable bar to pass. I joined the rust community after try!, but I imagine the RFC for ? was along the lines of "the problem is that writing the match form is really long and cumbersome, and the macro isn't convenient even for short, reasonable chains, we can see people using the macro here (insert popular crates), and this feature minimizes the the aforementioned concerns."

This features seems to have maximum utility in the specific case of

x.foo()
    .match {
        A::A => ..,
        A::B => ..,
    }
    .bar()

As @scottmcm mentioned:

it does still need to pass a "is it worth bothering" check.

This doesn't do it for me.

If you have

x.foo()
    .match {
        A::A => ..,
        A::B => ..,
    }

then you can do

let y = x.foo();
match y {
    A::A => ..,
    B::B => ..,
}

If you have

x.foo()
    .match {
        A::A => ..,
        A::B => return ..,
    }
    .bar()

then personally I think that's confusing control flow*, and should be

let y = x.foo();
let unbarred_y = match {
    A::A => ..,
    A::B => return ..,
};
unbarred_y.bar()

or

let y = x.foo();
match y {
    A::A => unbarred_y.bar(),
    A::B => return ..,
}

*The reason this is not confusing with ? is because that is limited to special cases which have similar semantics to stack unwinding in languages with exceptions. There is overwhelming precedent there and very few weird cases, wheres every time you go to read a .match you'd need to comprehend the control flow from scratch (which you already need to do with prefix match), but also in the context of a chain/pipeline.

Overall I don't think there is enough motivation here to warrant adding a language feature. I'd be more open to this discussion if there was widespread use of

foo.pipe(|x| match x {
    ..
}).pipe(|y| match y {
    ..
})

But I personally have never run into code using the tap crate in the wild, let alone evidence that this is a pervasive pattern that people want a solution to.

Cassy343 avatar Jul 28 '22 13:07 Cassy343

But I personally have never run into code using the tap crate in the wild

Most people don't want to pull in such a trivial dependency, but also don't want to vendor it themselves. So while it's popular, it's never going to be that popular.

How often do you need to write such ad-hoc matches which shouldn't be extracted into their own separate functions? Also, enums typically have more than 2 variants

I do this all the time at work! We do backend web development. Right now, 65% of my work is non-trivial error handling. Sometimes we will make a custom error type and write the 60 lines of From impls for the non-trivial handling. Other times I just want to match on the cases and return the correct error (think error messages + service that errored + can this be retried).

Here's some redacted code that I am looking at right now and is literally running in production.

match context
    .client
    .create_some_request(tonic::Request::new(some_request))
    .await?
    .into_inner()
    .result
    .ok_or(CustomHandlerError {
        inner: ServiceError::InvalidResponse,
        retry: false,
    })? {
    Success(s) => Ok(s),
    Error(err) => Err(CustomHandlerError::from(err)),
}

I could rewrite this with bindings

let response = context
    .client
    .create_some_request(tonic::Request::new(some_request))
    .await?
    .into_inner();

let result = response.result
    .ok_or(CustomHandlerError {
        inner: ServiceError::InvalidResponse,
        retry: false,
    })?;

match result {
    Success(s) => Ok(s),
    Error(err) => Err(CustomHandlerError::from(err)),
}

This is probably nicer, I agree. But these identifiers are most likely the ones I would choose, but don't add any value.

I would honestly prefer

context
    .client
    .create_some_request(tonic::Request::new(some_request))
    .await?
    .into_inner()
    .result
    .match {
        Some(Success(s)) => Ok(s),
        Some(Error(err)) => Err(CustomHandlerError::from(err)),
        None => return Err(CustomHandlerError {
            inner: ServiceError::InvalidResponse,
            retry: false,
        }),
    }

conradludgate avatar Jul 28 '22 13:07 conradludgate

I would honestly prefer

I can see why you do prefer that, however I actually prefer the former. I'd like to ask, why is

context
    .client
    .create_some_request(tonic::Request::new(some_request))
    .await?
    .into_inner()
    .result
    .ok_or(CustomHandlerError {
        inner: ServiceError::InvalidResponse,
        retry: false,
    })?
    .pipe(|res| match res {
        Success(s) => Ok(s),
        Error(err) => Err(CustomHandlerError::from(err)),
    })

insufficient for you? Are you sure there are cases so pathological that they cannot be nicely solved with already existing helpers/features?

Edited a couple times because I can't type rust code on github apparently

Cassy343 avatar Jul 28 '22 14:07 Cassy343

I think something like this is how I would write it today:

let result = context
    .client
    .create_some_request(tonic::Request::new(some_request))
    .await?
    .into_inner()
    .result;

match result {
    Some(Success(s)) => Ok(s),
    Some(Error(err)) => Err(CustomHandlerError::from(err)),
    None => Err(CustomHandlerError {
        inner: ServiceError::InvalidResponse,
        retry: false,
    }),
}

Personally I do think this kind of separation of acquisition and decision demonstrates the value of breaking up the chain.

phaylon avatar Jul 28 '22 14:07 phaylon