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

Runoff poll between two most popular options below!

I think it’s time to try bringing around the postponed RFC #1303, refutable let (RFC tracking issue) for consideration again. I’ve started drafting out what the new RFC might look like, and want to gather some feedback on both subjective opinion and any concrete benefits/drawbacks to the different possible syntaxes. I’ll keep the OP as-up-to-date as I can with examples. For some context, I wrote a blog post examining the original RFC and recently posted a new syntax idea to r/rust, which got some discussion. If all goes well, one syntax should emerge as a “better” choice which the actual RFC text can start with. If I’ve missed a possibility, please suggest it below, along with any drawbacks I might have missed.

I’m also interested if you can come up with any motivating examples. I’m convinced that this is a useful feature, given the limitations laid out in the “do nothing” choice, but most small examples I can come up with have another refactored representation that is better, using the high-level data manipulation fn.

The order of syntaxes below is roughly arbitrary with some grouping of similar choices. <keyword> refers to a bikeshedable new (potentially contextual) keyword when used. We start with some opt: Option<T>, and want to bind Some(val) = opt in the containing scope, otherwise log! some information and bail! (using error-chain or failure).


Just use combinators

(Do nothing)

let Ok(x) = opt.ok_or_else(|| {
    log!();
    Err()
})?;

Known problems:

  • Doesn’t work for types that haven’t built up a library of useful higher-order fn
  • Introduces a new closure context, meaning ?, return, break, and continue can’t be used for control flow

Just use match

(Do nothing)

let x = match opt {
    Some(x) => x,
    _ => {
        log!();
        bail!()
    }
};

