[Pre-RFC] Alternative syntax for pattern-matching based branching

Summary

This RFC proposes adding alternative syntax for non-exhaustive branching based on pattern matching: the match : and match while syntax.

If the syntax is added,

match <expr> : <pattern> => {
    // statements
}

and

match <expr1> : <pattern> => <expr2>

would respectively be equivalent to current

if let <pattern> = <expr> {
    // statements
}

and

if let <pattern> = <expr1> {
    <expr2>
}

. The match while syntax will have a similar equivalence to the current while let syntax.

Motivation

The current if let and while let syntax makes it easy for programmers to confuse let statements as expressions (with a following ;) that return bool values. Let me call a let statement with its ; stripped a "let clause".

Since Rust is an expression-oriented language, when one is not sure about a langauge element, they will assume that it is an expression. The let clause in Rust is not an expression. This creates an exception and causes some inconsistency and inconvenience, but the problem is not too huge. Just as this RFC mentioned, one will very quickly learn that this syntax does not create an expression by some simple experiments. However, the if let and while let syntax makes the problem worse. In if let and while let syntax, the usage of the let clause is exactly the same as if the let clause creates an expression that returns a bool value. In other words, currently, a learner who is not sure about whether the let clause creates an expression will be given both clues implying that it does and clues implying that it does not. Programmers will sometimes get confused and try to use let clauses as bool expressions.

In this RFC, it is given as a support material that programmers try to chain let clauses with &&. This is a sign of confusing let clauses as expressions. It is also mentioned in this RFC that using || with let clauses has been attempted and allowing it will be considered in the future. In this RFC, it is proposed that using ! to negate let clauses should be supported. I think these are also proof that programmers are confusing let clauses as expressions.

The RFC proposing && chain with let clauses is accepted. Once it is implemented and and the new version is published, the let syntax would be even more similar to a bool expression. The !let and "let with ||" proposals will create even more cases where let clauses are used as an expression. This trend will possibly cause a result that beginners will need to memorize special cases. They will need a list or a cheatsheet to help them to know whether a let clause can be used as a bool expression in a certain case.

I feel this trend is dangerous, so I want to propose new syntax which does not use let to possibly stop this trend, or at least to give people who do not want deal with the confusion an alternative.

Guide-level explanation

match <expr> : <pattern> => {
    // statements
}

and

match <expr> : <pattern> => <expr2>

would respectively be equivalent to current

if let <pattern> = <expr> {
    // statements
}

and

if let <pattern> = <expr> {
    <expr2>
}

. The match while syntax will have a similar equivalence to the current while let syntax.

Of course, just as the original match syntax, you can use | to match multiple patterns:

match <expr> : <pattern1> | <pattern2> => {
    // statements
}

If you want to chain multiple pattern matching conditions, you can write

match <expr1> : <pattern1> =>
match <expr2> : <pattern2> | <pattern3> =>
match <expr3> : <pattern4> => {
    // statements
}

To integrate boolean conditions and adding boolean guards into it:

match <expr1> : <pattern1> =>
match <expr2> : <pattern2> | <pattern3> =>
match <expr3> : <pattern4> if flag0 == true | <pattern5> =>
if a == 4 || b <= 3 && flag1 == true {
    // statements
}

This syntax does not have else, but when you try to write

if let <pattern> = <expr> {
    // some statements
} else {
    // more statements
}

, you can always write

match <expr> {
    <pattern> => {
	// some statements
    },
    _ => {
	// more statements
    }
}

Reference-level explanation

We replace the following production:

expr : literal | path | tuple_expr | unit_expr | struct_expr
     | block_expr | method_call_expr | field_expr | array_expr
     | idx_expr | range_expr | unop_expr | binop_expr
     | paren_expr | call_expr | lambda_expr | while_expr
     | loop_expr | break_expr | continue_expr | for_expr
     | if_expr | match_expr | if_let_expr | while_let_expr
     | return_expr ;

with:

expr : literal | path | tuple_expr | unit_expr | struct_expr
     | block_expr | method_call_expr | field_expr | array_expr
     | idx_expr | range_expr | unop_expr | binop_expr
     | paren_expr | call_expr | lambda_expr | while_expr
     | loop_expr | break_expr | continue_expr | for_expr
     | if_expr | match_expr | match_colon_expr | match_while_expr
     | if_let_expr | while_let_expr | return_expr ;

match_colon_expr : "match" no_struct_literal_expr ":" match_colon_arm ;

