[Pre-Pre-RFC] Reviving Refutable Let [Runoff]

Just throwing this out there:

if not let Some(val) = val {
   ...
}

The if ... let is supposed to be an easily recognised and familiar pattern.

I use blocks for let values often, and with the diverging block it doesn’t look good:

unless let Some(val) = {
   success();
   code()
} else { 
   failure();
   code()
};

It looks silly without the else between the value and the block, but with else it looks too if-let-like.

unless Some(val) = val return {
   ...
}

Require return or, where appropriate, break.

1 Like

Putting the pattern at the start of the block feels wrong to me because the binding is not in scope until the end of the block.

FWIW, in

let x = {
    stmt1;
    ....
    stmt2;
    expr
};

the binding is not in scope until the end of the block either.


(Also, I don't see why "Ambiguity when rhs of = is in form if { () }" would be an issue in practice. if has this ambiguity already and else is attached to the closest if when ambiguous, but I haven't seen any complaints about it.)

3 Likes

I would expect it to be formatted exactly like the equivalent if let:

unless let Some(val) = {
   success();
   code()
} { 
   failure();
   return code()
};

(With no else block allowed, since the body must diverge.)

(Also, I don’t see why "Ambiguity when rhs of = is in form if { () }" would be an issue in practice. if has this ambiguity already and else is attached to the closest if when ambiguous, but I haven’t seen any complaints about it.)

OK, I'll go into this a bit!

Rust does not have the classic dangling if ambiguity. In Java, it looks like this:

if (condition)
    if (condition)
       statement();
  else
      statement();

I've half-indented the else here to signify that it could legally be attached to either if and still satisfy the grammar. The language specifies that each else attaches to the innermost if.

This only occurs because of block-less if. In rust, if $expr $expr is invalid. Instead, you are required to use a block as follows:

if condition {
    if condition {
        statement();
    }
  else {
      statement();
  }
}

Even though I've mangled the indentation here, it's still unambiguous which if the else is attached to, because of the required {} block.

The interesting case, though, is that the following is valid code (proof):

let () = if true { () };
let () = if false { () };

