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

Thanks a lot!

Excuse me, but what duplication are you imagining here? The difference between these two snippets of code is literally one pair of braces, and it doesn't even get rid of a level of indentation. It makes no sense to move one of the match arms to the other side l'art pour l'art. SQL tried to "read like English", and it didn't work out well.

4 Likes

x is written three times. Here it's not too bad, but imagine you want to extract a complex pattern:

let (a, b, c) = match foo() {
    Foo(a, _, Some(b), Ok(c)) => (a, b, c),
    ... => ...,
}

// vs
let Foo(a, _, Some(b), Ok(c)) = foo() else match {
    ... => ...,
}

More generally, it seems to me that all arguments in favor or against let-else also work for let-else-match.

4 Likes

That's still only a couple characters more, and it's not really harder to read. Most real patterns don't get any more complex than one level of tuples, arrays, or struct/enum ctors anyway, so this largely seems to be a non-issue.

2 Likes

It's exactly the same motivation as let else itself! There's no need to run in circles like this.

6 Likes

It's not exactly the same motivation. let-else also eliminates a mostly irrelevant nesting level for the fallback case, and the non-success cases are indented the same amount with let-else-match. Yes, eliminating the repetition of the pattern bindings is a primary motivation for let-else which the language team agreed with and supported for let-else, but saying that is the only motivation is factually incorrect.

Additionally, the guarantee of divergence is not to be overlooked as a useful property to preserve. In the case where let-else is used to keep this guarantee, putting a match in the else arm does lead to extra indentation, as well as requiring the scrutinee to be a binding instead of any expression.

If let-else-match is to be properly proposed, I highly recommend that it come with similar "evidence of motivation" that let-else did; namely, the RFC should have some excerpted examples from real projects showing both what was written and how it could be rewritten to take advantage of let-else-match.

