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:

4 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.

8 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.

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.

5 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.

5 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,
}
1 Like

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