Enumeration does not derive `CanEqual` for `strictEquality`

What is the reason enumeration does not derive CanEqual?
It was very surprising that this is not the default case, which led me to submit the following issue:

However, only after I noticed that the test cases explicitly check the interaction of enums and strictEquality, I understood that this is an intended design decision.

13 Likes

I started experimenting with strictEquality just recently. Once it’s on it pretty much requires you to have it specified on every case class that will participate in pattern matching.

1 Like

IME so far it’s strictly worse than Scala 2’s linting support + -Xfatal-warnings.

In Scala 2 you do literally nothing and get actionable errors for the cases you care about (i.e. where you are mistakenly comparing values of different types).

In Scala 3 you have to pepper derives CanEqual on every non-primitive type where you’ll be doing equality checking.

For example, here are some ScalaTest failures that have me considering giving up and disabling strictEquality in sbt test scope.

assert(x == Some(model))

Values of types Option[User] and Some[User] cannot be compared with == or !=

assert(x == List(model))

Values of types List[User] and List[User] cannot be compared with == or !=

Compared to Scala 2 it’s kind of like the tail wagging the dog, but I guess this is the only solution until linting support lands in Scala 3.

4 Likes

I feel the same way. I really believe CanEqual should be derived automatically for reasonable structures, like case class, sealed trait, enum. Until then I hardly imagine some uses this feature.

3 Likes

For anyone else stumbling over this, here are two givens covering the aforementioned things like case class, sealed trait and enum:

inline given canEqualProductOrSum[T](using m: Mirror.Of[T]): CanEqual[T, T] =
  summonInline[CanEqual[m.MirroredElemTypes, m.MirroredElemTypes]].asInstanceOf[CanEqual[T, T]]
given canEqualSingleton[T <: Singleton]: CanEqual[T, T] = CanEqual.derived

Imho they should be in the standard library.

cats users might also want to add

given canEqualCatsEq[T](using cats.kernel.Eq[T]): CanEqual[T, T] = CanEqual.derived

If you have AnyVals: (Note that one could define an AnyVal with underlying objects that are not comparable, e.g. class F(Int => Int) extends AnyVal. If you might be tempted to do this, don’t use the following one.)

given canEqualAnyVal[T <: AnyVal]: CanEqual[T, T] = CanEqual.derived

If you only want case classes, but not sealed traits or enums, replace the Mirror.Of by Mirror.ProductOf.
If you only want enums, you may use

given canEqualEnum[T <: scala.reflect.Enum]: CanEqual[T, T] = CanEqual.derived
4 Likes

Hi @ansvonwa,

Thank you for this proposal. But I would like to push back a bit. IME, equality testing using == is actually not super common for ADTs. The real issue is pattern matching, as @SimY4 has pointed out:

(I think he meant to say enums there, because pattern matching on case classes doesn’t require CanEqual). Pattern matching is what my improvement proposal, SIP-67, solves. It was accepted a few months ago and I have submitted an implementation in the compiler yesterday.

While it’s tempting to solve the problem by providing a magic CanEqual instance, it doesn’t work for several important cases.

  • It doesn’t work for recursive enums like enum List[+A] { case Nil; case Cons(head: A, tail: List[A]) }
  • More problematically, it doesn’t work for enums where any of the cases contain a type that doesn’t have CanEqual, like for example a function type: enum Foo { case Bar; case Baz(f: Int => Int) }. A crucial point here is that this type not having CanEqual is actually a feature, not a bug: there is no way to determine if two functions are the same, and hence CanEqual shouldn’t be available – but we still need to be able to pattern match on this ADT.

I’m also generally opposed to “fully automatic derivation” (as opposed to semi-automatic derivation like you get with derives XXX clauses).

  • Letting the author of an enum type control whether or not a typeclass instance is available is a feature, not a bug. It can be a conscious decision to omit a derives clause, for example because the author of the ADT anticipates adding more cases in the future involving types that don’t have the required typeclass instances
  • fully-automatic derivation failures are an absolute nightmare to debug. When you enforce derives clauses for all typeclass instances, ADT authors are immediately notified when they make a change that causes derivation to fail. With fully-automatic derivation, it’s the user of the ADT who gets the error message rather than the author. Keep in mind that ADTs might be defined in a library, so somebody might make a simple library update which causes auto-derivation to fail. And because auto-derivation is recursive, this can happen many levels away from the root cause of the problem.
  • fully-automatic derivation leads to code bloat and slow compile times, because the same derivation will happen everywhere you use the typeclass, instead of just once as it does with a derives clause.

For all these reasons, I’m opposed to any automatic derivations in the standard library. == seems like too common a thing to require a derives clause for at first glance, but I suspect most derives CanEqual clauses on enum types actually exist only to enable pattern matching. For the remaining cases, sprinkling a few derives CanEqual clauses over the code base here and there is not a big deal – IMO a much better option than accepting all of the downsides of fully-automatic derivation that I have laid out above. Fully-automatic derivation is one of those ideas that are super tempting at first but turn out to be a bad idea in practice in the long term.

2 Likes