Should multiversal equality provide default `Eql` instances?

What is the plan for default Eql given instances?

I was trying out Multiversal equality for Scala 3 with Dotty 0.27.0-RC1 (http://dotty.epfl.ch/docs/reference/contextual/multiversal-equality.html) and was surprised to see the following behavior

$ dotr -language:strictEquality
scala> Option(1) == Option(1)                                                                                                        
1 |Option(1) == Option(1)
  |^^^^^^^^^^^^^^^^^^^^^^
  |Values of types Option[Int] and Option[Int] cannot be compared with == or !=

scala> Vector(1) == List(2)                                                                                                        
val res3: Boolean = false

scala> 'a' == 42
val res1: Boolean = false

I expected the Option(1) == Option(1) to compile successfully and Vector(1) == List(2) to fail compilation. However, looking at the implementation I see that there’s a default Eql given instance for all sequences but there’s no default given instance of Eql[T, T] https://github.com/lampepfl/dotty/blob/972ae73be961ce19103fe8edfa7246b44de7625a/library/src/scala/Eql.scala#L32

I am concerned multiversal equality won’t gain wide adoption with the current implementation given it’s both too strict and not strict enough at the same time. I suspect users who optionally enable -language:strictEquality would prefer to fail the compilation when comparing unrelated types (even if they are equal at runtime). I feel like it would be more natural to either 1) remove all default instances or 2) at least provide a default given instance for Eql[T, T].

11 Likes

I think the problem is that if you provide a default given instance for Eql[T, T] then you provide it for Eql[Any, Any] and you’re back to non strict equality.

1 Like

also tuples

scala> (1, 2) == (1, 2)
1 |(1, 2) == (1, 2)
  |^^^^^^^^^^^^^^^
  |Values of types (Int, Int) and (Int, Int) cannot be compared with == or !=

it seems like Eql needs instances for all the types Ordering et. al. do (actually more, since we don’t have Orderings for some larger tuples)

If Eql is invariant I don’t expect this to be true (though I didn’t check).

import scala.language.strictEquality
given Eql[Any, Any] = Eql.derived
class A
class B
new A == new B
// false
3 Likes

Good point, it doesn’t seem possible to provide an Eql[T, T] instance because both type parameters are contravariant. You end up with Eql[Any, Any], defeating the purpose of strict equality.

2 Likes

Should the compiler even look for an Eql if the types are the same?

Good points to discuss. There’s a question what strictEquality should mean. The current meaning is:

Under strictEquality, values of a type T can be compared only if there is an instance of Eql[T, T] available, and any such instance has to be defined explicitly.

This is analogous to type-class based equality in libraries sich as Cats or Scalaz. Only types that are equipped with the right typeclass instance can be compared, which makes it also possible to express that a type does not come with equality. strictEquality only works if all your libraries are designed for it. In particular it would not work without additional shims for the standard library. Because of this, strictEquality is at the moment best considered a hedge for the future, where libraries have moved, but at present it’s not ready for prime time.

The usual way to do multiversal equality is to define the proper reflexive Eql instances for types that you do not want to be universally compared. Then you don’t need strictEquality.

A different meaning of strictEquality would be this:

Under strictEquality, values of types T1 and T2 can be compared only if one of T1, T2 is a subtype of the other, or there is an instance of Eql[T1, T2] available (and eqlAny is not available in this case).

That would make the Option example compile. On the other hand, it would be no longer possible to force a type not to have some equality. The benefit of this semi-strict equality is that it still refuses to
compare unrelated types, without needing any explicit Eql instances for either of these types.

Maybe we need both forms? A downside is that it would complicate matters, by including an effective mode switch how equality should be handled. Explicit Eql instances are more flexible, but require a bit more notation. E.g.

case class Person(name: String, age: int) derives Eql ...

