Can we get rid of cooperative equality?

I think that would be not ideal but still admissible. Essentially it says that you can’t rely on Any#== to have a particular behavior when called on values of mixed numeric types.

Because it’s bad API design? Because it’s super confusing? Using the “overloaded method” argument is not enough to explain away unnecessary surprising behavior.

Let me put the other way around: what would be the argument in favor of still allowing 1 == 1L to pass the typechecker, if it would otherwise be inconsistent with (1: Any) == (1L: Any)?

If you consider a compile error too hard a breakage, let’s have it still return true but let’s deprecate it, on the grounds that it is inconsistent with the upcast version. Basically deprecating a bad API.

5 Likes

Question (pardon my ignorance): Why is == and != different than say a + operation?
The expected behavior I want is:

1 == 1L //fails compilation
1 + 1L  //fails compilation
1 == 1  //true
1 + 1   //2

If we want the first two examples to work, then we can introduce an implicit conversion into the scope.

2 Likes

Shouldn’t boxing be transparent in Scala?

In what sense is it irregular?


I’m with Sébastien: == should return the same result for primitives and boxed types. So to preserve that and improve performance we should explore the idea of 1 != 1L, or 1 == 1L being a type error.

4 Likes

(1: Any).equals(1L: Any) yielding false is completely surprising to me. I expected Scala’s == to have the same semantics as Java’s Object#equals, always and without exception. For an Int, I’d expect == to be java.lang.Integer.equals.

I’m for this change.

I have the impression the discussion got derailed. I did not propose 1 != 1L and in fact would strongly object to this. To show why co-operative equality is irregular even if it looks regular at first, let me simplify the question to some synthetic classes ANY, A, and B with a === method:

  class ANY {
    def ===(that: ANY) = this eq that
  }

  case class A(x: String) extends ANY {
    def ===(that: A) = this.x == that.x
    def ===(that: B) = this.x == that.x
  }

  case class B(x: String) extends ANY {
    def ===(that: A) = this.x == that.x
    def ===(that: B) = this.x == that.x
  }

  val a = A("")
  val b = B("")

  a == b   // --> true
  (a: ANY) == (b: ANY)  // --> false

That’s what we would expect from Scala’s behavior. The point is, === is an overloaded method so the static types on which it is called matter. It also means that boxing is visible because the static types change. If === was a multi-method it would give true also for the second time, but Scala does not have multi-methods. On the other hand, for ==, we treat it as if it was a multi-method, or, rather, as if it had an extra-ordinarily complex and expensive implementation which makes it look like it is a multi-method for some types, but not for others, where we still use the overloaded behavior. This is what’s irregular about it.

1 Like

It’s actually more like a factor of two on a fair comparison. I did these tests when creating AnyRefMap; switching from cooperative to non-cooperative equality (as possible when things are typed as AnyRef) saves about a factor of two in speed. Using primitives directly gives about another factor of two (hence LongMap), but that isn’t a fair comparison because we’re talking about the behavior of Any.

Absolutely! The opaqueness of boxing of numbers is the source of endless Java puzzlers. Intuitively, the number one is the number one, regardless of whether it happens to be stored in a 32 bit integer or a 64 bit floating point value or a byte. It’s just one. Because users can create their own numeric type (e.g. Rational) with their own representation of one, it is not practical to maintain “one is one” universally. But it’s still a huge and worthwhile simplification of the cognitive model needed for dealing with numbers.

This is an implementation detail, presumably for speed. It needn’t be done this way. The various equalsXYZ methods in scala.runtime can handle any comparison.

The current treatment is expensive, but makes numbers more regular than they would be otherwise, thus avoiding a class of bugs that people run into in Java.

Fundamentally, as long as we have weak conformance and such around numbers, it’s profoundly inconsistent to allow 1L + 1 but not say 1L == 1 is both valid and returns true.

Rust, for example, has decided to disallow all of these: you cannot write 1u64 + 1u32 or 1u64 == 1u32. This is consistent and reduces the chance of error, but is also something of a hassle. (Unadorned numeric literals will conform to the type expected to avoid making it much too much of a hassle.) But Rust has no top type, so there is no expectation that (1L: Any) == (1: Any) behaves the same as 1L == 1.

So if cooperative equality were removed, I think equality on Any would have to go away entirely.

4 Likes

Basically, since Java primitives behave differently from Java boxed
numbers, we can’t have comparisons between different numeric types that
satisfy all three of these:

(1) Scala unboxed numbers behave like Java primitives
(2) Scala boxed numbers behave like Java boxed numbers
(3) Scala unboxed numbers behave like Scala boxed numbers

