7

RFC: let-expression by HKalbasi · Pull Request #3159 · rust-lang/rfcs · GitHub

 3 years ago
source link: https://github.com/rust-lang/rfcs/pull/3159
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

New issue

RFC: let-expression #3159

Conversation

Copy link

HKalbasi commented 8 days ago

edited

Rendered

Please add a comment in addition to downvote (or upvote existing comments), because comments are more helpful. But feel free to downvote as well ;)

Copy link

lebensterben commented 8 days ago

edited

I'm against the now-merged let-else RFC. But this one is even worse in terms of readability.

For example:

// let else else future possiblity
let Some(x) = a else b else c else { return; };

// with let expression
(let Some(x) = a) || (let Some(x) = b) || (let Foo(x) = bar) || { return; };

In a general case, if it's not Option::Some, but Foo instead:

The let-else syntax requires user to figure out Foo in let Foo(x) = a else .. is a Sum type and thus the binding is fallible. (Otherwise else doesn't make any sense.)

But with the new syntax, (let Foo(x) = a) || (let Bar(x) = b) || (let Baz(x) = c) || .., you're gonna process each and every identity on the left hand side of = and they must all be Sum types, with exception of the tail of the chain.

For example, this allows the following readability nightmare:

pub mod XXX {
    pub enum YYY { Foo(i32), Bar(u32) }
	pub struct Baz (i8);
}

use XXX::YYY::*;
use XXX::Baz;

fn nightmare () {
    let (a, b, c) = (Bar(0), Foo(0), Baz(0));

    // Here we go
    (let Foo(x) = a) || (let Bar(x) = b) || (let Baz(x) = c) || { return; };
}

The reader gotta process the entire expression and internalize that the first two bindings may fail, but then, wait, Baz is a Product type and the binding never fail. So there's no need for the { return; } at the end.

This is nowhere being any better for new learners. It's confusing and error-prone.

Copy link

Author

HKalbasi commented 8 days ago

@lebensterben I don't see your concern. The user can write this today:

if let Baz(x) = c { } else { }

and just get a warning for unreachable code, which is good in my opinion. Irrefutable patterns in let expressions are like x==x and refutable ones are like x==2 and you can use both of them in || and if and similar. I don't see any catastrophic thing in your example and it isn't semantically wrong or meaningless. It just has unreachable code in || { return; } which probably will be removed by a compiler warning, before being read.

## Consistency

Currently we have `if`, `if let` and `if let && let` and we teach them as three different

constructs (plus `let else` in future). But if we make `let` a `bool` expression, all will become the same and it would be

fee1-dead 6 days ago

Member

There is something about this RFC that made me unsure about whether this change will be good, however I am pretty sure that it doesn't really matter if let pat = expr becomes a bool expression in this RFC. It can be pretty confusing with the PBs and NBs seemingly attached to a single bool value. Now if we replace all the checking that is needed for bool returned by let expressions with simply syntactic sugar, we get let else as well as if let chain. Is this RFC just about making let a bool expression, as well as a wrapper on top of the concepts from if-let-chains and let-else?

HKalbasi 6 days ago

Author

The reasoning here is, new learners when see if let, expect that let should behave like a bool expression (like anything else inside if scrutinee) and this confusion will grow if we chain them with && in if-let-chain and || in let-else. By making them an actual bool expression, there will be more things to learn (about PB and NB), but there would be no surprise for how let statement jumped into if and && because it is a bool expression.

fee1-dead 6 days ago

Member

Do new learners actually expect let to be a bool expression? The issue you linked in the RFC did not have any mention of bools. And new learners will get confused by the concepts of PBs and NBs too. You clearly have a bias towards if-let-chain and let-else because you describe their syntax as confusing, but as "more things to learn" when you talk about your RFC.

Kixiron 6 days ago

Member

Yah, the linked issue was renamed and labeled as a bug in error reporting, the error wasn't helpful to the end user and had nothing really to do with booleans at all

HKalbasi 6 days ago

Author

Putting () around let in if let has it's roots in expecting if is a isolated structure and let is an expression inside it (which should be bool because it is inside if scrutinee). New learner can soon discover that if let is another construct and is attached together, but situation is worse with introducing && and if-let-chain. I'm not inventing this and this thread in internals has a discussion about confusing let with an expression. Now there is two approach:

  1. Try to prevent this confusion, detect errors that mentions let expression as compiler bug (like issue I linked), and restrict new RFCs that use let in this way (like if-let-chain) and memorize special cases as different constructs. (current approach)
  2. Make let an actual bool expression. This needs generalizing binding rules to cover if-let and if-let-chain as a subset, and it will probably becomes a complex and confusing rule, but there would be no corner case and if will become a general thing capable of handling if-let and if-let-chain and more things. (the approach of this RFC and @Centril in that internal thread)

Second approach has costs. But cost of first approach (confusing let with an expression) is also real and I'm not the only person saying it. And boolean doesn't need to be mentioned explicitly and mentioning let expression is enough, because bool is the only type that makes sense in context of if-let and if-let-chains.

// with let expression

(let Some(x) = a) || (let Some(x) = b) || (let Foo(x) = bar) || { return; };

```

fee1-dead 6 days ago

Member

Please address this concern: aggressively mixing &&s and ||s is accepted by the compiler, but it can just be hard to read:

(((let Some(x) = a) && (let Some(y) = x.transform())) || { panic!("failed to get y") }) && ((let Some(a) = y.transform1()) || ((let result = y.transform2()) && ((let Ok(a) = result) || { return result; })) || ((let either = y.transform3()) && ((let Either::Left(left) = either) && (let a = transform_left(left))) || ((let Either::Right(right) = either) && (let a = transform_right(right)))));

beautified:

(
    (
        (let Some(x) = a) &&
        (let Some(y) = x.transform())) 
        || { panic!("failed to get y") }
    ) && (
        (let Some(a) = y.transform1()) || (
            (let result = y.transform2()) && (
                (let Ok(a) = result) || { return result; }
            )
        ) || (
            (let either = y.transform3()) && (
                (let Either::Left(left) = either) && (let a = transform_left(left))
            ) || (
                (let Either::Right(right) = either) && (let a = transform_right(right))
            )
        )
    );

(and it looks like I have mismatched parenthesis)

fee1-dead 6 days ago

Member

That counterargument is pretty weak to me. The second example with "complexity in patterns" is definitely less complex. At least I know the names of variables that are bound immediately by just looking at the first line of let ..., and I know exactly where each pattern matches on each expression.

HKalbasi 6 days ago

Author

Yes it is less complex, but it is also doing less work. And my point is this complexity can be scaled to infinity and you can write a 10 lines, confusing pattern.

At least I know the names of variables that are bound immediately by just looking at the first line of let ...

It is valid, but discovering which variable is bound isn't hard at all. In fact, all bindings in top-level of expression will be bound. In my example, they are x and y and a. In your example, also result and either are in top level, but your example doesn't compile.

I know exactly where each pattern matches on each expression.

Things won't be that easy if I add another | in pattern:

let (
  ((Foo(x), Some(y), (Some(a), _, _) | (_, Ok(a), _) | (_, _, Some(a)), _)
| (_, (Bar(y) | Foo(y), Bar(a), (Some(x), _, _) | (_, Err(x), _) | (_, _, Some(x)))

And in let expression we see which expression is in front of which pattern and this help readability. (Maybe I didn't understand your point, if it is the case an example can help)

My argument is "patterns can similarly and equally make infinitely complex examples", maybe I should change RFC text with better examples.

text/0000-let-expression.md

Outdated

Show resolved

Copy link

runiq commented 6 days ago

Isn't the || operator lazily evaluated? Can the rest of my code rely on a let binding behind a || operator?

Copy link

Author

HKalbasi commented 5 days ago

It is short circuited but since both side of || must have equal bindings (both in name and type), we can be sure that at least one of them is bound and it doesn't matter which one is. So rest of code can rely on them.

Copy link

ids1024 commented 5 days ago

edited

Regarding "let as a bool expression", it's worth noting that this isn't necessarily the only or most obvious thing a let expression might evaluate to.

Expressions like x = 5 (without let) evaluate to ().

In Javascript x = 5 evaluates to 5, and similarly with x := 5 in Python. Though this behavior wouldn't be possible in Rust for non-Copy types.

Edit: Somehow I forgot Javascript also has a let keyword. Doesn't seem let is an expression there. In Python however there's not a specific keyword for defining new variables.

Copy link

Author

HKalbasi commented 5 days ago

@ids1024 your point is valid. I added it to drawbacks.

Copy link

joew60 commented 5 days ago

If I submitted something like

(let Some(x) = a) || (let Some(x) = b) || (let Foo(x) = bar) || { return; };

to a code review; it would be rejected as unreadable. Phrases like "keep it simple" would be used. Even if I understand it today, am I going to understand it in six months?

The fact we can introduce this feature is not a justification for including it.

Copy link

Author

HKalbasi commented 5 days ago

@joew60 What is unclear in your example that you may not understand it in six months?

The fact we can introduce this feature is not a justification for including it.

Certainly. The justification for this RFC is that it unites features like if-let, if-let-chain and let-else, which are known to be useful, in a general and consistent manner, and then get some extra features for free (with no adding to grammar and concepts of the language). At the end there will be some complex usages (which I don't believe your example is among them) that code reviewers can reject.

If I submitted something like

(let Some(x) = a) || (let Some(x) = b) || (let Foo(x) = bar) || { return; };

to a code review; it would be rejected as unreadable. Phrases like "keep it simple" would be used. Even if I understand it today, am I going to understand it in six months?

That's how I feel about most of the examples in the RFC. I think this RFC is too focused on what is theoretically possible instead of what is useful, readable, practical, and realistic. Cramming more logic into a single statement is not an improvement. Code that looks like minified JavaScript is bad code.

I don't feel great about this RFC, but I might consider it more seriously if it had a more practical lens.

Which by a generalized let-else can become:

```rust

for w in block.stmts.windows(2) {

(let StmtKind::Semi(first) = w[0].kind)

&& (let StmtKind::Semi(second) = w[1].kind)

&& !differing_macro_contexts(first.span, second.span)

&& (let ExprKind::Assign(lhs0, rhs0, _) = first.kind)

&& (let ExprKind::Assign(lhs1, rhs1, _) = second.kind)

&& eq_expr_value(cx, lhs0, rhs1)

&& eq_expr_value(cx, lhs1, rhs0)

|| continue;

// 30 lines of code with two less tab

Comment on lines

+128 to +139

kennytm 4 days ago

edited

Member

this is more like a practical example of the existing if_let_chains feature (if you must include a continue for the number of indentations)

    if let StmtKind::Semi(first) = w[0].kind 
        && let StmtKind::Semi(second) = w[1].kind
        && !differing_macro_contexts(first.span, second.span)
        ...
        && eq_expr_value(cx, lhs1, rhs0)
    {
    } else {
        continue;
    }

this is also more consistent with the way people would write

if next_condition {
    continue;
}

rather than

!next_condition || continue;

HKalbasi 4 days ago

Author

if-let-chain still need one indentation, because variables are bound inside of if body (like if-let) but in the example with || variables are bound after statement (like let-else). If you believe if-let can't meet the needs that let-else provide, The same statement is true for if-let-chain and this RFC.

this is also more consistent with the way people would write

I am agree and prefer explicit if over || in normal cases. Without bindings, both behavior is the same (both in current rust and in this RFC) but because binding rules for them are different (for good reasons; for example accessing if-let variables after body is surprising) you can save one indentation by || one.

And it is worth noting that !next_condition || continue; is completely valid and working in today rust. But !next_condition else { continue }; isn't and won't, so let-else syntax has a negative point here and this RFC is more consistent with rust.

Currently we have `if`, `if let` and `if let && let` and we teach them as three different

constructs (plus `let else` in future). But if we make `let` a `bool` expression, all will become the same and it would be

easier for new learners to get it. After this RFC you get only an unused parenthesis warning

for `if (let Some(x) = y) { }`, not a hard error. And it will have the same behavior with

inquisitivecrystal 4 days ago

Contributor

I think that it's worth adding a helpful error message for this even if the RFC is not accepted. We could even consider making the compiler recover, but with a warning, though I'm not sure that's the way to go. I haven't checked what error message is given currently though.

HKalbasi 4 days ago

Author

I think recovering with a warning is good, especially in if-let-chains because people may want to sure that && isn't interpreted wrong so add a (unnecessary) parenthesis. But it will make demand for let expression even more, because users will say: What is this let that I can put it in if with parenthesis and && it inside if, but nothing else?

Copy link

Author

HKalbasi commented 3 days ago

@camsteffen I added some practical examples in this section and I probably add some more when I find.

Cramming more logic into a single statement is not an improvement. Code that looks like minified JavaScript is bad code.

This is minified JS: (picked from this page assets)

!function(){"use strict";function e(e){const t=[];for(const o of function(){try{return document.cookie.split(";")}catch(e){return[]}}()){const[n,r]=o.trim().split("=");e=

Is that example really looks like minified JS? What are their similarities? Just because it is in one line, it looks like minified JS? You can put it in multiple lines:

(let Some(x) = a)
|| (let Some(x) = b)
|| (let Foo(x) = bar)
|| { return; };

And the point of this is not count of lines. You can put current alternative in one line as well:

let x = if let Some(x) = a { x } else if let Some(x) = b { x } else if let Foo(x) = bar { x } else { return; };

Or in multiple lines:

let x = if let Some(x) = a {
    x
} else if let Some(x) = b {
    x
} else if let Foo(x) = bar {
    x
} else {
    return;
};

Either way, current solution has unnecessary noise, two times more x, and let expression solution has more cognitive load because it is a new syntax, but it is clear that what it wants to do.

Maybe you can explain your feelings and what you mean from minified JS by some examples.

Maybe you can explain your feelings and what you mean from minified JS by some examples.

JS is a very flexible language and allows you to write very dense code. Perhaps this is a better fit for the goals of JS. But I don't want to dwell on that comparison. The point is that the code is hard to read. That's just my own subjective feeling from looking at the examples.

Copy link

Author

HKalbasi commented 3 days ago

@camsteffen minified JS is a machine generated and unreadable by design code, Which doesn't related to that examples I think. JS is flexible but you can minify rust as well. Anyway, it is not our discussion, lets move on.

The point is that the code is hard to read. That's just my own subjective feeling from looking at the examples.

Can you please explain your feeling in more details? These questions may help:

  • Is || inside if scrutinee hard to read? And if so, what is your feeling about existing if-let-chain proposal?
  • Are ||, && in top level of a block hard to read? If so, does reading them as orelse, andalso like in standard ML make it easier? Problem is chaining them or single || is bad as well? And do you find else in let-else syntax easier to read than || in equivalent statement?
  • Do () around let makes them hard to read? And changing precedence of operators help?
  • Are consumed let expressions in places like assert! hard to read and if <expr> { true } else { false } is better?
  • Are your feeling for real code examples, equal to you feeling about other ones? Real code examples have more context in them and their variables are not single letter, so maybe they are easier to read.

In each case, if it is applicable, please provide which is wrong, for example bindings are unclear, side effects are unclear, ... It isn't necessary and you can simply don't like anything you want.

Copy link

IceSentry commented 3 days ago

edited

Using a top level || followed by a block is not necessarily hard to read, but it's definitely surprising because I've personally never seen that in any other language.

To me this:

(let Some(x) = a)
|| (let Some(x) = b)
|| (let Foo(x) = bar)
|| { return; };

is way harder/surprising to read than

if let Some(x) = a || let Some(x) = b || let Foo(x) = bar {
	return; 
}

Using a top level || followed by a block is not necessarily hard to read, but it's definitely surprising because I've personally never seen that in any other language.

|| followed by some command or block occurs quite often in bash scripts (and Perl too iirc).

How about adding and_then and or_else method to bool? Then we can write:

(let Some(x) = a)
  .or_else(let Some(x) = b)
  .or_else(let Foo(x) = bar)
  .or_else({ return; });

Which in my opinion is much more clear to read and ergonomic to write with autocomplete.

The only problem I can see is that this may potentially muck with operator precedence and some compiler optimizations.

@changhe3 What's the scope for x then?

@iago-lito The calling scope of course. I don't think function parameters are in their own scope, unless you explicitly use {}.

Copy link

Author

HKalbasi commented yesterday

edited

@changhe3 In this proposal, they are. And for good reason, If you allow function parameters to bind variables in parent block, then:

assert!(let Some(x) = foo);
println!("{}", x);

What would be the result of this program?

Do () around let makes them hard to read?

That is maybe the main problem for me. Each set of parenthesis causes me to pause and say "watch out, the operator precedence is being overriden here..." Lots of parenthesis is also a signal to me that factoring in a variable may be helpful for readability. But that isn't an option here. Rust currently enables you to be very conservative with parenthesis and I like that.

And changing precedence of operators help?

I don't think that is really an option? I have an expecation for the operator precedence of pat = x || y.

Another issue is that let is allowed anywhere in an expression. I like that in current Rust, bindings are generally introduced in predictable places - at the beginning of a statement, at the start of a match arm, or in function args. When reading code, the binding names "tell the story". So the readability of a function is highly affected by how quickly I can identify all the binding names defined. With this change, binding declarations are much less prominent.

Copy link

Author

HKalbasi commented 12 hours ago

@camsteffen

I don't think that is really an option? I have an expectation for the operator precedence of pat = x || y.

Your expectation isn't met always, and we changed it once in if-let-chain RFC.

I have a very vague and hacky idea to remove need to parenthesis without breaking change: let pat = x || y only is valid if pat and x and y types are bool. If it is possible to type check x and if it wasn't bool, rotate the parse tree to (let pat = x) || y, the need to parenthesis would be solved. If it isn't possible to change parse tree after type check, we can check if pat is a bool pattern without type checking, it should have format of ident, true, ident @ false, and | combination of this things, so it should be easy to detect it by parse level information. If we find that pat surely is not a bool pattern, we can rotate the parse tree. Negative point of the latter is that false positives like ident is possible and it still mandate parenthesis for let ident = x && y which x can be not bool, but it is better than mandating parenthesis everywhere. (Note that (let ident = x) || y is non sense and y is unreachable here.) With both solutions, there is no breaking change.

I don't know if this level of context sensitivity is feasible, and if something like this is done before, but if it is ok, (or ok with changes) let me know to put this in RFC. If it isn't, I should add parenthesis to drawbacks.

at the beginning of a statement, at the start of a match arm, or in function args

And macros. If the problem here is possibility of creating a mess, macros can do that very better, but if concern is that people accidentally make an unclear binding statement that make problem for code readers, I claim that 98% of meaningful binding statements have let at the beginning (or ((let) and it can be forced by lint, so you can keep looking at beginning of the statements for discovering bindings.

@fee1-dead

Do new learners actually expect let to be a bool expression?

I personally would expect that.

@camsteffen

I don't feel great about this RFC, but I might consider it more seriously if it had a more practical lens.

I don't understand the intricacies here, but as a +1: I would definitely find it nice to be able to write:

if let Some(x) = self.some_func() && let Some(y) = self.other_func() {}

instead of having now to do

if let Some(x) = self.some_func() {
    if let Some(y) = self.other_func() {}
}

It would reduce the number of braces, which is objectively better.

I realize this is possible sometimes, but it's not as readable

if let (Some(x), Some(y)) = (self.some_func(), self.other_func()) {}

It's not possible when the output of some_func needs to be piped into other_func. The workaround being:

if let Some((x, Some(y)) = self.some_func().map(|x| (x, self.other_func(x))) {} 

Which is not as readable as well.

Not a fan of:

... || {
    return Err("failed");
};

I feel like that should be a default clippy warning. It's bad style, but imo shouldn't block this feature.

Copy link

Member

fee1-dead commented 9 hours ago

as a +1: I would definitely find it nice to be able to write

That is not something coming from this RFC, see if-let-chains which is already an accepted RFC.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Assignees

No one assigned

Labels
None yet
Projects

None yet

Milestone

No milestone

Linked issues

Successfully merging this pull request may close these issues.

None yet

15 participants

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK