Learn myself some Scala 3, episode 4: strict equality

In Scala 2 – like in Java – we have universal equality which means that we can compare two objects – each of any type – for equality:

1
2
3
4
5
6
7
scala> final case class Foo()

scala> Foo() == Foo()
val res0: Boolean = true

scala> Foo() == Option(Foo()) // Pointless!
val res1: Boolean = false

As shown above, universal equality can lead to pointless equality comparisons. The reason for this unfortunate behavior is the equals method, which is defined for every class – either inherited from Any or overwritten – and has a parameter of type Any:

1
def equals(other: Any): Boolean

Scala 3 adds the strictEquality language feature which only allows using == and its counterpart != in the presence of a given Eql instance:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
scala> import scala.language.strictEquality

scala> Foo() == Foo() // Does not compile yet ...
1 |Foo() == Foo()
|^^^^^^^^^^^^^^
|Values of types Foo and Foo cannot be compared with == or !=

scala> given Eql[Foo, Foo] = Eql.derived
def given_Eql_Foo_Foo: Eql[Foo, Foo]

scala> Foo() == Foo() // ... now it compiles ;-)
val res2: Boolean = true

scala> Foo() == Option(Foo()) // Still does not compile, which is what we want!
1 |Foo() == Option(Foo())
|^^^^^^^^^^^^^^^^^^^^^^
|Values of types Foo and Option[Foo] cannot be compared with == or !=

As we can see above, after activating strict equality – also called multiversal equality – we can no longer compare two Foo instances for equality. After providing a given Eql[Foo, Foo] instance this becomes possible, but we still cannot perform the pointless comparison of Foo() and Option(Foo()), which is exactly what we want in most cases (see below).

Instead of “manually” providing given Eql[A, A] instances to compare certain types A with themselves for equality, we can simply derive them:

1
2
3
4
scala> final case class Bar() derives Eql

scala> Bar() == Bar()
val res3: Boolean = true

It is worth noting, that Eql is not used at runtime – the actual equality comparison is still carried out through calling good old equals.

Also noteworthy, we can still compare objects of different type for equality. All we need to do is provide the necessary given Eql instances – we need two, one for each “direction”:

1
2
3
4
5
6
7
8
9
10
11
scala> given Eql[Foo, Bar] = Eql.derived
def given_Eql_Foo_Bar: Eql[Foo, Bar]

scala> given Eql[Bar, Foo] = Eql.derived
def given_Eql_Bar_Foo: Eql[Bar, Foo]

scala> Foo() == Bar()
val res4: Boolean = false

scala> Bar() == Foo()
val res5: Boolean = false

The Scala standard library provides some of these bidirectional Eql instances for numeric types:

1
2
3
4
5
scala> 1L == 1
val res6: Boolean = true

scala> 1 == 1.0
val res7: Boolean = true

If backwards compatibility is needed, we simply not use the strictEquality language feature. This leads to the compiler providing the needed given Eql instance – if necessary – by using the eqlAny method, which itself is not defined as given:

1
def eqlAny[L, R]: Eql[L, R] = Eql.derived