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

There is no ambiguity if guard becomes a normal keyword in a future epoch, right?

The variable name guard is used quite a lot in Real Code™ unlike catch or dyn, so I don’t support keywordizing it when guard let is perfectly cromulent.

1 Like

I don’t see how guard instead of let resolves the let PAT = if cond { a } else { b }; / guard PAT = if cond { a } else { b }; ambiguity. The problem is the else, not the start of the syntax. Using a new keyword allows you to resolve the ambiguity differently (parsing it as let PAT = (if cond { a }) else {b} would be backwards incompatible) but you still need to resovle it and it’s not clear to me why that would be desirable.

Oh right, can’t believe I forgot guard is massively used as a variable name, even in Servo…

Would we be better off if foo.unwrap_or_else(|| break/continue/return) just worked? Edit: Would the common case of Result and Option be better off?

It would be a single line closure starting with one of those control flow primitives. For backwards-compatibility with return we need to come up with new syntax, for example unwrap_or_else(do || return bar) where do is some keyword.

The semantic desugaring would be something to the effect of:

fn func(x: Option<i32>) -> i32 {
    x.unwrap_or_else(do || return 1)
}
// Desugars into:
fn func(x: Option<i32>) -> i32 {
    let flag = false;
    let x = x.unwrap_or_else(|| { flag = true; mem::uninitialized() });
   if (flag) {
       return 1;
   }
   x
}

Opinions?

I don't think this is any different from if let, where the same example becomes

let x = if let Ok(x) = foo { x } else { panic!("there was an error") }

And again, I think all the examples using simple, two-variant enum matches are giving the wrong intuitions for this feature. To copy my MIR example from above (updated to what seems to be the current syntax proposal), when you're looking for a particular complex pattern, there isn't an "other side" that makes sense to talk about:

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

Yes, that's ignoring non-assignments, assignments from unary operations, etc. But that's the point.

I do like the unless form of things, since as I've said I think it's helpful even more non-pattern-matching conditions, the same way that assert! is phrased at what should hold, not the condition on which to panic!. But I don't seem to have been convincing enough about it, it would need the new epoch, and I have no problem with let ... else after glaebhoerl's points.

No, because of cases like the one earlier in this post where the there isn't any reasonable unwrap_or_else-like method that could be put on the type. I think the various forms of ? handle the things with unwrap* fine. (We could potentially even put the unwrap methods on Try, though that's a conversation for another day.)

2 Likes

This is perhaps more of a brainstorming suggestion, but since it hasn’t been brought up so far (I think): how about a full merging of let and a partially abbreviated match that contains the assignments within. For example:

let match bin_statement {
    Statement {
        let source_info,
        kind: StatementKind::Assign(let lvalue, Rvalue::BinaryOp(_, let lhs, let rhs)),
    },  // '=>' not required, but allowed - e.g., for extra calculations/assignments
    _ => continue
};

Granted, this would be a much larger language addition than a mere else after a let, but would be more flexible, and also IMHO somewhat more readable, because it highlights the bound variables.

Initial RFC draft is up! (Link is pinned to commit; switch to master to see most up-to-date version.)

I’ve written the Motivation and Reference-Level sections so far. I expect the Rationale/Alternatives section to be half of the RFC’s final text given how many options we have available for syntax, and alternate features that could express the same idea.

2 Likes

An initial review of the RFC draft:

if let takes a refutable binding and binds it to a new scope

This is not accurate, if let accepts irrefutable patterns in RFC accepted Rust, see: https://github.com/rust-lang/rust/issues/44495

In current Rust, the obvious way to handle this is to use if let to destructure the Ident:

I'd encourage you to use non-diverging examples. Otherwise, an RFC reader might get the impression that panics are encouraged. The RFC seems very tailored around the needs of custom derive macro authors (and compiler hackers), which are comparatively a small set of people.

Personally, I believe that let .. else { .. } can often be solved by providing .extract_foo() operations that extracts the fields out of a variant fallibly with some Try monad such as Option or Result.

This is better, because the error handling case is closer to the failable operation.

Why the use of match there? I'd just continue with if let.

The solution using if let would needlessly indent the happy path a level

Yes, but just a single level. The problem does not scale with if let pat = expr && cond {..} (which I think we should do some version of irrespective of this RFC).

I’ve done some digging for data of code that could take advantage of this in rustc. It’s incomplete but I got about 50 samples of code that could use this feature. The conclusion is that Option and Result are the common case, but not the 90% common case, more like 70%. So the other cases are significant, but still alternatives specialized for Option and Result should be considered.