(Note that this is not valid code when the type in the if expression is anything other than (), so this is effectively an extremely useless edge case of the grammar that this is allowed. But the argument against introducing ambiguity into the grammar doesn't care how edge-case-useless an ambiguity is, having one is bad.)

The only logical way to resolve the ambiguity is to attach it to the if. The only time not doing so is when if EXPR BLOCK is legal in expression position, which is only when it has type (). In any case, that's required to prevent breaking so much valid code today in form

let value = if rng { a } else { b };

I'll show the ambiguity visually by abusing parenthesis:

let (value =  if rng { a }) else { b } ;
let  value = (if rng { a }  else { b });

Avoiding introducing ambiguities into the grammar is not about how obvious the correct choice is, but about not having ambiguities in the grammar. Assuming Rust's grammar doesn't have any ambiguities (I know there has been effort to avoid them but who knows if any sneaked in), it's best to avoid introducing new ones.

And just to visually show let ... else with an rhs of if ... else:

let Ok(result) = if rng {
    let Inputs(i, n, p, u, t, s) = get_algorithm1_inputs();
    algorithm1(i, n, p, u, t, s)
} else {
    let Inputs(i, n, p, u, t, s) = get_algorithm2_inputs();
    algorithm2(i, n, p, u, t, s)
} else {
    // this one is required to diverge / have type `!`
    log!("Failed to do something important!");
    bail!(FailureCase::UnexpectedFailure)
}

Personally, I don't use "complicated" expr rhs'es on my if let, and probably wouldn't on my let ... else either, so this is going to show up less often. That's not a reason to ignore it, though; if anything it's a reason to pay more attention to the edge cases, because they'll be seen less often and you want them to be understandable at a glance.

(This got circular I'm cutting it off here)

If if condition { block } is extended in the future to mean if condition { Some(block) } else { None }, the ambiguity becomes more meaningful, but still would be required to attach to the if rather than the let to preserve the semantics of existing code. But that's a completely off-topic separate language extension idea.

2 Likes

Perhaps I’m missing something, but isn’t this just a discussion about catch, just from another viewpoint? I.e., what you’d want to write is simply:

let x = opt?;

then the question is: how can we define what should happen when the code actually bails. Hence, I think that this question here and catch need to go hand-in-hand.

(Now, I realize the above is not a general refutable let, but compared to error handling, IMHO that would not be worth complicating the language for.)

This isn’t so much about error handling and automatic error conversion like ?. This is more about handling of special early-out cases in general, which usually aren’t errors and aren’t just doing type conversions.

1 Like

Just throwing another suggestion out there - let … in … else

let Some(val) in expr else { /* ... */ }

I think it reads OK although it takes a bit of getting used to. Also possibly confusing for OCaml-ers.

Hmm, the mention of in makes me think of other irrefutable pattern & let locations and whether this makes sense there too.

For example, what about something like this irrefutable pattern position?

for Ok(v) else { return None } in whatever {
    ...
}

I’m against that; that’s a confusing option that could have been written just as easily (no rightward drift, reasonable extra characters, no control flow problems) as

for x in whatever {
    unless let Ok(v) = x { return None }
    ...
}

(And that version doesn’t require figuring out things like to which look a break or continue would apply if put in the else block of the pattern.)

And I think it’s pretty clear this shouldn’t be part of other lets,

while let Some(x) = v.pop() else { return } {

Or part of patterns,

match x {
    Fruit::Apple => ...
    Fruit::Banana => ...
    Fruit::Cherry else { return } => ...
}

So I think an extra keyword to use this is also helpful as a note about “no, this is its own construct; don’t try it elsewhere”.

2 Likes

This isn’t so much about error handling and automatic error conversion like ?. This is more about handling of special early-out cases in general, which usually aren’t errors and aren’t just doing type conversions.

Is it though? The vast majority of all examples in this thread and all linked threads is about bailing on Err or None. Now, perhaps this is because Result/Option make for easy examples, but it does muddy the waters.

Conversely, even if it's not about error cases: the same ability to run code before bailing would be nice to have for error handling as well.

In the same vein, I would also like to point out that most suggestions do not allow access to the diverging value in the bailing block, e.g.:

let Ok(val) = lengthy_computation() else {
    log!();  // log WHAT exactly? We threw away the Err...
    bail!()
}

IMHO this would be quite limiting in actual code. Edit: Even if assigning to a variable first, it's not clear that the failed destructuring wouldn't still count as a move (apart from it being quite inelegant).

This is absolutely why, as @CAD97 explicitly said earlier.

If you want more complex examples, there are plenty to be found in clippy, for example. Like https://github.com/rust-lang-nursery/rust-clippy/blob/master/clippy_lints/src/assign_ops.rs#L130, which could save some depth with unless let hir::ExprBinary(op, ref l, ref r) = e.node { return }. Or rust-clippy/clippy_lints/src/attrs.rs at master · rust-lang/rust-clippy · GitHub, which is an example with Option that's not about error handling and where ? wouldn't help. (There are probably better examples, but I just looks at the first few files in the directory.)

Or https://github.com/rust-lang/rust/blob/master/src/librustc_mir/transform/lower_128bit.rs#L46 in rustc, where unless let continue would be amazing.

Thanks a lot, those are quite informative examples! Looking through these and reading once more through RFC #1303 led me to this thought:

We could simply consider let to be an expression returning true on successful assignment, false otherwise. Then, using lazy evaluation of ||:

(let hir::ExprBinary(op, ref l, ref r) = e.node) || return;

Ugly, perhaps, but simple and easy to explain. This definition would also seamlessly fit with if let and while let, and would directly allow for if precond && (let Ok(x) = blah) && postcond(x) { ... } mentioned in RFC #1303.

Edit: obviously, when let returns false, the whole statement is required to exit the current scope.

1 Like

I don’t see those as very good examples either. The first example wants if let pattern guards which is an uncontroversial language extension, the second and third examples look like they want filter_map with ? inside the closure, the third one would do much better by using match instead of if let in any case.

This debates not only the usefulness of this feature, but that if even our most well-groomed code bases aren’t using the existing sugar optimally, is adding more sugar really effective?

1 Like

I would prefer the ‘if let’ syntax to be rethought. A form of syntax that has a clear meaning of ‘if [variable] matches/doesn’t match [pattern] {block}’. Perl has ‘=~’ and ‘!~’ that are used in that way (except pattern matching is limited to regular expressions) and that seems like a better approach than coming up with inconsistent ways to express the opposite of ‘if let’

This isn’t just “if expr doesn’t match pat, do something”. Otherwise, if !matches!(EXPR, PAT) {BLOCK} would be fine. The point of this is the following symmetry (which is also why the if let syntax is so nice and clean, which you may have missed given your objection here):

  • let PAT = EXPR; takes an irrefutable pattern, destructures the expression into it, and binds names to the containing scope.
  • if let PAT = EXPR {BLOCK} takes a refutable pattern, attempts to destructure the expression into it, and if successful, binds names into a new scope for BLOCK
  • The refutable let effort attempts to find the best/least problematic/controversial syntax to take a refutable pattern, attempt to destructure the expression into it, and bind names into the containing scope. The attached block is required to diverge such that this will work.

But it’s not symmetric. Think about it. By itself let takes an irrefutable pattern. When it’s in place of expression it takes a refutable pattern. That’s not symmetry, that’s special casing. In addition to that, if let creates a variable for use in the scope of the block that follows it. You don’t need that variable when if let fails. All you need is to check if the expression can’t be destructured into a certain pattern. In other words if !matches!(EXPR, PAT) {BLOCK} fits perfectly even if it looks awkward.

if let is not let in expression context. let is not allowed in expression context. if let is its own thing.

That’s exactly what I’m saying. I suppose I wasn’t very clear. I meant that the ‘if’ statement is treated differently depending on the presence of ‘let’.