The other part was about Vector. Here, it was a conscious decision to make all sequences comparable with each other. I still think that’s the right decision. One question to ask is whether we should allow some form of customization. E.g. we could put the Eql instance for Seq into Predef instead of the Eql object, so that it could be unimported. But, again not sure whether we want to provide this customization at the price of added complexity and heterogeneity,

1 Like

An example of this would be Throwable and subclasses, correct?

In the specific case of Option, I’m a little surprised that there isn’t a Eql instance that derives from an Eql instance for the type parameter. Is this an oversight, or is there a reason I’m missing that this was omitted?

1 Like

Strict equality doesn’t seem to govern pattern matches.

Nil == List.empty[Int]                 // no, for some reason
List.empty[Int] match { case Nil => }  // more like it
2 Likes

Probably because Nil is typed as List[Nothing], this would probably work:

(Nil: List[Int]) == List.empty[Int]

I think the alternative meaning of strictEquality shown by Martin is what programmers likely expect, because in most cases, this is what is needed and wished. In some (more rare) cases, we might want a type not to come with equality.

As a crazy thought, would it be possible to have something that goes (very roughly) in the direction of:

Under strictEquality, for values of types T1 and T2:

  • if there is an instance of ForceNoEql[T1, T2] available they can not be compared
  • or else they can be compared only if one of T1, T2 is a subtype of the other, or there is an instance of Eql[T1, T2] available (and eqlAny is not available in this case)

I’m aware that this is a daring thought (ForceNoEql and Eql can overlap, what happens in nonStrictEquality, etc). Just wondering if there is a way to force a type not to have some equality, but to consider this as a special case, to make things hopefully easier.

// allows for equality
case class Person(name: String, age: Int)

// forces not to have equality
case class Person(name: String, age: Int) derives ForceNoEql

In the specific case of Option , I’m a little surprised that there isn’t a Eql instance that derives from an Eql instance for the type parameter. Is this an oversight, or is there a reason I’m missing that this was omitted?

The reason is that strictEquality is not meant to be used yet. It can become usable once we have upgraded the standard library. A revamp of the library is not in the cards for 3.0, but one could add shims. Without strictEquality comparing options and tuples works as expected.

Another option would be to simply remove strictEquality until it can be given proper support. I think that’s probably the most conservative route.

7 Likes

If the standard library is partially upgraded (it sounds like Seq has an Eql instance, which is what I’m basing this on), it might be better to remove strictEquality until there’s more comprehensive support, as having Seq work and Option not work is going to be really confusing.

3 Likes

Removing strict equality until the stdlib supports it sounds like throwing away the baby with the bathwater. Is a (community contributed?) library with stdlib Eql instances all that’s needed until they’re included in the stdlib itself, or won’t that work?

1 Like

forgive my lack of being up on scala 3 syntax, but can’t something like this work:

implicit def eqlSame[A](implicit notAny: Not[A =:= Any]): Eql[A,A] = Eql.derived

Well, that only pushes the ball down to AnyRef, AnyVal, Product, Serializable, Enum, Throwable and so on. It’s not just Any that’s the problem, it’s any undesired LUB.

3 Likes

I think it would be a shame to remove strict-equality. I’m curious, what is the motivation for making Eql contravariant? I’m wondering if it would solve our problems if we:

  • Change Eql[A, B] to become invariant.
  • Provide default instances for A <:< B and B <:< A (while avoiding divergence)

I suspect this change would approximate most people’s intuition of what types are safe to compare. There are some cases where this would fail type checking even if the comparison is true at runtime, for example 'a' == 'a'.toInt or List(1) == Vector(1). We could provide optional instances for these odd cases.

1 Like

Perhaps it would be possible to use super traits to have the compiler not use implicits of type Eql[Any, Any], Eql[AnyRef, AnyRef[, etc., maybe through an annotation on the Eql trait.

Say you have a class Point with two implementation classes CartesianPoint and ColoredPoint. They both coexist in an application and should be comparable. You define an Eql instance on Point that should also work for comparing the implementation classes.

1 Like