Hmm, good point. From time to time I ponder the idea of an "opt-in" TCP closures in order to help address problems like "using ? in closures" and so forth. The idea would roughly be to have some kind of closure notation where the closure returns a "break/continue/return" enum, which gets propagated through the inner layers, and the compiler inserts code to interpret that when it comes to the right time.

I don't love this, but do expr(...) |foo| { ... } has been floated, so i'll run with it. This would mean: create a TCP-style closure C; invoke expr(..., C) with C as the last argument. Take the return return of that and "process" it to reflect control flow.

Then you would write:

let x = do foo.unwrap_or_else || {
    return;
};

Hmm Hmm Hmm. I really, really don't like that syntax :slight_smile: but I do sort of like the idea of leveraging TCP here.

3 Likes

This look quite complicated to follow and comprehend at scale, and this still means we need at least one helper function per case where one would want to use guard let. Many use cases I encounter for this feature in Servo rely on neither Option<T> nor Result<T, E>.

6 Likes

Yeah, true. In any case, TCP is a Big And Uncertain Thing to Design. Given that we already have if let – which could also be expressed with TCP! (I mean, basically anything can) – I’d not be opposed to adding some extension sugar to that form in the meantime.

There seems to be two syntax features at play here, trailing closure syntax and do. Is it the trailing closure syntax you don't like, do, or just the whole deal? I suppose trailing closure syntax might fit better if the feature were implemented generator-style rather than like try (based on matching on a return value), though I had the impression the latter was what you were suggesting.

I dislike both parts of the syntax, and I believe this is a vastly different feature than what we are trying to achieve here anyway.

I’d like to propose a variant of the syntax that has not been mentioned:

let PAT = [keyword] EXPR [keyword] { ... }

The second keyword may or may not be necessary - it depends on what the first keyword is going to be. This matches the currently discussed try/catch syntax, as well as the existing match and if-else syntax.

Also, I would like to point out that ‘return’ and ‘bail!()’ are not the only ways to exit early. Other ways to do it are ‘continue’, ‘break’ and directly calling abort() or raise() or a similar function. How the compiler should know whether or not the programmer has a valid exit block is unclear and has not been discussed.

Edit: Oh, I missed the ‘do … {}’ thing proposed by @nikomatsakis… It’s very similar to the template but personally I dislike the || operator in it

Rust has a ! type which captures the necessary semantics of “anything following this operation is unreachable”. Any function, macro, or statement which would prevent the end of the ‘exit block’ from being reached will evaluate to !. If the end of the ‘exit block’ is truly unreachable, it will in turn evaluate to !, and the compiler will know it’s correct.

That’s just closure syntax, with trailing closure sugar so you don’t have to wrap the whole thing in parentheses. The idea is just to allow higher order functions to behave more like built-in control-flow syntax, which might cover a lot of the use cases of a let ... else construction.

Not sure if this has been suggested and not particularly familiar with the considerations with this, but what about:

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

Oh, right! Got confused by the lack of parentheses. I really don't like the syntax then.

I doesn't look like a binding operation and 'if let' creates a binding inside the if scope and this would create it outside.

After thinking a bit more about this, now I don't like the syntax for exactly the same reasons I liked it before :smiley:

Sorry if this is slightly off-topic, but since it came up…

Even though the author of the linked blog post seems to suggest that TCP is an unambiguously good and desirable property, it’s almost certainly not. For example, it significantly impedes the function-ness of closures. Basically, it just trades off one kind of uniformity (~roughly, beta equivalence on nullary functions) for another (context-independence of return).

One feature for which I would choose Rust over Ruby or Kotlin any day is that it’s very clear what return exactly does. It always returns from the innermost function, regardless of what kind of funky syntax (currently only either of fn or |…|) was used to create the function. Given that misunderstood/misdirected control flow is the source of all sorts of confusion and potential bugs, being able to develop an unconditional reflex for “if I see a return, I look for the innermost curly braces” is probably way more useful and pragmatic than striving for strict beta equivalence.

I’m sure many programmers refactor their code in a way that they extract closures, which became too big over time, into a separate function or method; or, conversely, they inline trivial/small helper methods as a closure to make the code more concise. For one, I do it all the time. Both of these refactoring techniques would be severely broken and unsound if the target of return depended on whether it was inside or outside a closure.

(All of this, by the way, is especially true and relevant in the light of Rust not being a pure functional language targeting a textbook graph reduction virtual machine.)

2 Likes