match_colon_arm : attribute * match_pat "=>" [ expr | '{' block '}' ] ;

match_pat : pat [ '|' pat ] * [ "if" expr ] : ;

Note that match_pat is the same as the original one used with match_expr.

Drawbacks

Redundant: this syntax can be considered as a syntax sugar for if let, which is already a syntax sugar for match with only one meaningful branch…

Limited: This syntax does not have equivalence to if let PATTERN = EXPR { } else { }.

This RFC creates a possible pitfall: This RFC introduces while let && syntax:

while let <pattern1> = <expr1>
    && let <pattern2> = <expr2>
    && let <pattern3> = <expr3> {
    // statements
}

. The code above is NOT equivalent to

match <expr1> while <pattern1> =>
match <expr2> while <pattern2> =>
match <expr3> while <pattern3> => {
    // statements
}

The former is one single loop with multiple conditions, while the later one is multiple nested loops, each having one condition. An equivalence using the match while syntax would be

loop {
    match <expr1> : <pattern1> =>
    match <expr2> : <pattern2> =>
    match <expr3> : <pattern3> => {
	// statements
	continue;
    }
    break;
}

which is sort of awkward, but I wonder how popular this use case is.

The problem of duplicate code in else clauses cannot be solved by this syntax.

Also, generally the match syntax is a little more verbose than the if let syntax.

Another issue is, : does not align well with while.

Rationale and alternatives

Why is this design the best in the space of possible designs:

This design uses one single language element (the match : syntax) to support

  1. Simple non-exhaustive pattern matching conditional execution.
  2. Non-exhaustive pattern matching branching with multiple conditions that should all be satisfied (the “and” case).

2 is proposed and accepted here.

Also, it is totally consistent with the current match syntax. More importantly, unlike if let and while let, it does (should) not cause any confusions.

This introduction of this syntax will also enable a strong binding between keywords and their jobs. If a programmer completely uses match on to replace if let, then when reading their code, whenever one sees match, they know they have a branching here, and when one sees let, they know here is a (simple) declaration.

We can also possibly extend this syntax to support matching negation which is proposed here. The method is in the future possibilities section.

What other designs have been considered and what is the rationale for not choosing them:

The usage of : can certainly be discussed more. I am using it here because if cannot be used as it is already used for guards, and using : does not need any additional keywords. If people do not like it, it can certainly be changed into other things, like @, on, only, nex (for non-exhaustive), etc.

This RFC’s rationale section provides very good insights on other possible designs. I agree with all of them except for

  1. I do think causing programmers to confuse let clauses as expressions is a big problem.
  2. I do think current the match syntax places patterns at lhs and expressions at rhs, so it is acceptable to have a syntax which starts with match that has such a lhs/rhs configuration.

What is the impact of not doing this:

Just as mentioned in the Motivation section, we are having a dangerous trend of making let clauses bool expressions in some cases but no other cases. This will create pitfalls and difficulties in memorization for future Rust learners. For a language that has the potential to replace C++, I really hope this will not happen. Also, I do not think it a good idea to just make the clauses bool expressions.

Prior art

Swift

Swift uses let for all pattern matching. I feel it is where Rust developer got the inspiration of if let and while let. However, what is good for Swift might be bad for Rust. Swift is not an expression-oriented language. One can only blame themselves if they assumed anything in Swift is an expression, so have such syntax will not cause as strong a confusion as what the syntax can cause in Rust. Also, Swift uses let anywhere there is a pattern matching. This is not the case in Rust. We also have match in Rust which is specialized in branching (for comparison, let statements in Rust are specialized for declaration). I think it makes more sense to make all syntax used for branching similar to each other.

Unresolved questions

What parts of the design do you expect to resolve through the RFC process before this gets merged:

The symbol/keyword separating the pattern and expression can be further discussed.

What parts of the design do you expect to resolve through the implementation of this feature before stabilization:

What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC:

Should we make let <pattern> = <expr> clauses expressions: I think for consistency we should, but it should not return a bool. That would be confusing. I think it can either return <expr> like C/C++ or simply return ().

Future possibilities

Negation

We can extend this syntax to support matching negation proposed here. The syntax would be like this:

match <expr> : not <pattern> => {
}

Deprecation

Personally I hope if let and while let would get deprecated. I do not think that can happen, though.

