Let expression: Are we ready for another RFC?

I think we should open an RFC for let expression which replaces matches! macro. From macro example in docs:

let foo = 'f';
assert!(matches!(foo, 'A'..='Z' | 'a'..='z'));
assert!(let 'A'..='Z' | 'a'..='z' = foo);

let bar = Some(4);
assert!(matches!(bar, Some(x) if x > 2));
assert!((let Some(x) = bar) && x > 2);

If you find matches! more natural and that syntax strange, look at above examples inside if which is part of current language:

if let 'A'..='Z' | 'a'..='z' = foo {
  // it will works currently
}
if (let Some(x) = bar) && x > 2 {
  // this is special case of if chain proposal
}

I believe this is already implemented in the way of implementing if chain (At least simple form without &&). Just we need to see how it will play in wild which a RFC can show it.

Binding rules

It can be similar to && chain proposal. If a binder expression (combination of let expressions with && , || , ! and ()) consumed in a function call or in another operator or in return site of a block, all of its variables will be discarded (and unused variable lints can prevent confusion). If it consumed in a if or while statement, variables can be accessed inside of body of if or while. And if is consumed as an statement, it should be always evaluate to true (compile error otherwise) and then variables are available in next lines. It means (let x = 2) && (let y = 5); would be accepted for language consistency (can be linted away because it is useless, but it equivalent || one can be very useful: (let Some(x) = foo) || let x = default;. But this is for future)

To see how it can be extended to || and ! see this full algebra but this will be reversed for future.

Difference between () and {} in this context should be noted. {} will discard variables but () won't and will play well with logic operators. So those will be different:

assert!((let Some(x) = bar) && x > 2); // ok
assert!({ let Some(x) = bar } && x > 2); // compile error, x is local in the block

Is it the right next step?

Some one can say implementing || chains and !let is better at first but I don't think so. This case (alternative to matches!) has way more usage than || chains, and if we land it first it will create natural demand for those improvements. I think it can even landed before the approved && if chains.

So are we ready for creating this RFC?
cc @Centril

2 Likes

What problem(s) does this solve?

Because the (somewhat steep) price we pay for this is a conflation of creating bindings and asserting whether or not a value matches a pattern.

11 Likes

I'm not seeing enough of a practical difference between these

assert!(matches!(bar, Some(x) if x > 2));
assert!((let Some(x) = bar) && x > 2);

to warrant the change. The Some(x) if x > 2 notation is already familiar from general match expressions, and I suspect the consequences of introducing new bindings inside subexpressions will be messy.

The related thing I feel is actually missing from the language is irrefutable destructuring of enums based on control flow, such as

if let Err(e) = result {
    return something;
}
let Ok(val) = result; 
// use val

and ideally also

if val.is_none() {
    return default;
}
let Some(val) = val;
// use val

Is this what you meant when you mentioned "|| chains and !let"?

2 Likes
  1. Replace matches!: matches! is pretty good, but not perfect. For example unusable bindings are confusing for someone who doesn't know how matches! is implemented. But in let expression this is clear. Also matches! can not cover all of cases. For example how would you do this:
assert!((let Some(x) = a) && (let Some(y) = b) && x == y);

