Pre-SIP: relaxed `strictEquality` for `==` and `!=`

Recently, SIP-67 was implemented and released in Scala 3.8. I think it does a fantastic job of what it set out to do:

This proposal aims to make the strictEquality feature easier to adopt by making pattern matching against singleton cases (e. g. Nil or None) work even when the relevant sealed or enum type does not (or cannot) have a derives CanEqual clause.

I enabled strictEquality on a codebase where it wasn’t previously set, compiled (to a bunch of new errors), enabled strictEqualityPatternMatching, and saw the number of compile errors drastically reduced.

In the remaining errors, I see an opportunity; can we apply similar rules to == and !=?

Using the example from the previous SIP:

import scala.language.strictEquality

enum Nat:
  case Zero
  case Succ(n: Nat)

  def +(that: Nat): Nat =
    this match
      case Nat.Zero => that
      case Nat.Succ(x) => Nat.Succ(x + that)

Suppose I only care about Zero and don’t care about other cases:

  def +(that: Nat): Int =
    this match
      case Nat.Zero => that
      case _ => ???

Now, since I only care about the case object, I feel like I should be able to rewrite it as the equivalent (I think?):

  def +(that: Nat): Int =
    if this == Nat.Zero then that
    else ???

However, that produces the same

[error] 41 |    if this == Nat.Zero then that
[error]    |       ^^^^^^^^^^^^^^^^
[error]    |Values of Nat and Nat cannot be compared with == or !=
[error] one error found

that the SIP example code produces without strictEqualityPatternMatching:

[error] ./nat.scala:9:10
[error] Values of types Nat and Nat cannot be compared with == or !=
[error]     case Nat.Zero => r
[error]          ^^^^^^^^

Here are a few more examples, taken from the codebase I’m working on:

In a collection of ZIO Json objects, find the first one that isn’t a null json value and do something with it:

