Idea: Early returns and irrefutability

This is subjective. I assume readers of my code are familiar with, and conform to, the Google C++ style guide; you’re from a different school of programming entirely. I don’t think this is at all worth bikeshedding.

1 Like

It was my impression so far that Rust doesn’t do coercion to bool (for example, if 0 { isn’t allowed), unlike for example in Python, and I think that fits with the general style of Rust. Similarly, || currently only appears to accept bool (that is, 1 || 3 is not allowed). It seems like a sizable departure from this style to start coercing Option or other Try types to bool.

I find this pretty hard to grok. In particular, the asymmetry between the first case being outside of the block and other case(s) being in the block makes it less than obvious to me.

3 Likes

This is no better than just doing

let x = match res {
    Ok(x) => x,
    Err(e) => something_diverging(e)
};

Which is already valid. The only benefit to yours over this is a few less characters, which isn’t a sizable benefit.

2 Likes

It does with unary/binary ambiguity, like x ?? - y

3 Likes

How about that:

fn func(x: Option<u8>) -> Option<u8> {
    let y = x? + 2;
    Some(y)
}

?

It would be more like

let x = match x {
    Some(x) => x,
    None => return expr
};

Where expr is not necessarily an Option<_> or Result<_>.

Hmm. I do like that this avoids the need for ‘magic’ scoping rules as would be required for if !let, and it’s even more succinct. On the other hand, it feels a bit weird to be bringing a Perlism to Rust.

The parsing issue is interesting. As has been noted, code of the form:

let Foo(x) = expr || expr2;

parses today as let Foo(x) = (expr || expr2). But I believe this can never type-check, as || always returns a bool, while a pattern of the form Foo(x) can never match a bool. (Handy that we don’t have ‘pattern aliases’, nor do we allow function calls in constant patterns.) So in theory, let could adjust its precedence based on the syntactic form of the pattern.The only patterns a bool can match are variables, constants, and literals. Variables are irrefutable, so not usable with the proposed new feature. Bool literals can be identified at parse time; bool-typed constants can’t, but neither literals nor constants are particularly useful for this feature since they don’t introduce a binding – so it’s ok if, e.g., let true = x || continue continues to parse the old way.

For let Foo(x) = expr || expr2 to parse as (let Foo(x) = expr) || expr, the let Foo(x) = part would have to have stronger precedence than ||. For consistency, you would also expect it to have stronger precedence than anything weaker than ||.

According to the reference, the operators with weaker precedence than || are .. and ..=, assignment operators (=, +=, etc.), return, break, and closures. Assignment operators return () and can’t be matched against Foo(x) either. return, break, and closures are prefix operators, so they can’t conflict with let which is effectively another prefix operator. That leaves .. and ..=, and they actually do pose a problem, as this compiles:

let std::ops::Range { start, end } = 1 .. 2;

And although std::ops::Range { start, end } is irrefutable, it cannot be distinguished at parse time from a refutable enum variant pattern. Thus, adding this feature might require breaking that code (at least, it would require significant additional complexity to not break it). On the other hand, building and then immediately destructuring a range is fairly useless and unlikely to be seen in real code. This could also be postponed until an edition boundary.

In any case, a potential alternative is to just require parentheses:

(let Foo(x) = bar) || continue;

I’d say this is uglier, but it arguably has readability benefits, as it’s more obvious that this is not a regular let, and the || continue sticks out more.

1 Like

To clarify, what I think Centril is proposing does not actually involve coercion to bool – unlike the Perl and JavaScript constructs which serve as semi-inspiration. Rather, the idea is that

let Foo(x) = my_enum || continue;

would parse as

(let Foo(x) = my_enum) || continue;

let would be a bool-typed expression, evaluating to true or false based on whether the pattern matched (sort of retroactively justifying if let). It wouldn't matter whether the value of my_enum itself was 'truthy' or 'falsy' or anything like that.

1 Like

I don’t think you have to do any of this trickery.

Parse it as let <pat> = (<expr> || <expr>);. Have a transformation that depending on context makes the || expand to { let temp = <lhs>; if temp { temp } else { <rhs> } or let <pat> = match <lhs> { <pat> => <pat>, _ => <rhs>.

Or if we’re ok with || only working with Try types rather than with refutable patterns, add impl Try for bool and desugar || to (approx) match <lhs>.as_result() { Ok(t) => typeof::<lhs>::from_ok(t), => <rhs> }.

We have a lot of power to (ab)use || (and &&) since they’re only magic for bool-typed expressions currently. I’m not sure when they’re “desugared”, but it’s definitely “possible” to intertwine it with type resolution, if not ideal. And the Try version doesn’t require it.

In any case, I personally prefer magic semantics for || over magic semantics for let ||.


Edit bad thought:

What keeps us from “just” introducing a new statement of form let <pat> = <expr - ||> $(|| <expr - ||>)* ; that “just” works for bools?

The benefit of parsing it as (let <pat> = <expr>) || expr is that the desired semantics then become a natural consequence of existing precedent:

  • if let is effectively precedent for treating let as a bool-typed expression. Of course, if let is currently parsed as a special case, but with let as an expression, if let Foo(x) = bar can be retconned as just an if followed by the expression (let Foo(x) = bar).
  • || already works with a diverging expression on the right.
  • The compiler will have to produce an error if you try to use the variables bound in pat when the expression on the right of || doesn’t diverge – but that can be seen as just a special case of the existing initializedness checks. This already works:
    let x;
    if let Foo(x_) = bar {
    	x = x_;
    } else {
    	continue
    }
    println!("{}", x);
    
    But if you replace the continue with a non-diverging expression, the compiler complains about a “borrow of possibly uninitialized variable”, as expected.

Thus the language becomes more uniform overall.

Furthermore, it would naturally enable other boolean expressions involving let, such as this often-requested one:

if let Foo(x) = get_foo() && let Bar(y) = get_bar(x) {
    ...
}

Or even:

let Foo(x) = get_foo() || let Bar(x) = get_bar() || continue;

although that’s a bit ugly.

That said, I think your first proposal is largely equivalent to mine, other than not supporting the other boolean expressions I just mentioned. You would special-case || rather than changing the overall operator precedence, which avoids the .. problem, but you can do the same thing in the parser – it’s just nicer to keep a consistent precedence. Doing it as a late transformation would allow delaying the decision until after name resolution, so in theory you wouldn’t have the problem with constant boolean-typed patterns – you could allow this to work:

const MY_TRUE: bool = true;
let MY_TRUE = expr || continue;

But as I said, that’s not very useful (and it’s quite confusing), so I don’t think there’s much benefit in allowing it.

1 Like

That's not my proposal.

let x1 = Some(3);
let x2 = None;
let y1 = x1 || 10; // y1 == 3
let y2 = x2 || 10; // y2 == 10
let z = x2 || z1; // z == Some(3)

This is perfectly consistent with bool under the type bool = Option<()>; const true: bool = Some(()); const false: bool = None; isomorphism. And it's lazy just like foo() || bar() doesn't call bar if foo returns true. And it has the exact same precedence as it does with bool.

Hehe; my basic idea was that this would "fall out" from let p = e just being an expression and the same mechanism would make if !let p = e work. (let p = e) || continue is then mostly a matter of style.

Yeah, that's reasonable at least as a stopgap solution that may (or not) be relaxed later.

Yup!

Indeed; I'm working on this atm partially for let_chains.

Oh? It's quite significant that if !let would work as well, as it mitigates the "Perlism" issue. But I remember from previous discussions that scoping compared to if let was a sticking point; I'm interested by the idea of using || in part because it sidesteps the scoping issue. How do you intend to address it?

Not only that, but it also avoids parsing being dependent on types, which would be highly undesirable.

Then the following should also just work, right?

if let Foo(x) = my_enum && let Foo(y) = other_enum {
    ...
}

And with adding a bit of syntactic sugar for ‘let’:

let Foo(x) = my_enum;
let Foo(y) = other_enum;

let Foo(x) = my_enum,
    Foo(y) = other_enum;

if let Foo(x) = my_enum,
       Foo(y) = other_enum {
    ...
}

It wouldn’t have to be based on types. In the post you quoted I was trying to explain how it could be done based on the syntactic form of the pattern. But maybe I can be more clear:

  • If the pattern is a single identifier, let would parse as it currently does. Such patterns are normally irrefutable variable assignments which wouldn’t be useful to use with let-as-expression. They can also be names of constants, in which case they’re generally refutable, but also generally unnecessary to use with let since you can just use == instead. You could also use parentheses in that case.
  • If the pattern is a struct/enum literal like Foo(bar) or Foo { bar: baz }, let would parse with different precedence – just stronger than &&. This is okay / not a breaking change because the altered precedence would only make a difference in situations that couldn’t possibly have type-checked in the past – with the possible exception of building and then immediately destructuring a Range, which is unlikely to be seen in real code. But it’s not necessary to actually do type-checking to make this determination.
  • Other types of patterns: Foo::Bar should probably be parsed the old way but there are some counterarguments; tuple, slice, and reference patterns should be parsed the new way; _ should be parsed the old way; ref shouldn’t affect things.

FWIW, there was a similar discussion last year where I happened to make the exact same proposal :smiley: .

Yeah sure; it has been raised many times. For now I want to make progress on Tracking issue for eRFC 2497, "if- and while-let-chains, take 2" · Issue #53667 · rust-lang/rust · GitHub and then we can extend it later :slight_smile:

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.