It is difficult to have good JVM performance unless Scala numbers behave
like Java numbers. Scala boxed and unboxed being different sounds insane.

The only sane and efficient option seems to be, as has been suggested, to
deprecate comparisons between different numeric types and instead require
conversion to larger types, like Long and Double. Since these days almost
every platform is 64 bit, Long and Double are natively efficient.

Side note: comparing floating points to anything is pure evil. It can only
be forgiven in rare circumstances, such as emulating a language that does
not have integer types, like JavaScript.

4 Likes

I would simply argue that === defined in this way is a poor API because it does not conform to the intuitive notion of sameness.

When concepts are different, it’s a good idea to use different method names.

class Confusing {
  def buh(s: String) = Try{ (new File(s)).delete }.isSuccess
  def buh(f: File) = f.exists
}

(In fact, I’d suggest that this example is a good argument against allowing overloaded method names.)

2 Likes

If you happen to not like this change, another way to think about it is:

The current way is odd and surprising.
The changed way is odd and surprising, but it’s faster.

1 Like

Can you post a REPL transcript of the odd and surprising behavior? (With ==, not equals?)

Can you post a REPL transcript of the odd and surprising behavior? (With
==, not equals?)

scala> class A(val x: String) { def ==(that: A) = this.x == that.x }
defined class A
scala> val a = new A("")
val a: A = A@23b3aa8c
scala> val b = new A("")
val b: A = A@338cc75f
scala> a == b
val res2: Boolean = true
scala> (a: Any) == (b: Any)
val res3: Boolean = false
scala>

The example shows that == is an overloaded method, and behaves like one.
Except for numeric types
where we magically make it a multi-method.

1 Like

I would simply argue that === defined in this way is a poor API because it does not conform to the intuitive notion of sameness.

Poor API or not, that’s how == is defined! And there are many good reasons for that, starting with performance. Imagine if all primitive == comparisons delegated to Any

So are we suggesting that overloading be turned off for ==? Right now best practice would be (barring an extra canEqual check) to:

class A(val x: String) { def equals(a: Any) = a match {
  case a2: A => x == a2.x
  case _    => false
}

which doesn’t have the odd and surprising behavior you demonstrated. At least a linter should by default complain if one overloads == in that way.

So are we suggesting that overloading be turned off for ==? Right now best practice would be (barring an extra canEqual check) to:

No, the opposite. Keep overloading but don’t treat numeric types specially. I.e.

(1: Any) != (1L: Any)

just like

(a: Any) != (b: Any)

in my example.

Btw we cannot turn overloading off for ==. Scala has no way to achieve this. We can turn overriding of by making == final (and it is!) but that does not prevent us from overloading it.

But people don’t overload equals that way for the most part; they do it like case classes do, precisely so that they avoid the confusing behavior that equality is not preserved by upcasting.

But people don’t overload equals that way for the most part; they do it like case classes do, precisely so that they avoid the confusing behavior that equality is not preserved by upcasting.

Correct. I believe this illustrates well the different concerns here. I see at least three:

  1. What is an intuitive meaning of == from an API perspective?
  2. What is the cleanest way to express == from a semantics perspective?
  3. What is the most straightforward and efficient implementation?

I thought initially that (1) and (2) were aligned, that we needed co-operative equality to hide boxing for generics. I am now convinced that (2) and (3) are aligned. The only way to approach == in the Scala context is as an overloaded method and that means co-operative equality only muddles the picture. The seeming inconsistencies we see are not due to == specifically but due to the fact that Scala has overloading as opposed to multi-methods. The same inconsistencies can be constructed for any other overloaded method, including == on other types.

I agree that (2) and (3) align better than (1) and (2) do.

But how much do (2) and (3) matter if we can’t have (1)?

We already have a perfectly sensible method that can be used to align (2) and (3) in those special cases where (1) is not a primary concern: equals. The question is whether == should lose its special status in attempting to achieve (1).

(Also, as an aside, def foo(final a: Any) could be added as syntax that forbids overloading of method arguments.)

Multi-methods are handy; I appreciate them in Julia, for instance. But I’m not convinced yet that a best-effort manual emulation of them to preserve the intuitive meaning of == isn’t worthwhile. Yes, it’s a hassle. But it’s an up-front hassle that simplifies all later thinking about how to use equality, which for scalability is a nice win since you can concentrate on more relevant things than the difference between intuitive == and the actual behavior in the face of the diversity of numeric types.

1 Like

Shouldn’t the signature of == be

def ==(other: Any)

instead of

def ==(other: A)