Should multiversal equality provide default `Eql` instances?

And that can be a nasty surprise if you go to compare an Array and are used to the structural equality provided by List

1 Like

In this case, I imagine that users would have to explicitly allow comparisons between Point subtypes with some kind of derives Eql pragma. For example:

trait Eql[A, B] // invariant
object Eql {
  private val anyDerived = new Eql[Any, Any] {}
  def derived[A, B]: Eql[A, B] = anyDerived.asInstanceOf[Eql[A, B]]
}

abstract class Point derives Eql
// "derives Eql" generates something equivalent to
// object Point {
//   given [A <: Point, B <: Point] as Eql[A, B] = Eql.derived
// }
final class CartesianPoint(...) extends Point
final class ColoredPoint(...) extends Point

// compile error without `abstract class Point derives Eql`
CartesianPoint() == ColoredPoint()

I don’t think it’s a common situation that two different subtypes of a class have custom equals() implementations like this. I suspect it’s more idiomatic to rely on the auto-generated equals() methods from enums/case classes.

2 Likes

I don’t think it’s a common situation that two different subtypes of a class have custom equals() implementations like this.

In fact, you only need one extension. Say you have a base class Seq with equality and you make an IndexedSeq subclass:

val s: Seq[T] = new IndexedSeq[T]()
val s2 = s
s == s2 // error!

So, ultimately a non-variant Eql would mean that ad-hoc extensions of classes are not longer supported. Your equality would no longer work work, unless someone had planned for such extensions. I believe that is too much of a burden to ask, and too surprising. By contrast, the current scheme without strictEquality provides a much smoother upgrade path.

EDIT: There’s another argument for contravariance, and that is that Eql is structurally contravariant. Its type parameters appear all in contravariant positions. In my experience, it’s usually a bad idea to override the natural structural type with something else. It might seem to work well at first, but then come back to bite you later somewhere where you don’t expect it. I realize that’s a very unspecific argument to make, so you might disagree with it. But I tend to take it as a guideline for my own work.

1 Like

By default, comparisons between two values should always succeed if they have the same type or a supertype/subtype relationship. Comparisons between sibling subtypes should fail by default because they are almost always an application bug unless the classes override equals().

I’m not sure I understand why s == s2 would be an error in your example because both values have the same type Seq[T].

There’s another argument for contravariance, and that is that Eql is structurally contravariant.

My experience with contravariant type-classes is that you often end up with counter-intuitive type inference. For example, that given [A] as Eql[A, A] is equivalent to given as Eql[Any, Any]. You can approximate the structure of a contravariant Eql by providing default instances like this.

trait Eql[A, B]
object Eql extends EqlPriority1 {
  private val anyDerived = new Eql[Any, Any] {}
  def derived[A, B]: Eql[A, B] = anyDerived.asInstanceOf[Eql[A, B]]
}
trait EqlPriority1 extends EqlPriority2 {
  given [A, B](using ev: A <:< B) as Eql[A, B] = Eql.derived
}
trait EqlPriority2 {
  given [A, B](using ev: A <:< B) as Eql[B, A] = Eql.derived
}

That means equality is no longer transitive.

I’m not sure I understand why s == s2 would be an error in your example because both values have the same type Seq[T] .

Yes, here’s a fixed example:

val s = new IndexedSeq[T]()
val s2: Seq[T] = s
s == s2 // error!

My experience with contravariant type-classes is that you often end up with counter-intuitive type inference.

I think that’s true for Scala-2. But in Scala 3 type inference changed for contravariant types, so at least some things work better now.

3 Likes

That’s what I was missing! I agree my proposal is not a valid solution if we require transitivity. Thank you for the explanation :slight_smile:

1 Like

It’s worth noting that Scala 2 supports dotty-like handling of contravariant typeclasses under a flag: https://github.com/scala/scala/pull/6037

4 Likes