With matches? This come to my mind which is poor and shows inconsistency of language (also doesn't implemented (approved even?) yet):

assert!(matches!(a, Some(x) if let Some(y) = b && x == y));
  1. Consistency: how would you teach if let to new learners? As an special syntax different with if? So how would you explain && chains in if let? This will be reduce complexity here. let PAT = EXPR is a bool expression which returns true if pattern matches. And binding rules are equals to todays if let binding rules (future || chains and !let will increase complexity for binding, but they will pay for themselves and are not this topic)
  2. Open door for future improvements.

I can't see how it is a problem? We already do this for simple let Some(x) = y; and create compile error.

2 Likes

Not tested, but how about

assert!(matches!((a, b), (Some(x), Some(y)) if x == y));

Your points about unusable bindings and consistency are more compelling, I think.

3 Likes
  1. That behavior is also present in the expansion of the macro, in fact we could say it originates there. So that isn't just matches! where this behavior occurs. But indeed, that behavior can be a footgun, though luckily one that's quickly detected.

How about this?

assert!(matches!((a, b), (Some(x), Some(y)) if x == y ));

(EDIT: ninja'd by @zackw on this point)

Same as matches!: by showing how both compile to (and are merely syntactic sugar for) a match expression. The reasons for doing it that way are that that is how the compiler implements it, and that point one is an easy heuristic to remember later when you want to figure out how a piece of code that uses it works.

Again, as syntactic sugar for the match expression it compiles to.

As I indicated above, it conflates things that have nothing to do with one another. That's not reducing complexity, that's adding to it in the form of special cases that didn't used to exist.

I'm not sure what you're referring to here. The only way that line of code can be used is if the compiler can prove that y is always a Some(_) value, which isn't often the case.

1 Like

This style is reviewed in if-let-chain rfc and they mentioned two problems, it isn't short circuited and it can't depend on previous chains: (example borrowed from that rfc)

assert!((let Ok(user) = read_user(::std::io::stdin()))
    && user.name == "Alan Turing"
    && (let Ok(hobby) = read_hobby_of(&user))
    && hobby == "Hacking Enigma");

So if let and if and if let && let are three different animals in your point of view? They will be the same after let expression:

  • let is an bool expression which return true if it matches the pattern.
  • if is something that get a bool and if it is true run the first block and otherwise run the else block (and similar declaration for while)
  • if compiler can figure out a let expression is statically true in some context (by breaking && (and in future || and !) and by knowing the definition of if) it will let you access variable binded by that let expression.

Which is more clear? This view or syntax sugar of match for each case? New learners already expect anything inside if to be a bool expression. For example here a new learner wrotes if (let Some(x) = y) and this thread OP mentions this problem. Also n that thread there is a good discussion around let expression which @Centril explains it better than me.

1 Like

Meanwhile in C++: "Pattern matching using is and as".

4 Likes

I'd definitely like to see something like x ☃ Some(y) && use(y).

Last discussion I recall about this was mostly about how to phrase the exact scopes of the variables involved, because with shadowing it would be subtle. (C# avoids it by having default nulls and no shadowing so things are just in-scope in both sides of a branch.)

I really like this idea, because it's a simpler, more coherent and more general alternative to if let chains.

I have one concern tho: It's not very intuitive that let evaluates to a bool. A different syntax such as EXPR is PATTERN would be more intuitive, but it's not backwards compatible with if let. A new edition could automatically migrate if let to is, but this is not ideal: Programmers new to Rust would be confused what if let means when reading older code. This has also been an issue with the module system changes in the 2018 edition.

3 Likes

I don't like EXPR is PAT syntax. It is another binding syntax and it doesn't play well with language in past.

Somehow it is. For example why let Some(x) = y doesn't compile? Because it doesn't cover all cases, or because it doesn't always evaluates to true so (let Some(x) = y) || (let x = default); should compile with this logic. With is, we miss this (or we should accept 2 is x; as alternative to let x = 2).

Can you explain problem more? Or a link to that discussion?

1 Like

To you, perhaps. But clearly not for everyone.

In fact I'd call that syntax downright confusing because of its binding effects. Similar in line with assigning in the predicate of an if-expression in C (which is possible there and I'd immediately call it an antipattern).

There are no user-observable bools used in pattern matching i.e. there is no "evaluating to true", so that's an artifact of how you happen to understand it.

3 Likes

You can observe it today. Put it inside if <put let here> { true } else { false }. It is how we observe bool expressions, right? You can easily convert it to a macro and print what let expressions evaluate to.

I don't see how this is related to assignment in C. AFAIK in C we can't bind new variables in expressions. We can assign to old variables and this is also possible in today rust and it is even common pattern in some cases, like this:

let n = 12;
let count = 1;
while { n >>= 1; n != 0 } {
  count += 1;
}

In fact, this is from C's popular while (n >>= 1) {}. I don't see how it is related to let expressions.

Three things I'd like to add:

1. Using the result of an irrefutable let binding should be either a warning or an error:

let x = let y = 42; // x is always true!
foo(let y = 42); // the function argument is always true!

This is bad not only because it's useless, but also because it does something different than what a C or Java dev would expect.

2. Not using the result of a refutable let binding should be either a warning or an error:

let Some(x) = foo;
// x can't be used because the binding is refutable

This is also useless and might confuse people.

3. To make this work, Rust needs more complex control flow analysis to identify where a binding is in scope. Rust already does control flow analysis to check where a variable is initialized, so I guess that's not a show stopper. However, that could be unintuitive and error-prone in combination with shadowing:

let x = 5;
if let Some(x) = foo && x.is_bar() || baz {
    // x can be used here, but which x is it?
}

I guess a warning or error could be shown in this case.

1 Like

I was talking about if-let, which is a wholly separate piece of functionality, and has nothing to do with if-else other than sharing some syntax, and desugaring to a match. So my point stands.

It's related because of 2 reasons:

  1. You want to use it similarly to that construct and
  2. Both that construct and your proposal introduce bindings i.e. side-effects in what should essentially be a mere predicate. This reiterates what I said earlier about conflating completely separate functionalities. I'm not particularly keen on seeing such conflation happen, they make any language that introduces them worse. Not convinced? This Python proposal that was recently accepted may have been the causal reason for Guido van Rossum giving up his position as python's BDFL, and it's essentially about replicating the assignment-in-an-if/else-predicate from C in Python. It kicked up a gigantic storm of criticism in the process: huge parts of the community considered it a bad idea. So this is some real-world data about both the benefits and drawbacks of introducing something like this.

Why? If matches works today and it can be implemented in the library, there's no value in adding it as a separate language feature.

3 Likes

What a C or Java dev expect from let expression? int x = (int y = 6); isn't a thing in C or java I think. But I'm agree with warn against always true let expressions when they are useless.

This is error. let expressions if comes in top level must provably evaluate to true always.

That is error, too. if we interpret || as described in this algebra binded variables in baz should be equal to let Some(x) = foo && x.is_bar() (if there is x here there should be a x there) and until that day, using || and ! for let expressions should be error for forward compatibility. Error can be fixed with:

let x = 5;
if { let Some(x) = foo && x.is_bar() } || baz {
    // x is 5, that inner x is local to the block
}

Or:

let x = 5;
if let Some(x) = foo && x.is_bar() || let Hello(x) = bar && baz {
    // x is either inside of foo or inside of y depend on runtime conditions
    // experimental errors in early versions
}

But I understand your concern isn't specific to ||, so I would change your example to:

let x = 5;
if some_bool_consumer(let Some(x) = foo && x.is_bar()) {
    // x is 5
}

Isn't clear that x is the outer one? We can lint against consumed let expressions inside if and while predicts so it will warn against above and third above code here.

I think the point is that if let looks a lot like an if expression, so it would be natural to assume that let is a bool expression. Therefore, making let an actual bool expression would make if let and while let less surprising and easier to understand for people learning Rust.

It would also simplify the grammar, because if let and while let would no longer be special; they would just be regular if/while expressions that just happen to contain a let binding.

4 Likes

On the one hand that's true.

On the other it could easily be used as an argument that it's a false similarity and thus shouldn't even have used that syntactic shape. If/else doesn't do pattern matching at all, either. It just determines the value of a predicate and acts on it. So that mental model / similarity happens to not reflect reality.

Of course that won't change anything since if-let is stable. But this feature isn't yet, and my opinion is that we don't need to dig the hole of misconceptualization deeper than it already is. I even think it's a good idea to update the Book to caution against this very issue.

This is one of the better arguments in favor of it so far. However, having written a few grammars myself, I don't believe the long-term maintenance burden would be affected significantly unless there is some non-trivial processing between parsing and desugaring.

1 Like

No. x = y in C family and x := y in python are equal to { x = y; x } in rust and there is no similarity between { x = y; x } and let <pattern> = y;. Type of { x = y; x } is equal to type of x but type of let <pattern> = y; is bool and their usecases are completely different.

1 Like