I also expect you can construct a motivating example where temporary lifetime matters; e.g. if you do let $pat = mutex.lock() else match, the lock will be released after the let, but with let x = mutex.lock(); let $pat = x else { match x, the lock won't be released until drop(x) -- kinda the opposite case of clippy::significant_drop_in_scrutinee where the lifetime extension (or rather, end) is desired.

let-else is a special form for a subset of let-match, and let-else-match is a subset of that subset, so justifying the addition of a new syntactic structure will be a not insignificant burden of proof. It can absolutely be justified and I currently believe it carries its weight[1], but it does need a strong supporting argument.


  1. An important part of my support of let-else-match is what I'll call the "obfuscation factor". If someone who is generally familiar with Rust sees a let-else-match for the first time, what is the possibility that she guesses incorrect semantics for what the code in front of her does? If she has seen let-else before, I believe the worst case scenario goes along the line of a combined/unordered thought of { "doesn't Rust require braces after else?" and "why doesn't this match have a scrutinee expression" } followed by "I guess it must be matching over the failed let pattern then." If she hasn't seen let-else used before, the chain would be a little longer, but I still don't see a way that she could assume the construct does anything other than it does. (Modulo advanced only-sometimes-matter details like temporary lifetimes.) â†Šī¸Ž

2 Likes

I went back and looked at the nasty networking code that I mentioned earlier in this thread. It turns out that I misremembered, and it wouldn't particularly be aided by let-else-match. It does this

/// Connect a stream produced by unconnected_socket.
/// The result is Ok(true) for a connection that's already complete,
/// Ok(false) for a connection still in progress, and Err(...) for any
/// other condition.
fn connect_socket(sock: &Socket, addr: SocketAddr) -> io::Result<bool> {
    use libc::EINPROGRESS;
    let status = sock.connect(&addr.into());

    // There currently isn't an io::ErrorKind for EINPROGRESS.
    // We write 'Err(e) => Err(e)' instead of '_ => status' because
    // status is an io::Result<()>, not an io::Result<bool>.
    match status {
        Ok(()) => Ok(true),
        Err(ref e) if e.raw_os_error() == Some(EINPROGRESS) => Ok(false),
        Err(e) => Err(e),
    }
}

which is paired with this

                match connect_socket(&stream, addr) {
                    Ok(false) => {
                        // Normal case: the connection has been
                        // initiated but not yet completed.
                        slot.insert((addr.ip(), stream, before));
                        j += 1;
                    }
                    Ok(true) => {
                        // Abnormal success: the connection has
                        // already completed, record its elapsed time
                        // now.
                        record_owtt(meas, before.elapsed());
                    }
                    Err(e) => {
                        // Abnormal failure.
                        if !handle_abnormal_failure(meas, &before, &e) {
                            return Err(e.into());
                        }
                    }
                }

and then later there's this

                    match evaluate_connection_result(status) {
                        Complete => {
                            record_owtt(meas, after.duration_since(*before));
                            in_flight.remove(key);
                        }
                        NetErr => {
                            meas.record_failure(status);
                            in_flight.remove(key);
                        }
                        Spurious => {
                            // need to poll this one again, do nothing now
                        }
                        Fatal => {
                            return Err(
                                io::Error::from_raw_os_error(status).into()
                            );
                        }
                    }

and basically the theme is that there's several match arms, one of them diverges, and some unpredictable subset of them needs data from inside the enum. I think it is actually appropriate to keep using match for this code. Maybe I should fold connect_socket into its caller to get rid of the slightly odd Result<bool, io::Error>. Unrelatedly, I wish library PR 79965 "More ErrorKinds for common errnos" had added one for EINPROGRESS. Oh well.

5 Likes

Yes (for my usecases), I have been using let-else since stabilization and more often than not I run into this problem when handling errors and just use a match instead.


Here's my weird 2 cents on this pre-pre-proposal.

I think this is a (fated) slippery slope, let-else is somewhat a broken feature, so after its stabilization, it's natural to want to fix it.

I'd really love to be able to do this in my projects, but I have to agree with Josh and JJpe, this is too complex.

The first theme listed in the Rust 2024 Roadmap is "Flatten the learning curve", if we took that by heart, let-else wouldn't be a thing (nor let-else-match).

I use Rust a lot (like most of you), and I don't mind complexity for myself (I like new ergonomic features), but we really need to think about how hard Rust is to learn in the current state and how we are still trying to pump more stuff in it.

I don't think people are stressing enough about the learning curve problems when they talk about new features, it is IMO the biggest problem in the language at the moment, and each new ergonomic feature adds one more barrier when reading/understanding Rust code if you're a beginner.

Rust is expressive enough but we'll keep wanting more and more expressiveness, always, my prediction after seeing let-else stabilized is that in the next 5 years Rust will be harder to learn than it is today, and this will keep going.

Here's a quote from the Roadmap:

our goal is to let you focus squarely on the "inherent complexity" of your problem domain and avoid "accidental complexity" from Rust as much as possible.

let-else-match matches the latter, ultimately I think that features like this are good for me and you right now, but bad for the future of the language.

Sorry for overshooting with the feedback and practically going off-topic.

8 Likes

By the way, the motivating issue with redundant patterns in match would be solved much more cleanly by flow typing. That would be a very nice feature to get, which is useful well beyond let else statements.

6 Likes

Yes indeed. I think some amount of local only unnamable flow based typing could be very useful in rust for a while host if other reasons as well.

Flow typing would be very nice, but it still doesn't quite solve the use case as there's nothing bound in the else branch:

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

I guess, this puts a name to what I described above?

1 Like

I would assume that if could with like the following:

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

In principle you should even be able to save 2 indentation levels:

let f = foo();
let Ok(x) = f else {
    let Err(e) = f;  // irrefutable at this point
    println!("Error: {:?}", e);
    return;
};
8 Likes

Or one could even allow field access syntax, saving another line.

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

I have not seen in the thread the possibility of allowing an identifier-like pattern syntax

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

to be understood as alias of

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

That is to have some explicit binding, as @josh comments.

However, I remain quite uneasy of any form of this feature.

2 Likes

That doesn't scale to enums with more than 2 variants.

Maybe something like this?

let Ok(x) = foo() else err {
    let Err(e) = err;  // irrefutable at this point
    println!("Error: {:?}", e);

    return;
};

Keep insults to yourself, please.

I think naming a binding to the expression seems like a step we could take without substantively increasing complexity. It wouldn't solve the whole problem, but it would help with a decent fraction of it.

In particular, even in the more-than-two-variants case, it would allow things like debug-printing the expression: let Variant(x) = expr else @ e { bail!("unexpected thing: {e:?}"); }

(Not bikeshedding the syntax, here, just agreeing that "bind name (or perhaps irrefutable pattern) to the expression" seems potentially reasonable.)

2 Likes