Pre-Pre-RFC: `let else match` statement

Background

Rust 1.65.0 stabilized let-else statements. In the discussion on r/rust, u/intersecting_cubes made the following observation:

let-else is useful, but only in limited cases where you don't want to inspect the other branches. Most of the time, I do want to inspect them, though. For example, if I'm unpacking a Result, I probably want to log the error instead of just returning or breaking. This requires binding the Err branch, which let-else won't do.

The more I thought about it, the more I agreed with this point. It seems like it would be quite common to want to use values in a failed pattern before diverging. This would still retain all of the benefits of let-else statements (reduced repetition, etc.), but you would have a more powerful else branch.

Proposal

My (very early, only moderately considered) proposal is to add a let-else-match statement. The match statement would need to cover all patterns that weren't covered by the let binding.

I imagine something like the following:

let Ok(x) = foo() else match {
    Err(e) => {
        println!("Error: {:?}", e);
        return;
    },
};
println!("Got: {}", x);

If our call to println! was a constant, then we could have used the new let-else statement. However, if we want to use the Err value, we need to match on it. As of today, the best option is to revert back to a normal match statement, with all the duplication that entails.

let x = match foo()  {
    Ok(x) => x,
    Err(e) => {
        println!("Error: {:?}", e);
        return;
    },
};
println!("Got: {}", x);

Another Example

let (Some(x), Some(y)) = (foo(), bar()) else match {
    (Some(x), None) => {
        println!("We need both, not just {}", x);
        return;
    },
    _ => return,
};
println!("We have {} and {}", x, y);

As of today, this would need to be written as:

let (x, y) = match (foo(), bar()) {
    (Some(x), Some(y)) => (x, y),
    (Some(x), None) => {
        println!("We need both, not just {}", x);
        return;
    },
    _ => return,
};
println!("We have {} and {}", x, y);

This is adequate but repetitive.

Possible Concerns

Does this lead to ambiguous syntax?

