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

What's the point of encouraging contributors to polish an idea if it's never going to be accepted?

3 Likes

If it's never going to be accepted (e.g. significant whitespace), then that's an immediate rejection once that's apparent.

Most proposals aren't so clear-cut, though. We should be and try to be transparent about the likelihood of a feature being accepted, but improving a proposal is part of figuring out how well the proposal would fit Rust.

This is also why we encourage focusing early on the problem statement a feature hopes to address.

6 Likes

Because even if I, as one of the people with (non-absolute) veto power, have a negative initial reaction to something, that still doesn't mean it's never going to be accepted. Obviously I'm only human, so my first impression still impacts me, but I try to set that aside as much as I can.

It's true that all language change proposals "start with -100 points", to use a phrase whose source I've forgotten. But even though skepticism is the correct default answer for every feature, some things will still pass the bar.

And it's not uncommon for things that do eventually pass the bar to be disliked for a while. My favourite example is dyn Trait, which was broadly :-1:'d when it was first proposed in 2016 (Introduce `dyn` keyword by ticki · Pull Request #1603 · rust-lang/rfcs · GitHub), but then a year later it came back (https://github.com/rust-lang/rfcs/pull/2113) to broad :+1:'ing. Sometimes it takes a while to find the right form, or for other things to happen that make it easier. (For instance, a bunch of things which used to be infeasible are now possible thanks to editions.)

8 Likes

This feature is indeed needed, but there's a choice of the specific syntax to use.

I think the biggest benefit of the let else match syntax is that it opens the floodgates for loop match and other constructs, and this new syntax custom of chaining keywords is both easy to assimilate and useful for combating rightward drift.

I thought first it was a typo, but no. Right after a comment which worried about opening the floodgate to all other kinds of extensions.

But I'm making a counterpoint. I want let else match and loop match. It's orthogonal, it fits Rust, and if it's ever stabilized people will ask why it didn't ship sooner.

I don't actually see it that way.

To me, the reason that let-else match might exist is because it's one construct that needs to be considered as one thing. The point, in my mind, of

let F::A(a) = blah()
else match {
    F::B => break,
    F::C => continue,
}

it that that match is exhaustive despite not including an arm for F::A since the let is included as an arm for the purposes of those checks. (Said a different way, if you added braces around the match it wouldn't compile any more.)

Something like loop { match { ... } } -> loop match { ... }, on the other hand, is just removing braces, which I don't see as worth it -- having to argue about when to omit it and when not to will cost more than is gained from avoiding two braces.

5 Likes

Adding my name to the list of people wanting this; I just tried to write an let-else-match in anger after realizing the limitations of let-else...

// much better than the alternatives
let Ok(Some(foo)) = get_foo() else match {
  Ok(None) => return Err("missing foo"),
  Err(err) => return Err("failed to get foo"),
};

I also think this would be useful; IMHO, one of the pain points of Rust's syntax is scope accumulation and excessive rightward drift, so anything that can reduce this is worth trying out.

loop {
    match foo {
        F::A(a) => {
            // Already 3 indentation levels in, and I 
            // haven't even started doing stuff yet!

Additionally, constructs like loop match { ... }, if cond loop { ... } or even fn foo(a:Foo) -> Bar match { ... } serve as an in-band signal that there won't be any "dangling code", which is often somewhat of a code smell, and makes early exits more painful:

fn frobnicate(foo: Foo) {
    match foo {
        Foo::A(_) => ...,
        Foo::B(_) => {
            ...
            // could be break 'match ?
            if cond { return }
            ...
        },
        ... // imagine 10-20 arms here
    }
    // did the previous early return intend to skip this? no idea :shrug:
    some_very_important_thing();
}
3 Likes

Note that with the recently stabilized break from labeled blocks, breaking a match from the arms is quite painless:

match foo {
    Foo::A(_) => ...,
    Foo::B(_) => 'arm: {
        ...
        if cond { break 'arm }
        ...
    },
    ...
}
4 Likes

What's wrong with

let Ok(result) = get_foo() else { return Err("failed to get foo"); };
let Some(foo) = result else { return Err("missing foo"); };

If I got the concept right "let match else" is just a match statement that allows 1 varient to excape the match scope. I think this can be achieved in many ways as shown by others here without extra syntax. A simple code refactoring of an obviously oversized function into a few smaller functions should do the trick 99% of the time.

Personally I dislike else match because match suggests that arm like Some(x) => x could be embedded inside which isn't true for this kind of match. Perhaps the following combination would be better:

// when no pattern matching expected
let Ok(Some(foo)) = get_foo() else => {
    return Err("failed to get foo"),
};

// with pattern matching
let Ok(Some(foo)) = get_foo() else {
    Ok(None) => return Err("missing foo"),
    Err(err) => return Err("failed to get foo"),
};

Like some others in this thread, I think else match adds too much complexity and surface area for too little value.

I appreciate that let else lacks the ability to further process the variant, and I do think it may make sense to have a mechanism for binding the expression in the scope of the else, to allow (for instance) displaying/logging the unexpected non-matching value. If you're writing let Some(SpecificToken(s)) = tokens.next() else { ... }, then you may need the token so you can report a more useful parse error. You can, of course, pull the expression out and give it a name, and then use let pat = ident else { ... ident ... }, but we might want to make that easier.

But the added complexity of "allow a match and rule out the variant matched by the let seems like too much, to me, given that we already have match to solve that problem.

I don't think we need all language constructs to grow until they can each separately handle all the same control flow goals. I think it's alright if we have special-purpose constructs like let else that exist to handle particular needs, and then people need to use different constructs if they want full generality, and those constructs may not be as optimized for the same special purpose.

10 Likes

If we had a feature where the compiler could be aware of which patterns are unreachable/impossible for the value in some variable/place based on control-flow and lack of intermediate mutable access[1], then something like

let Ok(Some(foo)) = get_foo() else match {
    Ok(None) => return Err("missing foo"),
    Err(err) => return Err("failed to get foo"),
};

could be written as

let foo_res = get_foo();
let Ok(Some(foo)) = foo_res else {
    match foo_res {
        Ok(None) => return Err("missing foo"),
        Err(err) => return Err("failed to get foo"),
    }
};

which is at least somewhat nicer than the already possible alternative of

let foo_res = get_foo();
let Ok(Some(foo)) = foo_res else {
    match foo_res {
        Ok(None) => return Err("missing foo"),
        Err(err) => return Err("failed to get foo"),
        Ok(Some(_)) => unreachable!(),
    }
};

I could also imagine that such a feature could be followed with allowing field-access in enums in cases where the variant is known. E.g. instead of

let Ok(foo) = f() else match {
    Err(e) => {
        cleanup();
        panic!("got an error in situation X: {}", e);
    }
};

one could perhaps just do

let f_res = f();
let Ok(foo) = f_res else {
    cleanup();
    panic!("got an error in situation X: {}", f_res.0);
};

Disadvantage: You need the extra let some_name = …EXPR… if it’s a value that needs to be computed first. Advantage: you don’t need the single-arm match cases that would probably be common with a else match feature.


  1. I am aware that this sounds like a hard to pull off feature, and discussing the technicalities would quickly become off-topic here, yet I also believe that some MVP with practical use should be doable, and further refinement could be added later ↩︎

6 Likes

The word you're looking for is Typestate analysis - Wikipedia.

As I recall Rust actually had something like that back before 1.0, but it was removed long ago.

I thought the whole “typestate” thing was usually referring to user-defined sets of type-states. The knowledge of what variant an enum is in seems more like a fairly narrow and non-customizable analysis, perhaps more akin to warning about unreachable code, or tracking which variables are (statically known to be) initialized, or perhaps even a bit comparable to the borrow checker (within a single function) tracking which variables/places are borrowed in what manner.

OTOH, I don’t know anything about the typestate-related features that pre-1.0 Rust once had, so I don’t actually know what kind of features they were and why exactly they were removed.

Also, similar to tracking uninitialized variables, and unlike the borrow checker which has explicit lifetime annotations in function signatures, I’m not proposing that this kind of analysis should be allowed across functions, i.e. I think it might be going too far to add the ability to – say – somehow annotate in the function signature of Option::is_some that after the function call, if the return value is matched against true, the Option is known to be Some and on false it’s None. I feel like putting this kind of stuff into function signatures would quickly put Rust onto the path of being turned into a proof assistant.

I've often heard what typescript does with types https://www.typescriptlang.org/docs/handbook/2/narrowing.html or what C# tracks for nullability https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/nullable-reference-types#nullable-references-and-static-analysis called typestate, and those are also not custom predicates, but flow-sensitive typing where code dominated by a type check can use something as that type.

After all, one could argue that variants are a "user-defined set of type-states". The "Retrofitting Typestates into Rust" paper includes "This feature generates an enumeration containing all states [...] using an enumeration allows us to “unify ” all states under one type".

But you're right that it seems like the typestate that was removed in Rust 0.4 seems to have been oriented around custom predicates (example in this mail), although Graydon made sure it didn't become dependent types.

Niko would probably know more, as the one who removed it. But I guess the core is that it's unclear to me exactly what "typestate" is defined to mean. So maybe we really both agree here :slight_smile:


EDIT: Oh, I found the paper: http://www.cs.cmu.edu/~aldrich/papers/classic/tse12-typestate.pdf

Fig. 6 shows the "typestates of a variant: an object with two alternatives", which looks exactly like the "which variant is the enum" case.

So I guess the problem is that "typestate" can mean lots of things -- the paper also describes initialization as a typestate, in which case Rust still has a "limited version" of typestate if you want to read it that way.

I don't know if there's a general term of art for "typestate with user-extensible arbitrary predicates" as different from other more limited forms.

1 Like

I suppose an important aspect is whether or not custom API can interact with these states. E.g. if a fn foo(&mut self) function on an enum could be annotated with something of the effect “only can be called if enum is statically known to be in variant Bar and after returning it will be in variant Baz”, then I’d agree that knowing enum variants would feel a lot like what I imagine as custom type-states.[1] If it’s just a as-straightforward-as-possible (while still useful) analysis that allows conveniences like the example I’ve had above, or also the ability to write e.g.

enum Foo {
    Bar {
        x: i32,
        y: i32,
        z: i32,
    },
    Baz(String),
}

fn foo(a: Foo, b: Foo) {
    match (a, b) {
        (Foo::Bar { x: a_x, y: a_y, z: a_z }, Foo::Bar { x: b_x, y: b_y, z: b_z }) => {
            g(a_x, b_x);
            g(a_y, b_y);
            g(a_z, b_z);
        }
        _ => todo!(),
    }
}

a bit nicer as

enum Foo {
    Bar {
        x: i32,
        y: i32,
        z: i32,
    },
    Baz(String),
}

fn foo(a: Foo, b: Foo) {
    match (a, b) {
        (a@Foo::Bar { .. }, b@Foo::Bar { .. }) => {
            g(a.x, b.x);
            g(a.y, b.y);
            g(a.z, b.z);
        }
        _ => todo!(),
    }
}

(yay, actual field syntax with named enum fields!)

then its more of a “make control flow more convenient” or “improved exhaustiveness check heuristics” kind deal than anything else. Actually, I guess it’s exactly that: improved exhaustiveness check… allowing you to leave out some match cases, or – if there’s only one case left – even use it in places requiring irrefutable patterns (i.e. in let statements), or with direct field accessors.


  1. That’s probably also where some negative effects of such a feature would first start appearing: I can imagine that the ability to express such things could significantly complicate function signatures, the cost of which could be to make Rust too complicated/weird of a language, given it already has borrow-checking and lifetime signatures. OTOH as long as it’s an intra-function affair of cases where “I know this enum has this variant, and your (talking to the compiler) optimizer probably knows so, too, so why do I need to .unwrap() again, add unreachable!() match arms, or the like?”, I wouldn’t worry about negative effects, except perhaps from complication of compiler implementation. ↩︎

I was able to implement "let else match" using "macro_rules!" (based on usual "let else" statement). Here is code: Rust Playground

Notes:

  • Works on recent stable Rust
  • "macro_rules!" introduces binding "scrutinee", but (thanks to macro_rules design) it doesn't leak, so there is no bug here
  • I didn't tested this code much
  • Some features are not supported, for example "let pat: type = ...", but can be trivially added

Despite all this, I still like original proposal, I want it to be in the Rust language itself

1 Like

FYI no it can't, at least not trivially, as : is not allowed to follow $pat fragments; only =>, ,, =, if, and in are in the "follow set" of $pat. Follow sets are used such that the pattern grammar can be extended in the future, e.g. there have been some proposals to make : Type part of pattern syntax rather than part of let and function argument syntax. (The details don't matter here. TL;DR: [1]) You could straightforwardly accomplish it with a tt muncher, but it wouldn't be pretty and certainly not trivial.

It's worth noting three minor shortcomings of your macro definition:

  • it'll always capture the scrutinee by value[2], even though a match/let pattern doesn't necessarily, such as if all the patterns don't actually bind anything or all bindings are explicitly put into the ref binding mode.
  • temporary lifetime extension is different for a let than a scrutinee.
  • it doesn't allow if guards on the match arms.

I'd personally make a minor enough tweak (or it was minor before I reintroduced putting the else arms in a let-else to get guaranteed diverging behavior without the ! type) which addresses the latter two but doesn't address the former — I think it's a fundamental limitation of needing to destrure to the primary pattern at least twice in the desugar.

Normal let-else uses an unadorned let $pat = $expr else $block, so that isn't changing. The common case is not needing to reuse the nonmatched variants, so it makes sense for it to be the unadorned form.


It does seem like most of what let-else-match offers is handled by the more general feature of flow-refined pattern types, so (despite that being a much bigger feature) it seems reasonable to focus on that first. (There's already a prototype implementation for pattern restricted types, though not the flow type refinement.)


  1. The short version is that given e.g. struct Meters(f64);, it would be nice to be able to write fn f(Meters(m)); instead of fn f(Meters(m): Meters);. This has shown up various times before under names such as "generalized type ascription." Expression type ascription is in the process of being removed (at least the : Type version of it) for various reasons, but I still think making type ascription an optional part of patterns — at least the root pattern, anyway — makes sense. ↩︎

  2. or reborrow if it's a borrow ↩︎