How to improve strictEquality?

Having been bitten by univeral equality again recently, I once again considered switching on the -language:strictEquality option. Alas, it’s not currently a great experience.

For me, the major issue with it is pattern matching against case objects. When you match against a case object, it performs an equality check, and unless you have a CanEqual instance for that type, this won’t compile even for trivial examples.

enum Foo:
  case Bar

def f(x: Foo) = 
  x match
    case Foo.Bar => ()
  // error: Values of types Foo and Foo cannot be compared with == or !=

One could argue that it’s not so bad to just slap derives CanEqual onto that ADT and move on, but that’s unsatisfactory:

  1. I may not control this ADT. It might come from a library, or it could be generated by a tool. You can provide an orphan instance, but that sucks too.
  2. The type may include data that cannot be compared for equality, like enum Foo { case Bar; case Baz(f: String => String) }. This type shouldn’t have a CanEqual instance because we cannot determine equality of functions. But we still need to pattern match on it!

There are workarounds, for example, this compiles:

enum Foo:
  case Bar()

def f(x: Foo) =
  x match
    case Foo.Bar() => ()

As does this:

enum Foo:
  case Bar

def f(x: Foo) =
  x match
    case _: Foo.Bar.type => ()

But the code that we started out with is actually perfectly fine, and having people rewrite their code in this way (which is clearly much uglier, especially the second one) just isn’t cool, and we need a better solution than that.

At this point the obvious question is: why does it perform an equality check at all? Why not just make case Foo.Bar equivalent to case _: Foo.Bar.type? Unfortunately, that isn’t backward compatible because equals can be overridden, which means that things like this work:

Vector.empty match
  case Nil => ()

Vector.empty is not Nil (because that is a List, not a Vector), but they do compare equal, which is why the pattern match succeeds.

I think the solution to this problem is to find a subset of pattern matches that we
can statically determine to be well-behaved, and allow pattern matching against case objects even when a CanEqual instance is not available.

One case that is definitely safe is when the scrutinee is of an enum type, and that enum doesn’t override the equals method, and doesn’t extend any other type (that might override the equals method), and the pattern is one of the cases of the enum.
What other cases should be allowed? What should the rules be here?

4 Likes

I think that either all case classes should derive CanEqual by default or at least enumerations. If one does not want CanEqual then case classes are the wrong tool.

3 Likes
1 Like

Fully agree.

Strict equality should be the default. For that it needs to work smoothly.

Maybe it’s just a bandwidth issue? I mean, maybe nobody is working on that currently but it’s on the planing board? If so would be nice to communicate it this way.

The compiler team can’t do everything at once, and there are a few bigger things going on currently, so I see no problem if this here will take time. But getting a memo that reads “we’re planing to improve strict equality in the future” would be indeed nice.

1 Like

The underlying idea with this argument seems to be that there’s nothing wrong in principle with the fact that matching against case objects requires a CanEqual instance, and that the problem to be solved is how to conveniently make that instance available.
I have a different view on that: the purpose of pattern matching on an ADT (sealed trait or enum) type is control flow and variable binding. This doesn’t require equality testing at all, and we know this because it works just fine for all ADTs that don’t have a no-argument case, like Either or Try. Pattern matching and equality testing really are two different pairs of shoes, and there’s no sensible reason why Option needs a CanEqual while Try does not.

That said, we can’t just throw out this feature (that pattern matching can perform an equality check) due to backward compatibility concerns (i. e. the “Vector.empty == Nil matches Nil” thing), and that’s why I’d like to figure out a good set of rules for when CanEqual is not required.

1 Like

I have been programming with enabled strictEquality for a longer while now.
While doing so, I did not understand equality as an automatic feature for case classes and enums: I could identity cases where equality should not be available because not meaningful for the represented domain. Furthermore, equality should consider Products: only when all elements of a Product support equality, then the Product itself shall be allowed to support equality too; CanEqual currently can’t do this.
In my understanding pattern matching and equality are not two different pair of shoes but they are related, because pattern matching can imply the necessity for equality, without requiring equality in all cases (it depends how a match case is defined).

I have summarized my thoughts in the documentation of the type-safe-equality library found on github.

3 Likes