I don't think there's a possibility for ambiguous syntax since a normal else statement has to 1) come after an if statement, and 2) has to be followed by {, not the match keyword.

Is this actually a common use case?

This is the part that I'm least confident in. I don't know how often this will come up. Since let-else was just barely stabilized, I haven't even used that yet. If anybody wants to go through a small crate and see how many places could take advantage of this that could be helpful.

Does this make the language more complicated?

Yes. Is it worth it? I think it adds a nice feature without making the language more complex, and I think the behavior is fairly intuitive. If I saw this code in the wild and hadn't been closely following Rust for a while, I suspect that I would shrug my shoulders, think "I didn't know you could do that", and expect it to work as I have described. I think that my interpretation of the behavior would be the most common interpretation by far.

The only alternative interpretation of the semantics I can think of is thinking that the match lived inside an implicit else block. However, Rust has a pretty consistent history of not letting blocks be implicit, so that interpretation would be assuming a significant deviation from that pattern.

Do we really need to do this?

This is simply syntactic sugar and doesn't allow us to write any code that was impossible to write before. It does seem like a natural extension of the let-else statement.

What alternatives are there?

let Ok(x) = match foo() else {
    ...
};

^ This seems weirder to me than my proposal, but I guess it could work.

let Ok(x) = foo() match else {
    ...
};

^ This doesn't flow as well for my English brain. I also think that the match keyword should be closer to the patterns it is matching against than the else keyword.

let Ok(x) = foo() else {
    Err(e) => {
        println!("Error: {:?}", e);
        return;
    },
};

^ I believe this would make both programmatic parsing and human reading much more difficult. You don't know whether it's a "normal" let-else statement or a let-else-match statement until you see if there are arrows or not. This also might subject to more serious ambiguities; I haven't thought about it for very long.

Conclusion

After thinking about this for about a day, it seems like it could be a helpful addition to the Rust language. I've tried to come up with reasons why it wouldn't work/would be too costly/aren't a good fit for the Rust language and haven't come up with anything.

I look forward to hearing your feedback! If there's an obvious (or subtle) reason why this isn't a good idea, please feel free to point it out! :smile:

9 Likes

I think it would be fairly strange to have a second match construct which is almost the same as the standard one but is missing a branch. Especially since match can be nested in weird places already, I don't think this different behavior would be clear unless you were already aware of it.

15 Likes

match still requires being exhaustive, so that's not necessarily an issue; if the match is in valid code you know it covers all cases.

Additionally, the unstable never type feature allows writing matches which don't mention any enum variants which are uninhabitable.

1 Like

The original usecase I feel is already handled well by combining let-else and Result-combinators:

let Ok(x) = foo().map_err(|e| println!("Error: {:?}", e)) else { return };

(and if you commonly have such a structure, you could define your own Result extension methods for logging).

The tuple example is a much stronger one, but it would be more interesting to see snippets of real code that can use this construct. The original let-else RFC and PR thread had a few real examples that were a good motivator.

Also of note, this is a future possibility in the let-else RFC.

8 Likes

About the syntax, I feel like match is introducing a new indentation level and is almost no better than let x = match foo() { Ok(x) => x, Err(e) => { ... } };. But maybe:

let Ok(k) = foo() else Err(e) => {
    println!("Error:? {e:?}");
    return;
}

Since I think the common case is handling one variant, or all other variants in the same way.

12 Likes

Previous discussions about let-else-match:

Scott goes off on a wild tangent idea: let v = foo?; is

let Ok(v) = foo
else match {
   Err(e) => yeet e,
}

(Basically lean into how this is all sugar for a match anyway. This doesn't trip my usual "I don't want else match" because it's all one construct, so the match can work without needing typestate.)

I'm picturing that as one big construct which would desugar to

let (v,) = match foo {
    Ok(v) => (v,)
    Err(e) => yeet e,
}
2 Likes

This is sufficient when you're using the Result (or Option) type. However, it fails when using custom types—especially enums with more than two variants. Imagine this case:

for time in get_times() {
    let Now(timestamp) = time else match {
        Past(history) => return Error::TooLate(history),
        Future => continue,
    };
    // Use timestamp...
}

You can't use map_err (or any similar) method for this use case. Once again, you would need to revert back to a complete match expression.

for time in get_times() {
    let timestamp = match time {
        Now(timestamp) => timestamp,
        Past(history) => return Error::TooLate(history),
        Future => continue,
    };
    // Use timestamp...
}

This is probably the line of thinking I'm most sympathetic to. I do believe that the fact that this would have an else keyword immediately preceding the match helps a lot in differentiating them.

However, if I'm not mistaken, this would be legal:

let Ok(x) = foo() else match match bar() { Some(y) => y, None => Ok(default_x), } {
    Err(e) => {
        println!("{}", e);
        return;
    },
}

I'm guessing we don't normally worry about optimizing for weird-exprs.rs, but it is something to keep in mind.

Two things I forgot to reply to:

The tuple example is a much stronger one, but it would be more interesting to see snippets of real code that can use this construct. The original let-else RFC and PR thread had a few real examples that were a good motivator.

This is a good point. I don't have good motivators to point to. If others have good motivators hopefully they can raise them here :smile:

Also of note, this is a future possibility in the let-else RFC .

I totally missed this. Good to know that someone else brought this up! I tried looking around but didn't find it.

3 Likes

I think the key is that the proposed else match { ... } does not accept any argument after the match, instead implicitly using the preceding expression.

EDIT: Disregard, I didn't realize you were most likely alluding to the existing case that

I'd love to have this!

If one had anything with more than two variants, we could just get the value:

let Now(ts) = foo() else x => {
    // x is either Past or Future here.
    return
}
1 Like

I think this entire branch of rust development has kind of snowballed out of control and needs to be reigned in, badly.

Just because we can add more control structures doesn't mean it's a good idea. And I think Rust's complexity budget is (in danger of) being overspent.

Say we add this. What's next? Where does it end? When do we say "no further"? And no, those are not rhetorical questions.

And then there's the content itself. This proposal is just a match with a nonobvious and asymmetric ordering of cases. I think that's a spectacularly bad idea. Keep in mind, no new abilities are being added, just new ways to write what's already perfectly possible.

26 Likes

That's slightly unfair. The value of let else match is that it creates a binding beyond the substatement for exactly one of the match arms.

let Ok(val) = foo() else match {
   Err(e) => { report_error(e); return; }
}
// val is usable here

You can express this with a plain match but the control flow goes down and back up again and it's not, IMNSHO, nearly as obvious that all but one of the match arms diverges.

let val = match foo() {
  Ok(val) => val,
  Err(e) => { report_error(e); return; }
}

I have written code that does nasty low-level networking things that had a construct like this, but with three match arms, each containing nontrivial code (gotta handle EINPROGRESS specially) and it was painful to reason about, and I couldn't find any better way to write it.

I don't exactly disagree with your position that this is an unnecessary new feature, but I think you're strawmanning the case for it.

9 Likes

This touches the heart of the matter: obviousness is entirely subjective here. I know that because to me a match expr is the far more obvious construct to deal with, precisely because of its regularity. I've written my own fair share of nested matches, and even then I'll take those any day. Because their meaning is obvious.

And as you indicated, match can already do today what let else match is meant for. Another way to phrase that is: that use case is already being catered to.

You know, this reminds me of the unless macro in common lisp. Even though it's always available, I never used that either for much the same reason: it's not obvious enough to be worth it, simply because I always have to invert the predicate first.

10 Likes

I don't think it's that simple. "match can do what let else match is meant for" is true, but so is "match can do what let else is meant for". But let-else was deemed worth having, so it's possible that let-else-match might be worth having too.

(Remember that match can also do everything that if can do too, and arguably does so better than match does let-else. But we still have if!)

I agree that an unless that just inverts the predicate isn't worth it. But if the body is required to diverge, then there's a reason to use it.

After all, we write assert! with an "inverted" predicate too. So a "here's something that must hold afterwards" condition is a useful thing, and I could imagine writing loop body invariants as unless x > 0 { continue } or similar as a useful thing, especially to avoid mental DeMorgan gymnastics.

(I'm not actually proposing it for Rust, though. It might have made sense to spell let-else as unless let, but that ship has now sailed.)

1 Like

That presupposes that everyone agrees that let-else was worth having, which isn't true. The current thread could just as much serve as evidence that let-else was a mistake: too weak to properly deal with the whole use case, just encouraging adding more syntax soup to plug more holes.

12 Likes

There's nothing that everyone agrees.

let-else's acceptance is undeniably evidence for "things can be added to Rust even if it's already possible to do with match".

It may very well be that let-else-match doesn't pass the bar for acceptance -- generalizing sugar made for a common special case is often not something we'd do -- but if so it won't just be because match can do it.

6 Likes

I want to add the following syntax to the list of possible alternatives:

let (Some(x), Some(y)) = (foo(), bar()) {
    else (Some(x), None) => {
        println!("We need both, not just {}", x);
        return;
    },
    else _ => return,
};

println!("We have {} and {}", x, y);

for time in get_times() {
    let Now(timestamp) = time {
        else Past(history) => return Error::TooLate(history),
        else Future => continue,
    };
    // Use timestamp...
}

But for some reason I'm sure that it already has been considered somewhere.

I never said or implied it was just because match can do it. That argument was meant to show that there's nothing new under the sun here, in sharp contrast to features such as GATs or const generics. Those features obviously pull their weight, and yet there was a heavy discussion on whether GATs should have been included at all.

Meanwhile I distinctly don't get the sense that a similar level of scrutiny is being applied here, and whether this feature or let-else pull their weight is completely nonobvious to me.

Just to put things into perspective a little, I don't think either of these 2 let features is the end of the world. However, over the last couple of years I've read how some folks feel like Rust is careening in the direction of C++ - so humongusly huge that the language just buckles under its own weight and so each project simply decides on some subset of the language to keep things workable. Problem is, each project decides on a different subset. Initially I thought the notion of Rust becoming too large preposterous. But as time marches on, less and less so. Thing is, I think it works kind of like the event horizon of a black hole: you don't really notice¹ passing that threshold and so before you know it, it's too late.

¹ I'm making the pretty large but simplifying assumption that an observer would live long enough to actually reach the event horizon intact.

6 Likes

Two things I'll add here:

  1. Paradoxically, it's the "lets you do something fundamentally new" features that in a way need more of that kind of scrutiny. Once GATs and const generics are in the type system, they're there forever. But if we decide in 2025 "you know, that let-else feature was a horrible mistake", we could remove it from the 2027 and following editions. It needs to keep working in the older ones for the stability promise, but when things are just sugar that's exactly the kind of thing that editions let us change or remove.

  2. This is a "Pre-Pre-RFC" on IRLO, not even an RFC PR, let alone an FCP. We should be discussing lots of things that won't get accepted in threads like this. Regardless of whether I think the feature is a good idea, I still want to see the proposal reach its best form: the most rusty syntax, the most orthogonal behaviour, the most convincing motivation, etc. It's pretty rare that a proposal is an immediate no. (Though there are some, like "switch from braces to significant whitespace".)

18 Likes