Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

RFC: let-expression #3159

Closed
wants to merge 19 commits into from
Closed

RFC: let-expression #3159

wants to merge 19 commits into from

Conversation

HKalbasi
Copy link
Member

@HKalbasi HKalbasi commented Aug 8, 2021

@lebensterben
Copy link

lebensterben commented Aug 8, 2021

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.

@HKalbasi
Copy link
Member Author

HKalbasi commented Aug 8, 2021

@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
Copy link
Member

Choose a reason for hiding this comment

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

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?

Copy link
Member Author

Choose a reason for hiding this comment

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

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.

Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Member

Choose a reason for hiding this comment

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

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

Copy link
Member Author

Choose a reason for hiding this comment

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

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.

text/0000-let-expression.md Show resolved Hide resolved

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

Choose a reason for hiding this comment

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

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)

Copy link
Member Author

Choose a reason for hiding this comment

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

Compiler won't accept your code as-is, by rules of bindings. But I added your concern with a weaker example here: https://github.com/HKalbasi/rfcs/blob/master/text/0000-let-expression.md#hard-to-read-let-expressions

Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

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 Hide resolved
@runiq
Copy link

runiq commented Aug 11, 2021

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

@HKalbasi
Copy link
Member Author

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.

@ids1024
Copy link

ids1024 commented Aug 11, 2021

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.

@HKalbasi
Copy link
Member Author

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

@joew60
Copy link

joew60 commented Aug 11, 2021

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.

@HKalbasi
Copy link
Member Author

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

@camsteffen
Copy link

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.

Comment on lines 128 to 139
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
Copy link
Member

@kennytm kennytm Aug 12, 2021

Choose a reason for hiding this comment

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

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;

Copy link
Member Author

Choose a reason for hiding this comment

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

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
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Member Author

Choose a reason for hiding this comment

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

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?

@HKalbasi
Copy link
Member Author

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

@camsteffen
Copy link

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.

@HKalbasi
Copy link
Member Author

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

@IceSentry
Copy link

IceSentry commented Aug 13, 2021

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; 
}

@samlh
Copy link

samlh commented Aug 23, 2021

@kennytm Hm, but the logic to fail compilation if the expression fails to diverge does seem to answer your question:

Proving divergence requires type-checking, so now whether a variable is in scope depends on the type-checking result of the previous statements. But type-inference requires information from the entire function scope. So, I don't know, how are you going to convincingly teach the compiler which z are being shadowed or unused, and what should be the type of m?

Assuming divergence and double-checking later does solve that issue. The answer for the example you gave is thus that (some) z is always in scope, and m is always a Vec<number>.

I'm still in agreement that it is hard to explain, and that this is a bad idea, but your example does have a specified result :).

@joshtriplett
Copy link
Member

joshtriplett commented Aug 23, 2021 via email

@HKalbasi
Copy link
Member Author

if mystery_function_* is diverging, your example is equivalent to this:

(let Some(z) = z) else { mystery_function_1(&m); };
(let Some(z) = z) else { mystery_function_2(&m); };
(let Some(z) = z) else { mystery_function_3(&m); };
(let Some(z) = z) else { mystery_function_4(&m); };
(let Some(z) = z) else { mystery_function_5(&m); };
m.push(z);

How you see problem (and what is the problem you see) with this RFC but not with let-else?

Also I thought not containing z is the entire point of the RHS of ||?