Why do we need two ways to do the same thing? This alone is a huge point against doing this. Using the argument that we already have multiple ways of doing the same thing is not an argument in favor of adding a new way (I am saying this because whenever I bring this up, this is the first response that I get, and it isn't a valid argument).

This makes at least one more syntax form the support, so this argument rings hollow. Due to backwards compatibility guarantees, we can't remove any syntax forms.

This is wrong, let can also pattern match. The patterns must be infallible is all.

For example,

let (x, y) = (0, 1);
assert_eq!(x, 0);
assert_eq!(y, 1);
9 Likes

I would further note that another alternative could be allowing a let expression as an rvalue actually return a boolean.

3 Likes

There have been a few soft proposals for let-as-expr that actually approach covering the edge cases decently well. I think we’re likely to asymptotically approach that by slowly allowing let in more positions.

The confusion of let-not-quite-expr can be handled quite well by compiler errors.

1 Like

<expr> : <something> conflicts with type ascription syntax.

The proposed syntax necessary has type () because there is no else clause. Which encourages imperative-style programming and it doesn’t fit Rust, an expression-oriented language, in my opinion.

8 Likes

Oh I missed that. : can also be any keyword or special characters that fit in the context.

Why do we need two ways to do the same thing?

Yeah, I actually hesitated a lot before bringing this up because of the redundancy issue. One reason I made this proposal is that I think it is worthwhile to start a discussion on the let-as-expr problem. I didn't see any serious discussion on it else where yet.

This is wrong, let can also pattern match.

I didn't make myself clear enough... What I wanted to say is that let is more specialized for the declaration usage of pattern matching, while match is more specialized for branching.

I actually really want to see a discussion on that. One argument I have seen against that is that after let returns a bool when you write something like

fn foo() {
    let bar = 0
}

The compiler would produce an error message saying that this function is expected to return () but got bool, which is less useful than just saying “hey you missed a ;.”

The compiler should offer a hint to insert a ; in that case. (Why doesn’t it currently‽)

It does

https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=4f09814ca33b5dca164be739228d7620

error: expected one of `.`, `;`, `?`, or an operator, found `}`
 --> src/lib.rs:3:1
  |
2 |     let x = 0
  |              - expected one of `.`, `;`, `?`, or an operator here
3 | }
  | ^ unexpected token

error: aborting due to previous error

And I think it would even if we change let x = <expr> to evaluate to a boolean

What I checked was that fn main() { true } doesn’t. Apparently there’s a check for “pure” literals?

2 Likes

My take-away from this is that we should lean into the user's confusion by making what they think is a reality an actual reality. That is, the confusion goes entirely away if let PAT = EXPR is actually an expression. This is also a good idea because checking whether an expression matches a pattern is useful. We have a bunch of methods like is_some that implement this in a low-powered way. Many Implementations of PartialEq would also be unnecessary if let was an expression.

This means that if let would still be a used form and now instead of removing the confusion around let possibly being a bool-typed expression, you now also have questions like "Which way should I use?" between the old and the new syntax. Moreover, I think this brings more complexity to the compiler rather than removing some (which let PAT = EXPR being syntactically valid achieves).

Why is that?

I think @CAD97 is right here. Cheatsheets will not be needed. If anything, by making let PAT = EXPR into a syntactically valid expression, as is done in [let_chains, 2/6] Introduce `Let(..)` in AST, remove IfLet + WhileLet and parse let chains by Centril · Pull Request #60861 · rust-lang/rust · GitHub, we can turn:

error: expected expression, found statement (`let`)
 --> src/lib.rs:2:9
  |
2 |     bar(let 0 = 1);
  |         ^^^ expected expression
  |
  = note: variable declaration using `let` is a statement

into this:

error: `let` expressions are not supported here
  --> $DIR/lib.rs:2:9
   |
LL |     bar(let 0 = 1);
   |         ^^^^^^^^^
   |
   = note: only supported directly in conditions of `if`- and `while`-expressions
   = note: as well as when nested within `&&` and parenthesis in those conditions

(The way I have implemented this, if you simply remove the emission of this errors then (let 0 = 1): bool is now a semantically valid expression)

4 Likes

Why is that?

One argument I have seen against making let an expression is mentioned above:

After let returns a bool when you write something like

fn foo() {
   let bar = 0
}

The compiler would produce an error message saying that this function is expected to return () but got bool , which is less useful than just saying “hey you missed a ; .”

Personally I feel expressions like let foo = (let <pattern> = <expr>) can be confusing, and semantically the word "let" doesn't imply anything related to boolean values.

I think @CAD97 is right here. Cheatsheets will not be needed.

I feel that the new error message already describes the case where let can be used as an expression in a pretty convoluted way. It can be hard for beginners to realize what the message means quickly, especially for those who are not yet familiar with && and parenthesis. When ! and || are allowed to be used with let (if they will be) the message would be even more complicated.

you now also have questions like “Which way should I use?” between the old and the new syntax.

I agree with this. I made this post mostly to point out that the "Is let an expression" confusion can be a problem. I personally like the solution I proposed, and I think it is worth sharing, but I do realize it's got quite a few drawbacks.

So in this case we can either do the error recovery, or we can say that the type is wrong (and do some more recovery).

I think it would be more likely that let <pattern> = <expr> is not bound to another binding. But if you do need a binding, some appropriate naming can ease confusion:

let is_some = (let Some(_) = stuff());

I find that it does if you take into account the fallible nature of pattern matching and this intuition can be driven by if let, && and ||. E.g. consider (let Some(x) = foo()) || continue;

I feel that a user is more likely to understand && and parenthesis before they ever understand let. In particular, && and parenthesis exist in nearly all languages and parenthesis in particular is something you are likely to understand from at least middle school mathematics.

The message is precise, but it doesn't have to be that precise and you would still get "the thing you are trying to do isn't allowed here" which is enough for most cases. We could even suggest an alternative way of writing what the user wants because we can understand it now. As for !, I think that if we support that, then we should support let expressions everywhere.

2 Likes

I feel that a user is more likely to understand && and parenthesis before they ever understand let .

What I meant is that this can be confusing for users who are not familiar with the case where && and parenthesis are used with if let. They will be confused by what the second line of the note is refering to.

I still think when people see let in the middle of a complex expression they will be a little surprised. I think it would be perfect if some keyword that is semantically closer to the concept of success and failure had been chosen (like we just do match whenever pattern matching occurs), but I won't say I cannot live with let that returns bool.

I'm open to tweaking the error message based on user input. :wink:

There was/is a suggestion to use EXPR is PAT as a binary operator. One the one hand it more clearly suggests that this is a test (so that we expect that the type is bool), on the other hand it is less clear about what sort of test it is ("is" is vague) and it does not suggest that binding can happen. I believe this was considered when if let was first introduced but not accepted back then.

I would have been fine with turning EXPR is PAT into a full expression if Rust already accepted if foo is Some(_) { ... } and not if let Some(_) = foo { ... }. However, given the "facts on the grounds", I think it behooves us to be "fiscally responsible" with complexity.

2 Likes

I don't think this is quite the motivating example you intended, since even with a semicolon bar is unused. If this proposal is adopted I do expect to see some code like this to appear:

fn foo() -> bool {
    ...
    ...
    let _: Baz(_) = bar
}

Which highlights that the proposal could add some proposed warnings/lints. For example it seems like using let _ = ... in an expression context should have a warning (directing the user to let _: _ = ...? I can't think of a good reason to use that either outside of generated code).

let _: <pat> = ... in a boolean context also occupies the same problem space as the matches! macro, so I would list that as an (incomplete) alternative.

I’m strongly against this. Please don’t introduce new, subtly different syntax for an already-existing language feature. We just don’t need dialects in Rust. The proposed syntax has also no strong (or IMO any, at all) benefits compared to what match has to offer – the motivation is weak and its claims bold.

1 Like

I agree. How far away are we from making this happen? From what I've seen there have been a bunch of RFCs that move in this direction, but why don't we just go for this outright?

1 Like

Implementation-wise, if you remove these lines from the compiler then let PAT = EXPR will become a real semantic expression in all contexts:

https://github.com/rust-lang/rust/blob/master/src/librustc/hir/lowering.rs#L4348-L4353

However, bindings won't work yet so you can do if let A = x && B = y { ... } but not if let A(y) = x && B = y { ... }. Moreover, I haven't considered drop-order in the context of let P = E in all contexts because this is just for error recovery right now.

There's still work to be done in terms of supporting if a && let b = c && ... { but I'm making steady progress. (Working on removing hir::ExprKind::While at the moment, [let_chains, 3/6] And then there was only Loop by Centril · Pull Request #61988 · rust-lang/rust · GitHub).

Well there's just one accepted experimental RFC:

I think it's right that we move in an incremental fashion here because the RFC solves perhaps the most pressing problem and doing it this way allows us to stage the implementation in a good way also. There's also bound to be many questions re. how flow sensitive the analysis should be around bindings and definitive initialization.

After && I think it would be natural to extend the syntax to support || as well.

4 Likes