That’s a good point about the lack of transitivity already.
I guess it depends on what CanEqual is supposed to model. Is it there for efficiency/catching-silly-mistakes? Or is it there for correctness in unusual situations? Or is it for declaring the set on which an ordinary equivalence relation works?
Equivalence relations are lovely and intuitive to work with. But CanEqual doesn’t seem to be about equivalence relations in the mathematical sense, because a relation R is defined on a set and for every any a, b in the set, a R b is defined.
But that’s not true with CanEqual!
scala> object O:
| opaque type A = Int
| opaque type B = Long
| given CanEqual[A, A] = CanEqual.derived
| given CanEqual[B, B] = CanEqual.derived
| given CanEqual[A, B] = CanEqual.derived
| val a: A = 7
| val b: B = 8
|
// defined object O
scala> object P:
| import scala.language.strictEquality
| import CanEqual.given
| import O.{a, b}
| val aisa = a == a
| val bisb = b == b
| val aisb = a == b
| val bisa = b == a
|
-- [E172] Type Error: ----------------------------------------------------------
8 | val bisa = b == a
| ^^^^^^
| Values of types O.B and O.A cannot be compared with == or !=
1 error found
So you have the bizarre situation that a == b is valid (and could be true) but you can’t even ask about b == a. Indeed, there is no point to having two type parameters unless you aren’t actually trying to establish an equivalence relation. You only need one parameter for that: CanEqual[S].
I had originally interpreted the second parameter as a poor approximation to writing CanEqual on the type union, but now I’m not so sure. I think the point of CanEqual is to use == for things that potentially aren’t equivalence relations.
For instance, technically it just doesn’t work to say 5.C == tempRecord where tempRecord is some class, if you store 5.C as an opaque type on an Int or Double. But you can write a working tempRecord == 5.C. And CanEqual allows you to express this: given CanEqual[TempRecord, Celsius] = CanEqual.derived, but without the corresponding CanEqual[Celsius, TempRecord].
So I think it is in the nature of CanEqual to take you out of simple and intuitive equivalence-relation land unless you write CanEqual[Q, Q]. Instead, CanEqual is its own unruly beast, because for good or ill, JVM equality is an unruly beast, and CanEqual needs to capture that. How unexpected things are depend on your discipline in using the beast, not because you cannot express unexpected things.
If we’re already in a regime where (a: A) == (a: (A | B)) could be valid but (a: (A| B)) == (a: A) could be invalid, which is exactly what CanEqual[A, A | B] (alone) expresses, so we lose symmetry, we can’t complain too much about losing transitivity. If we wanted it to be nice, we would have written CanEqual[A | B, A | B].
But the feature you’re asking for is basically
def check[C <: (A | B)](a: A, ab: C)(using A <:< C)
scala> def check[C <: (O.A | O.B)](a: O.A, ab: C)(using O.A <:< C) = true
def check[C <: O.A | O.B](a: O.A, ab: C)(using x$3: O.A <:< C): Boolean
scala> val ab: (O.A | O.B) = O.b
val ab: O.A | O.B = 8
scala> check(O.a, O.a)
val res1: Boolean = true
scala> check(O.a, O.b)
-- [E172] Type Error: ----------------------------------------------------------
1 |check(O.a, O.b)
| ^
| Cannot prove that O.A <:< O.B.
1 error found
scala> check(O.a, ab)
val res2: Boolean = true
But we can want either behavior. For instance, we might need asymmetric equals, but for convenience we want to do the test at runtime rather than having different compile-time paths, like we do with x: (1 | 2 | 3) compared to Int. Regular widening makes sense sometimes; excluding nonoverlapping sets also makes sense sometimes.
So I think that rather than stating a rule, we actually would need another trait of some sort to specify which behavior we wanted. I think the best would probably be a trait like Between[A, A | B] instead of A | B, where that type is an instruction to the compiler that when widening, it has to bind the type first and then check (as in the example check method), but when using it, it’s just A | B. That is, something like
opaque type Betweener[X, Y >: X] = Unit
object Betweener:
inline def apply[X, Y >: X]: Betweener[X, Y >: X] = ()
extension [X, Y >: X](bt: Betweener[X, Y >: X])
inline def eval[Z >: X](z: Z)(using lt: Z <:< Y) = lt(z)
There may be some way to generalize this to be more useful, but acting as if the widening was done via Betweener (and failing in the cases it wouldn’t compile) ought to do the trick.
Without an opt-in like this, I’m nervous about any default behavior that is less obvious than a different default, but prevents the obvious behavior from being had at all.
(Edit: note that the justification document does not make these points, but rather discusses how to encode contains on collections. However, if you ignore the original rationale and just look at the consequences of what we’ve got, I think we have to either embrace the non-equivalence-relationness of CanEqual, or we need to migrate from CanEqual to something else that enforces the desirable properties. In particular, the key observation in the document that the CanEqual1[Any] fallback is always available could be resolved by making it not always available via some mechanism, for instance by having Eql[T] <: CanEql[T], where Eql[T] is what you use for tight type bounds but CanEql[T] is what has the Any fallback.)