[error] 142 |        sources.find(_ != Json.Null) match {
[error]     |                     ^^^^^^^^^^^^^^
[error]     |Values of types zio.json.ast.Json and object zio.json.ast.Json.Null cannot be compared with == or !=

Have a helper method that determines if an app is in dev mode:

sealed trait Mode
case object Dev  extends Mode
case object Prod extends Mode

final case class AppModeConfig(mode: Mode, layers: Map[String, Mode]) {
  def isDev: Boolean = mode == Dev
}
[error] 36 |  def isDev: Boolean = mode == Dev
[error]    |                       ^^^^^^^^^^^
[error]    |Values of types com.connectstrata.libs.core.Mode and object com.connectstrata.libs.core.Dev cannot be compared with == or !=

I’m aware that in both cases, I can avoid manual CanEqual instances now by using pattern matching with the new language flag (and in the case of Mode, I can trivially derive CanEqual, or add an isDev method to the trait and implement it in each subclass), but these are still extra hurdles to adopting strictEquality (which I think is a great feature aside from how tedious it is to use, especially in large pre-existing codebases).

3 Likes

I think this makes sense. The current state of affairs is that the language pushes people to rewrite perfectly good code like

if a == Foo.Bar then
  foo
else
  bar

to

a match
  case Foo.Bar =>
    foo
  case _ =>
    bar

It’s hard to see how this helps anyone. And worse than that, it might steer people towards adding unnecessary derives CanEqual clauses to avoid this ugliness. If this kind of equality check is safe within a match, then it’s safe in an == expression as well.

Given that your motivation is practical experience with the feature, could you share some numbers? How many errors did you get with plain strictEquality, how many with strictEqualityPatternMatching, and how many of the latter could be eliminated with your proposed feature?

(I wrote and implemented the strictEqualityPatternMatching feature)

1 Like

Isn’t it just spelled eq?

The motivation for pattern matching was that case _: Nil.type => is awkward.

Also, the test in the match is Zero == this.

With strictEquality: 17

With strictEquality and experimental.strictEqualityPatternMatching: 5

Of those 5:

  • 3 would be resolved with the proposed change
  • 1 would be resolved with relaxed equality for opaque types, something like opaque type Foo = String; (“1”: Foo) == (“2”: Foo). I feel like for an opaque type Foo = T, if a CanEqual can be derived for T, most of the time it’s desirable to also have one for Foo
  • 1 would be resolved with relaxed equality for case classes (and more generally any sealed hierarchy of case classes/objects) where all elements of the case class can have a CanEqual derived. In my specific code, I have a CORS filter that accepts a CORS Allowed Origin via config, converts it to a zio.http.Header.Origin, and compares it to the Origin in the request. It would be nice to be able to compare Origins without having to explicitly derive a CanEqual for them. The library defines Origin like this:
sealed trait Origin extends Header
case object Null extends Origin
final case class Value(scheme: String, host: String, port: Option[Int] = None) extends Origin

Can you elaborate on this:

Isn’t it just spelled eq?

If what you mean by that is “just use eq instead of ==", I rarely see/use eq, and when I do, it’s because I specifically want reference equality, rather than because I’m trying to bypass (what I see as) a compiler shortcoming.

Additionally, using eq as a workaround here defeats the whole purpose of strictEquality; the compiler won’t warn if you do 1 eq a.

I would have said that eq expresses exactly what is intended for a singleton.

An object might implement an arbitrary equals. I meant to add that if you know it doesn’t override equals, then of course == is OK.

I think they intended to reinstate some checks that weren’t ported.

Scala 2 will say

scala> 1 eq x
         ^
       warning: comparing values of types Integer and String using `eq` will always yield false
val res0: Boolean = false

I see quite a lot of eq in Dotty, such as sym eq NoSymbol. I no longer think of it as an optimization, and Dotty also uses == for it.

eq may incur parens, as in while p1.validFor == Nowhere && (p1 ne p2).

1 Like

I disagree, I think it makes a lot of sense for people to be wary of eq, as you rarely want to think about references in Scala

So encouraging it for the specific case of objects, just to avoid a limitation of strict equality seems like the wrong approach

1 Like

That’s fair!

However, strictEquality doesn’t apply to eq, so again, it would defeat the whole purpose here.

That is objectively true, but

  1. it is relatively uncommon to override equals, and
  2. I would argue something like override def equals(other: Object): Boolean = false is a bug / incorrect, even though it’s valid code; it should at the very least be this eq other (and I can’t think of a case where that would be undesired)

Is strictEquality the vehicle “to reinstate some checks that weren’t ported”, or will it be something else?

Taking a couple steps back, I became interested in this topic after shipping some code that was comparing two unrelated types. When I found the issue, I was shocked (coming from Scala 2, I expected to receive the compiler warning about comparing unrelated types that you showed above!). I can’t remember what exactly led me to strictEquality (I think maybe it was trying to get scapegoat or scalafix to surface the error, or some other page somewhere that said the language flag has to be enabled?), but I was disappointed with how unruly CanEqual was and how much work I had to put in (on an existing codebase), simply for the compiler to give me a warning on 1 == “a”.

Maybe the aim of strictEquality is not really what I’m after; what I’m after is more or less as you said “to reinstate some checks that weren’t ported”. I’d love for some compiler setting/flag that basically does something as simple as (is it simple?):

Given an expression a == b, if the closest common supertype (least upper bound?) of a and b is AnyRef or anything higher (Any), then generate a warning.

Is that the same way it was implemented in Scala 2? Is this the relevant/related code?

P.S. I also think it’s “annoying” that with strictEquality disabled:

UUID.randomUUID() == “foo” produces Values of types java.util.UUID and String cannot be compared with == or != (because there’s a CanEqual available for String)

UUID.randomUUID() == OffsetDateTime.now happily compiles

As an extension (though I’m not entirely sure where I’m going with this…), again assuming strictEquality is disabled, and given some T that should be comparable with other Ts, deriving CanEqual will give you equality protection any time T is compared.

However, if you have some U that cannot be compared and thus should NOT derive CanEqual, it seems there’s no way to get that protection (without globally enabling strictEquality).

2 Likes

I think all this shows is that the standard library should come with CanEqual instances for more JDK types, starting with UUID and various java.time classes. Then you’d get a little more safety by default and less hassle if you globally enable strictEquality.

3 Likes