It is not exception of RHS, but exception of diverging values. You can imagine diverging branch add a hidden z which will be in the scope for the result of function (which we won't see after diverge)

@kennytm
Copy link
Member

kennytm commented Aug 23, 2021

@HKalbasi What if mystery_function_2() returns a bool and not diverge? What if the divergence of mystery_function_3() depends on the type of m?

trait Q { type A; fn a(&self) -> Self::A; }
impl Q for Vec<i32> { type A = !; }
impl Q for Vec<Option<i32>> { type A = bool; }
impl Q for Vec<Option<Option<i32>>> { type A = !; }
...
fn mystery_function_3<T: Q>(q: &T) -> T::A {
    q.a()
}

How you see problem (and what is the problem you see) with this RFC but not with let-else?

In let-else we know for sure, before type-checking, that all z are shadowing, because the else clause is guaranteed to diverge.

There is no such guarantee in this RFC, (let Some(z) = z) || BOOL_VALUE; is a valid expression statement (which does nothing).

@samlh
Copy link

samlh commented Aug 23, 2021

Edit: Deleted as I realized I was getting side-tracked. Dropping out.

@steffahn
Copy link
Member

This example seems wrong, unless I’m misunderstanding something

A more complex example in this category from sentry-cli:

if let Ok(var) = env::var("SENTRY_DISABLE_UPDATE_CHECK") {
    &var == "1" || &var == "true"
} else if let Some(val) = self.ini.get_from(Some("update"), "disable_check") {
    val == "true"
} else {
    false
}

Which can become:

{ let Ok(var) = env::var("SENTRY_DISABLE_UPDATE_CHECK") && (&var == "1" || &var == "true") }
|| { let Some(val) = self.ini.get_from(Some("update"), "disable_check") && val == "true" }

Wouldn’t the “translation” be behaving differently if &var == "1" || &var == "true" evaluates to false?

@HKalbasi
Copy link
Member Author

@joshtriplett Thanks for your detailed comment. I can answer to some parts but I want to focus on my main point.

It does matter, though. The syntax we use becomes part of people's
models and intuitions, and else there feels like it forms part of a
consistent languge model. || doesn't feel like as good of a fit.
(There are things that would fit even worse, such as => or !, but
I feel that else fits better than || in this context.)

All of my point is: if that amount of better-ness is more important than consistency between let-else like construct
and if-let-chain like construct (and even consistency between if and if-let and if-let-chain by making let a bool expression) then everything seems ok. We will get a better and meaningful keyword, with cost of losing and forgetting let-expression as a bool forever, a concept predates to if-let-chain (see Centril's comments in this thread about let expressions, when let-else was a postponed and unknown proposal).

They are hard to compare, because they are different type of costs. But I like to see your reasoning here. To make things easier to compare, what will you decide in these scenarios? :

  • Imagine if-let-chain RFC proposed a let-expression RFC like this one, but no one used let-else style of it. Then a let-else RFC comes in and you would realize that by replacing else with ||, it becomes a existing (but unpopular because it isn't rusty) thing in current language. Then would let-else still deserve a new keyword with better meanings?
  • Now imagine a weaker let-expression RFC proposed by if-let-chain, which only allows && and || inside if or consuming as bool, and doesn't unifies with let statements. Now a let-else RFC would come in, would you still choose else over ||?

These aren't impossible, due the fact that people (like centril I mentioned above) was aware of let-expression. If your answers is consistent and you always choose else over ||, it shows that a special keyword for this construct with better meanings worth it costs, but otherwise I doubt there is a strong benefit for else over || in let-else but you can explain what is the difference that change decision to a non-optimal one.

@HKalbasi
Copy link
Member Author

@kennytm

There is no such guarantee in this RFC, (let Some(z) = z) || BOOL_VALUE; is a valid expression statement (which does nothing).

It is valid as long as BOOL_VALUE binds z, or diverge. So there is a guarantee that this statement shadows z if compiles.

@HKalbasi
Copy link
Member Author

@steffahn good catch, seems it is a common pitfall. I will update the example.

@rfcbot rfcbot added final-comment-period Will be merged/postponed/closed in ~10 calendar days unless new substational objections are raised. and removed proposed-final-comment-period Currently awaiting signoff of all team members in order to enter the final comment period. labels Aug 24, 2021
@rfcbot
Copy link
Collaborator

rfcbot commented Aug 24, 2021

🔔 This is now entering its final comment period, as per the review above. 🔔

@scottmcm
Copy link
Member

In the lang meeting today, Josh pointed out that rust-lang/rust#53667 has a checkbox for thinking about syntax -- and thus things like this -- as part of stabilization for that. So I think I agree with looking at how if-let-chaining goes further before considering this more.

@HKalbasi
Copy link
Member Author

@scottmcm It is ok to put thinking on this kind of thing on the time of stabilization for if-let-chain and let-else, but let-else seems is intentionally not mentioned. From meeting note:

Josh: I don’t think this should be a blocker for let/else, since if we introduce an is syntax, it wouldn’t affect let/else.

So it should be a blocker until we declare that we want is syntax instead of the current one for if-let-chain. Right? Before that, they are intertwined because both use let.

And as a general comment about is: I see is expression for if and while and let statement for top-level bindings a good and meaningful separation, but what will happen to if let? It will become deprecated?

@joshtriplett
Copy link
Member

@HKalbasi No, it shouldn't be a blocker for let-else, because that would imply that is syntax would be a substitute for let-else. Even if we have is syntax, and even if we support || or ! with it (which produces really unusual scoping rules that I don't think we should introduce), I still don't think we should make people write if !(expr is pat) { diverge } or (expr is pat) || { diverge }; instead of writing let pat = expr else { diverge };.

@HKalbasi
Copy link
Member Author

@joshtriplett If is syntax becomes a thing, then everything is good, let and let-else for binding in toplevel and if-is and if-is-chain for conditional bindings. But I'm talking about other side, if the is syntax doesn't come and we keep let in both if-let-chain and let-else, then progress on if-let-chain can change things on let-else side.

@joshtriplett
Copy link
Member

@HKalbasi You seem to be taking it as a critical point that we can't have if let and let else without somehow making the two interact and use the same underlying construct. But we absolutely can, and I think we should. Those seem like different constructs that serve different purposes. let handles top-level bindings, and adding let else allows refutable patterns to affect the rest of the containing block by bailing if they don't match. if let is a conditional block, inside which the pattern has matched, and after the block the bindings are no longer bound. It's OK for those two use cases to have different language constructs; both constructs have a semantic similarity that helps people learn and understand both, but hat doesn't mean they require a unifying construct that can serve both functions.

@afetisov
Copy link

The examples in this RFC are very unconvincing. There are barely less symbols and barely less rightward drift in the proposed versions compared to the current macro-based ones. I won't bash at the problems already mentioned, but I can't help but note that the problems have more idiomatic solutions.

This example

let Some(x) = foo() && let Some(y) = bar(x) && f(y)

can be rewritten much more cleanly and idiomatically using try operator:

let x = foo()?;
let y = bar(x)?;
f(y)

The only problem is that it doesn't work locally since the try operator always returns from the containing function. We may want to postpone returning, and the function may not even return an Option<T> as required to use ?. However, this problem will be fixed more idiomatically with try blocks:

try {
    let x = foo()?;
    let y = bar(x)?;
    f(y)
}

Done! Very clean, very readable, doesn't require to mess with the enclosing function. In current stable Rust one can use option-returning closures instead of try blocks, which are less ergonomic, but still a net win if you're doing a lot of matching:

let opt_chain = {
    let x = foo()?;
    let y = bar(x)?;
    let z = quux(y)?;
    Some(f(z))
};
if let Some(t) = opt_chain() {
    // do stuff
}

The || operator cannot be represented this way, however since the produced bindings must be the same with the same bound type, we can just compose a few options:

if let Some(t) = foo().or_else(|| bar()).or_else(|| quux()) {
    // do stuff
}

Sure, it's less readable than composition via ||, but not significantly so, and as a benefit there is no confusion about binding semantics. Perhaps Rust could, like Python, introduce || operator on Options with the semantics of or_else-chaining, but the consensus seems to be against that feature. If you're doing this stuff a lot, you can write a simple macro to simplify those expressions:

macro_rules! option_or {
    ($e: expr $(, $es: expr)* $(,)?) => {
        $e $( .or_else(|| $es) )*
    };
}

if let Some(true) = option_or!(foo(), bar(), quux()) {
    //do stuff
}

Finally, there is stuff about bindings for arbitrary sum types. Again, this case is easily solved by a macro, together with the techniques above:

macro_rules! option_or {
    ($e: expr $(, $es: expr)* $(,)?) => {
        $e $( .or_else(|| $es) )*
    };
}

macro_rules! bind {
    ( $($ident: ident),+ , $pat: pat = $expr: expr) => {
        #[allow(unused_parens)]
        if let $pat = $expr { Some(( $($ident),+ )) } else { None }
    };
}

enum Foo {
    T(bool),
    H { y: u32, g: bool },
    F
}
struct Bar { f1: u32, f2: bool }

let x = bind!(x, Foo::T(x) = foo())?;
if let Some((n, b)) = option_or!(
    bind!(y, g, Foo::H { y, g } = baz(x)),
    bind!(f1, f2, Bar { f1, f2 } = bar(x)),
) {
    // do stuff with `n: u32, b: bool`
}

One could probably write an even more nice looking bind! macro as a procedural rather than declarative macro.

The techniques above compose seamlessly, don't require any modification to the base language or parsing rules, and don't introduce any potential ambiguities with respect to binding scope and order. They are a bit less readable, but imho not significantly so, and not enough to outweigh the benefits. These patterns also don't look like something which should be very common in most of the projects (syntax analysis and compilers are a different case, of course).

The main downside is that the compiler can't give good error messages for macros, unlike built-in let-expressions. However, this is outweighed by the fact that someone would first need to implement all that extra analysis and syntax-awareness. The macros are also quite simple and well-scoped, which should limit any possible confusion.

@nsabovic
Copy link

nsabovic commented Sep 3, 2021

My first comment ever in any Rust discussion! As someone who works with Rust and Swift daily I am interested in the outcome of this discussion. I am mostly interested in two use cases I have encountered in the code.

  1. In Swift there is guard let which I use all the time. Statement like this:
guard let value = result else {
    return 0
}
// value is in scope.

This is described as something that helps with the indentation but I find that it actually simplifies how I reson about that code.

In stable Rust I don't know how to express this without nesting. So two options I can think of are either:

  • Introduce new expression—guard expression like Swift.
  • Beef up existing if let expression to allow negation, like:
if !let EnumKind(value) == result {
    return 0;
}
// value is in scope.

I find the latter more familiar.

  1. In Swift you can chain let expressions like this:
while let next = lines.peek(),
   let nextTag = next.split(separator: "=", maxSplits: 1, ommitingEmpty: false).first,
   tagSet.contains(nextTag) {
   result += lines.next()
}

Notice the use of comma as the separator. It makes it obvious what order the bindings happen at and that conjunction is the only option. The reson this construct is useful is only because you can use the binding from one expression in the following expressions. In Rust without #![feature(let_chains)] I feel the urge to write something like this:

while let Some(true) = lines.peek().map(|next| match next.split_once("=") {
    Some((next_tag, _)) => tag_set.contains(next_tag),
    _ => false,
}) {
    result.push_str(lines.next().unwrap());
}

My experience with large Javascript codebases is that we want to steer clear of this.

Now in Rust with #![feature(let_chains)] we can do this:

while let Some(next) = lines.peek() &&
      let Some((next_tag, _)) = next.split_once("=") &&
      tag_set.contains(next_tag) {
    result.push_str(next);
    lines.next();
}

We're introducing && which looks like a well-known, short-circuiting Boolean operator. But it's not, because there is no ! and ||. Sucks because in the non-let subexpressions you can use both of them. I get a pause each time I write something like

if a || b && let Some(value) = value { }

Which doesn't compile. But these do:

if (a || b) && let Some(value) = value { }

if a || b && c { }

I find this janky and confusing. I see two directions that are easy to defend:

  1. Go the Swift way, don't mix Boolean logic and chained bindings and introduce new statements. This works well in Swift, it removes expressivity and nudges towards The Right Path which I find to be a good thing as Rust doesn't need more complexity. I like the try statement for its own merits although you can't introduce two bindings with it, nor zero, so it doesn't help with the example above. I think macro solutions are inferior for the same reason I think e.g. C coroutines implemented with the preprocessor are a horror show.

  2. Implement full Boolean logic for let bindings and deal with the order of operations. We're not introducing a new mental model but using Boolean algebra so I actually see this as a simplification of things and this simplification is the only reason why I think it's a good idea. I haven't seen any arguments against that can't be solved with "unused variable"/"condition is always true" warnings, and on the other hand I am not aware of any language that implements this so I've got the fear. But now that we're half way there with #![feature(let_chains)], not even trying it out feels weird.

Maybe the next step is to try a prototype of this RFC and then decide between let expressions which I see as the logical continuation of #![feature(let_chains)] or a completely different approach? I don't want this to come out as "that's a great 20% project" kind of comment—can I help somehow? Does this RFC have a champion in the compiler team or maybe a WIP PoC implementation?

@rfcbot rfcbot added finished-final-comment-period The final comment period is finished for this RFC. to-announce and removed final-comment-period Will be merged/postponed/closed in ~10 calendar days unless new substational objections are raised. labels Sep 3, 2021
@rfcbot
Copy link
Collaborator

rfcbot commented Sep 3, 2021

The final comment period, with a disposition to close, as per the review above, is now complete.

As the automated representative of the governance process, I would like to thank the author for their work and everyone else who contributed.

The RFC is now closed.

@rfcbot rfcbot added closed This FCP has been closed (as opposed to postponed) and removed disposition-close This RFC is in PFCP or FCP with a disposition to close it. labels Sep 3, 2021
@rfcbot rfcbot closed this Sep 3, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
closed This FCP has been closed (as opposed to postponed) finished-final-comment-period The final comment period is finished for this RFC. T-lang Relevant to the language team, which will review and decide on the RFC. to-announce
Projects
None yet
Development

Successfully merging this pull request may close these issues.