Known problems:

  • Verbose, requires repeating information (especially if more than one value is bound in the pattern)
  • Does not enforce divergence of the error case (this can be fixed the way the desugaring would work, by introducing a let _: ! = { <block> } context, but this adds more boilerplate and another level of indentation, which the point of this construct is to reduce needed indentation.

let ... else

let Some(val) = opt else {
    log!();
    bail!()
}

Known problems:

  • Ambiguity when rhs of = is in form if { () }
  • Expect regular binding by prefix, get something extra at the end

<keyword> let ... else

guard let Some(val) = opt else {
     log!();
     bail!()
}

Known Problems:

  • Doesn’t start with let yet adds bindings to the containing scope (including shadowing)
  • Ambiguity when rhs of = is in form if { () }
  • Requires new keyword

<keyword> let

unless let Some(val) = opt {
    log!();
    bail!()
}

Known Problems:

  • Doesn’t start with let yet adds bindings to the containing scope (including shadowing)
  • Requires new keyword
  • Human ambiguity around blocks in rhs the same as exist for if let

else let

else let Some(val) = opt {
    log!();
    bail!()
}

Known Problems:

  • Doesn’t start with let yet adds bindings to the containing scope (including shadowing)

  • Human ambiguity around blocks in rhs the same as exist for if let

  • Human ambiguity when preceded by an if or if let statement – what should the following do?

    let x = 1;
    let y = Some(2);
    let z = Some(3);
    
    if let Some(x) = y {
        println!("{}", x);
    }
    else let Some(x) = y {
        println!("{}", x);
    }
    println!("{}", x);
    
    Answer
    [error] else let must diverge
    |
    | else let Some(x) = y {
    | ^^^^^^^^
    note = expected type `!`
    note = found type `()`
    

    (Where it points picked arbitrarily) What about when you add a panic!() to the else let block?

    Answer
    [warning] unused variable
    | 
    | let x = 1;
    |     ^
    note = shadowed here:
    | 
    | else let Some(x) = y {
    |               ^
    
    [build successful, running]
    2
    3
    

    I believe this ambiguity will go away with time as programmers learn to parse the else let syntax, similar to the adjustment period adopting if let or ?.


let ... <keyword>

let Some(val) = opt or else {
    log!();
    bail!()
}

Known Problems:

  • Expect regular binding by prefix, get something extra at the end
  • Requires new contextual keyword

<keyword> ... <keyword>

(or with the let)

guard Some(val) = opt or else {
    log!();
    bail!()
}

Known Problems:

  • Doesn’t start with let yet adds bindings to the containing scope (including shadowing)

if !let

if !let Some(val) = opt {
    log!();
    bail!()
}

Known Problems:

  • if let does not introduce bindings to the parent scope, this does (including shadowing)
  • makes a let binding feel like a bool-valued expression

let match / match let

let match Some(val) = opt {
    _ => {
        log!();
        bail!()
    }
}

Suggested here

Known Advantages:

  • Allows you to easily handle destructure the error case
  • Strictly more expressive than the other proposed options

Known Problems:

  • Heavier than any of the other options
  • Match syntax could be added into other options even without the match keyword
  • Don’t do anything
  • let ... else (known ambiguity)
  • <keyword> let ... else (known ambiguity)
  • <keyword> let
  • else let
  • let ... <keyword>
  • <keyword> ... <keyword>
  • if !let

0 voters

2 Likes

An advantage for the ones with a new keyword: it makes the “it must diverge” part really obvious up front, and works with non-let cases. I think the latter would be nice especially for compound conditions, so that you don’t need to demorgan in your head. Like unless a > 0 || b > 0 { return None; }

Also, I suggest using a more complex example to make the “you have to repeat the variable names three times” more obviously annoying. Like in the below, you’d need to repeat source_info, lvalue, lhs, and rhs:

    unless let Statement {
        source_info,
        kind: StatementKind::Assign(lvalue, Rvalue::BinaryOp(_, lhs, rhs)),
    } = bin_statement
    {
        continue;
    };
1 Like

That is a very good example; I used a shorter one for the demonstration just to try to keep the length manageable.

I just want to point out that in theory else $expr { diverge; } (so the preexisting keyword option) could still work, as else cannot be followed by an expression in the current grammar, only a block. But that is a good argument against the forms that use a leading new keyword without let.

I’d like to add another option to that list:

let match Variant0(x) = expr {
    Variant1(..) => ...,
    Variant2(..) => ...,
    ...
}
// `x` is in-scope here

I think this is preferable to let ... else for a couple of reasons. Firstly, it’s just as powerful since you can use a single match branch to blindly capture all possibilities other than the pattern you were looking for. eg:

let match Foo(x) = expr {
    _ => return,
}

Secondly, when you test to see whether expr matches your patten you’re already doing a match on it. So it makes sense to have access to the result of that match in the “else” clause. Otherwise people will end up writing code like this:

let Ok(x) = my_result else {
    let err = my_result.unwrap_err();    // ewww! unwrap!
    ...
}

Edit: Although maybe match let would be better than let match. That would make it look like if let and while let although that might be a bad thing since match let binds variables in the code after the block, not in the block.

12 Likes

I actually really like this idea of providing match arms within the diverging block. Though this could be provided without the match keyword provided, doing so clarifies intent.

I personally don’t have a real opinion on let match versus match let. The order of match let is more consistent to if let/while let, but since this adds its binding to the containing scope, starting with let seems beneficial as well.

I’ve added the option to the OP, but I’m not allowed to edit the poll (to avoid invalidating results).

Interesting idea- removes some rightward drift at the cost of adding some in other cases. I wonder if there’s a good syntax for making it optional- something like this maybe?

unless let Foo(x) = some_foo() match {
    Bar(y) => return ...
};

Or

let Foo(x) = some_foo() else match {
    Bar(y) => return ...
};

The second one plays into the idea of else match that I think has popped up before.

2 Likes

Another approach is to lean on hypothetical is operator: https://github.com/rust-lang/rfcs/issues/929#issuecomment-285602496

if some(foo) !is Foo(x) {
    // no x in scope here
}

// x in scope here

cc @petrochenkov

1 Like

I love @petrochenkov 's suggestion a lot. We really need a “subject-verb-object” structure to express “expression-matches-pattern”. is is less verbose than matches , I think it is a great option on table.

Details about $expr is $pat (or let $pat = $expr as a boolean expression) can be found in discussions of RFC #2260. One big problem of is is how bindings are made. Without name bindings it is not sufficient to replace let … else. 2260 compare is with several other languages (C#, Kotlin, TypeScript), but:

  • Swift’s $pat ~= $expr does not support name binding

  • In C# 7’s $expr is $pat, a declaration statement will be inserted before the current statement, i.e. the following two statements are equivalent:

    return f(c is float d);
    // <=>
    
    // declaration inserted
    float d;
    // when we evaluate `c is float d`, it will be expanded to
    bool _result = c is float;
    if (_result) {
        d = (float)c;
    }
    return f(_result);
    

    This is possible only because every type has a default value in C#. The variable d will be “leaked” after calling f, and has a value of 0 if c is not a float. This treatment is not possible in Rust.

  • Kotlin’s smart cast ($expr is $ty) and TypeScript’s type guards (e.g. $expr instanceof $ty) is not used for pattern matching (destructuring). They alter the type of $expr so that type-specific fields will become available.

There is an attempt to create a precise scoping rule in https://github.com/rust-lang/rfcs/pull/2260#issuecomment-354525996.

1 Like

I’m not sure what’s the best option here (though I like the let match family of options), but I definitely feel like this pattern comes up a lot, and it’d be nice to have a good fix for it.

4 Likes

I think we should consider syntaxes which act purely as modifiers on patterns (converting a refutable pattern into an irrefutable pattern).

One possible example:

let {
    panic!()
} unless Some(foo) = bar();

This adds a new form of pattern:

<irrefutable_pat> = <expr:!> unless <refutable_pat>

Would also be usable as:

match x {
    A => println!("A"),
    B => println!("B"),
    panic!("unexpected") unless C => println!("C")
}
3 Likes

Interesting concept. What makes

match e {
    A => "A",
    B => "B",
    unreachable!() unless C => "C",
}

any better than

match e {
    A => "A",
    B => "B",
    C => "C",
    _ => unreachable!(),
}

though? Correct me if I’m wrong, but I can’t think of any location other than let where a single pattern is used rather than allowing one of many to be selected, and a _ arm serving to make it exhaustive.

I’m pretty sure that specific syntax would require using a full keyword instead of a contextual one as well, in order to avoid ambiguities.

This doesn't work, you also need the if block to be diverging, otherwise we can have an uninitialized x

if foo !is Some(x) {
    // can't use 'x' here, good.
    // but we choose to fallthrough
}
let y = x;  // the `x` here should be invalid

Unless you only permit return/break/continue, knowing whether an expression is diverging (e.g. has type !) requires type checking, which would be too late to determine if x should be in scope or not.

The idea is that x is always in scope (from name resolution point of view), but it may be potentially uninitialized and if it's uninitialized, then access to it results in an error reported by some control/data flow pass performed after type checking.
(I never proposed bindings from is to be in scope outside of their statement though, so in my scheme is is not a solution for the let ... else problem.)

2 Likes

I was under the impression that refutable let's else branch is required to be diverging under any proposal? Like, this is the main idea behind the feature: you handle exceptional cases by returning, and deal with a happy case using straight-line code :slight_smile:

Yes, refutable let requires the else branch to be diverging, but a simple is expression places no such requirement.

Hm, it does places such requirement? Like, you’ll get “variable is unitialized” error at compile time if there’s no divergence, and everything would be ok if there is divergence.

:confused: I’d expect it follow Swift’s guard let ... else which requires a diverging block.

IIRC there is also suggestion allowing it to be non-diverging but still initializes the x:

let Some(x) = foo else { Some(4) };
// equivalent to `x = foo.unwrap_or(4);`

I didn’t remember suggestions making it conditionally uninitialized though :sweat_smile:

1 Like

What I don’t like about proposed variants of the syntax is that they look too much like a multi-line value for a regular let:

let Some(val) = {
   some();
   code()
};

is visually quite similar to the opposite case:

let Some(val) = val else {
   some();
   code()
};

And the let {…} unless <pattern> is so far the clearest one in this regard :+1: (the different meaning of the block is easy to see by the lack of the pattern in let {.

I also propose a variant: do {} unless let <pattern>.

1 Like

I really don’t like putting the pattern at the end of the diverging block, because it’s the condition that determines whether it will execute. Would a different initial keyword be a strong enough marker? (e.g. unless let Some(val) = val { ... })

1 Like