This trick enables overloading `==` for opaque types

One of the reasons I was still using value classes is that you cannot override the default == and != operator definitions and to also extend Selectable. After seeing this trick Should extension methods works with Dynamic trait? - #2 by Jasper-M by @Jasper-M, it turns out you can use this concept also to define your own == and !=.

trait FooEq:
  inline def == (foo2: Foo): Boolean = 
    println(s"Did you know that $this == $foo2 ?")
    true

opaque type Foo <: FooEq = Int & FooEq
object Foo:
  def apply(int: Int): Foo = int.asInstanceOf[Foo]

Foo(1) == Foo(2)

You’re breaking the spec with that asInstanceOf. It is out of spec because your value isn’t actually an instance of FooEq. So one day type inference will decide to swap the & (for example) and you’ll get a ClassCastException.

How is different when using type tagging in Scala 2 where we had with ?
I’m not using any information from FooEq.

It was just as bad in theory, but since with was not commutative in Scala 2, the chance that you would run into an actual CCE was reduced. Also usually the tag would be an abstract type erasing to Any, which made the likelihood of a problem in practice drop to 0.

I guess now the important question is, should we allow overloading == for opaque types ?
(And only then discuss the specific mechanism)

Really, why not?

Maybe the proper mechanism is to remove == and != from Any altogether (or make them protected) and implement them as extension methods on Any. That will enable declaring more specific extension methods on opaque types.

Because now you have an overload of == that does not behave like the inherited Any.==, so if you happen to upcast to Any, or use in a generic context that uses ==, you get a different behavior.

That’s usually not a very nice design of overloads. We should prefer different names for methods that do different things. Overloads are good for methods that do the same thing with different types of arguments.

2 Likes

I don’t see == to be any different than + or /. Sometimes it can mean equals, and according to context it can mean something else.
The fact that == can just work between any objects before strict equality is a code-smell to me. I would have expected that strict equality would have manifested as if == does not belong to the class and an extension method could have been applicable.

1 Like

Shouldn’t the compiler flag it, then?

No. asInstanceOf is entirely on you. You’re telling the compiler: even if you can’t prove that the value has that type, trust me, it has. If you then break the trust you’re asking of the compiler, it’s on you.

+ and / do not hide any inherited definition of + or / that behaves differently when given the same argument, though. So I don’t see how they’re relevant here.

It might be a smell but that’s what we have in Scala. Fighting those semantics is only going to bring more pain, not relieve it.

What I meant was that there are languages that + is defined and works everywhere as string concatenation.In my point of view, if + existed in Any, then it does not make sense to limit its overloading just the same.

Honestly, I kinda like the idea, but I’m not sure that it’s possible

Going outside of what’s possible to change, I’m less and less convinced that dynamic dispatch is a good thing
For me, everything should be an extension method (i.e. maintaining data and operations separate in the back-end, even if the syntax allows them to be declared together)

Dynamic dispatch is often abused. It should satisfy Liskov’s substitution principle. Some useful cases:

  1. Take advantage of additional structure to optimize the computation.
  2. Return a refined object, of a subtype of the original type, but indistinguishable according to the original type.

equals and toString, in Java, and thus in Scala, are well-known violations of that principle. There should have been type classes instead. But I thought it was way too late for Scala to get rid of those…

Wouldn’t “everything [being] an extension method” go against Scala’s uniform access principle? Methods representing virtual attributes of an object seem natural to me. For typical operations, on the other hand, I would tend to agree.

This would clearly not be a change that’s possible to make, and I hope I didn’t imply it would be

They are less and less to me, what does it matter if the code for method bar is located alongside foo or somewhere with the rest of the code, as long as I can call foo.bar and it executes the code

Hmm, looking at the spec, I don’t understand why the code doesn’t crash already.

def asInstanceOf[A]: A = this match {
  case x: A => x
  case _ => if (this eq null) this
            else throw new ClassCastException()
}

The spec of asInstanceOf, as written there, has been wrong for more than a decade. I haven’t gotten around to update it yet. There are much simpler cases that show how broken it is, like

val x: List[Int] = 1 :: Nil
val y: List[String] = x.asInstanceOf[List[Int]] // "succeeds", against the spec

I see. Though your example seems fine with the spec, even when changing x.asInstanceOf[List[Int]] to x.asInstanceOf[List[String]], since matches only happen against the erased type.

// Simulates asInstanceOf from the spec.
def asInstOf[A](o: Any): A = o match
  case x: A => x
  case _ => throw new ClassCastException()

val x: List[Int] = 1 :: Nil
val y: List[String] = asInstOf[List[String]](x) // Succeeds

It would have been much nicer if == and != were not members of Any and Object but extension methods. Then they would compete against all other overloaded extension methods, which would naturally produce the best types.

Can we still make the change? Not sure. == and != are currently final methods, so we don’t need to worry about overrides. But we would need to worry about changes in overloading resolution since before no extension method could displace the predefined members, unless its result type was different from Boolean. On the other hand, that was a strong incitement not to write such extension methods, so maybe the breakage will be quite small.

There’s the argument that we should not try to make usage of == more convenient since anyway as overloaded methods these don’t support abstraction. I.e. say you have your own implementation of == on Foo arguments. That’s all good, but when you create a Set[Foo], its contains method will use equals instead of the specialized Foo. That’s true, but

  • It’s already the situation today, and stll people use overloaded ==.
  • You could very well create extension methods that are less ad hoc because they are backed by some Eq typeclass. It won’t solve the problem with existing Sets but it could work for new classes.

So overall, I see little downside in making == and != extension methods on Any instead of final methods.

Binary compatibility can also be handled.

  • In the bytecode these universal == and != methods are anyway replaced by equals, so no problem there.
  • For Tasty backwards compatiblity we can make the existing methods private[Any]. That prevents them from being selected but does not constitute a binary breaking change (or so I am told).

Maybe someone would like to try this out and report back?

2 Likes

Is this actually true? I’ve never seen overloaded == in the wild. Every library I know which needs something similar uses ===

2 Likes