Vibe check: equality patterns

Could we teach the compiler to consider simple guards for exhaustiveness? Maybe just simple equality between pairs of variables from the pattern?

To me OR and AND behaviors are very different. In the OR case a just receives whatever value there is, and in the AND case a enforces equality between occurrences.

And you can even combine these, and have four bindings with both of the meanings at the same time: (This(a), This(a)) | (That(a), That(a)).

To me this is way too much of a codegolf feature, and too much of a risk of creating a wrong pattern when user reused the same binding name by accident.

Patterns are already stretched thin with magic of binding modes and match ergonomics. Novice users are already incorrectly trying to make patterns match against a value of a variable from an outer scope. It's easier to explain that patterns never match against a binding's value, than that they do, but only against bindings from themselves, and not always, because | makes alternative bindings, but can still be restricted by a binding value from a non-alternative part of the pattern (A(a) | B(a), C(a)). This is a very subtle implicit syntax.

Patterns currently don't run arbitrary user code, so equality in patterns is more primitive than PartialEq's equality, so this pushes users towards the worse type of comparison. You won't be able to use this trick to check something like String vs Cow<str> in patterns, even where if a == b works.

Just add a couple of lines of code, or _ => unreachable!(), instead of adding yet another subtle mode to an already magical part of the language.

Or at very least this could have a more explicit syntax with == added to the pattern somewhere, like (a, b == a), except patterns are duals of expressions, and what is a dual of ==?

2 Likes

For a Copy type, (a, a) is the constructor for matching (a, a).

(But I misspoke about Swift, I meant that it allows arbitrary expressions and already has to deal with that for exhaustiveness, not that it allows multi-use patterns like this.)

1 Like

I find that more readable than with guards. Iirc (on mobile, so not fully sure) that would be:

(This(a), This(b)) if a == b | (That(a), That(b)) if a == b

OK, yeah that is horrible. Fair point.

I'll take whichever one optimises best. Which as I understand it, is currently not guards. But perhaps we should focus on improving optimisation of guards instead.

That is really the main problem for me, as soon as I add a catch all case I don't get notified about new cases being added to the enum. Nor do I get any guarantees about exhaustiveness. But perhaps simple equality guards could participate in exhaustiveness checking instead.

That is pretty decent actually. I could live with that syntax.

I had been meaning to ask if (a, ==a) would be enough of an additional signpost for you, @kornel.

In principle I would also rather have "sufficiently simple" guards participate in exhaustiveness checks, because guards are already in the language. And it seems like that would go well with the subset typing people were talking about a while ago (i32 is 0..16 or something like that). The challenge though is to define "sufficiently simple". Earlier someone posted a link to a github issue where someone wanted the compiler to infer that if a < b + if a > b + if a == b was an exhaustive set of match arms (for a,b integers). I don't think that would be too hard, but where do we draw the line and how do we explain the line to people who aren't PL theory experts?

1 Like

Personally I would like for the simple syntax to work, but to ask for a more explicit form when there is any ambiguity. Thus:

// Works.
(a, a) => println!("same!"),

// Error, ambiguous binding.
(a, a) => { use(a); }
// prefer
(x @ a, a) => { use(x); }

// Warning?
(This(a), This(a)) | (That(a), That(a)) => …,
// prefer
(This(a), This(a)) | (That(b), That(b)) => …,

I think there is sense in having pattern matching work as a language in its own right, I think it is reasonable to include intuitive extensions. The simple form needs to work regardless in the parser, so that warnings can point users towards the "correct" forms, whatever that winds up being.

…Which actually means we should NOT use "repeating the same variable name to express a PartialEq constraint", because then those aren't the same variable, just different variables that happen to have some custom code pronounce them equal in some sense.

I also think we shouldn't use this syntax for a stricter kind of equality than PartialEq, because it would be very easy for the user to get tripped up expecting that they can use it to make sure, e.g., that 2 Vecs are equal.

Rust just fundamentally doesn't deal in the concept of "the same value in 2 different places" the way more-mathematical languages do.

3 Likes

A similar suggestion was presented in the Scala community. Martin Odersky initially liked it until I presented him an example that went something like this:

struct Notorious {
    x: u64,
    y: u64,
}

impl PartialEq<Self> for Notorious {
    fn eq(&self, other: &Self) -> bool {
        self.x == other.x
    }
}

impl Eq for Notorious {}

fn main() {
    let n1 = Notorious { x: 1, y: 2 };
    let n2 = Notorious { x: 1, y: 3 };
    match (n1, n2) {
        (n, n) => { println!("n.y: {}", n.y); }
        _ => { println!("No match"); }
    }
}

What do you think this should print?

It should print

(n, n) => { println!("n.y: {}", n.y); }
//                              ^
// Error: ambiguous binding. Try one of
// (n @ n, n) => { println!("n.y: {}", n.y); }
// (n, n @ n) => { println!("n.y: {}", n.y); }
1 Like

Consider this:

    let x = f64::NAN;
    let (a, a) = (x, x) else { return; };

It's strange for the (a, a) pattern not to unpack the (x, x) constructor.

1 Like

Another reason why that shouldn't compile is that Notorious doesn't have "structural equality". Constant patterns don't actually allow running an arbitrary implementation of PartialEq, and in my opinion equality patterns should be similarly restricted. Basically the same as if the pattern was defined like this:

const X00: (u8,u8) = (0,0);
const X01: (u8,u8) = (1,1);
...
const XFF: (u8,u8) = (0xff,0xff);

if let (X00 | X01 | ... | XFF) = pair { ... }

Because structural equality depends on the value, you can match against const NA: Option<Notorious> = None. However, equality patterns would include all values of the type, which recursively excludes any non-derived PartialEq within the type, as well as floats (since matching on f32::NAN is also forbidden)

7 Likes

Absolutely, yes. The exhaustiveness part is a bit of a research question but that's the exciting part for me :3

That's quite compelling. Makes me more inclined towards something like (a, ==a), but the initial motivation was "(a, a) is natural, maybe we should just make it work".

Yeah, I definitely see the appeal of it!

Seems like even if (a, a) remains an error, it'd be a good place to have a hand-crafted error message that explicitly suggests the cleanest alternative. That way we'd still get the benefits of the pathway "user assumes you can do it -> user ends up with code that does what they want".

4 Likes