In Scala 3, what's the recommended method to define an abstract method for case constructor?

68 Views Asked by At

Scala 2 has several "back door rules" that were never documented or explained, but they are used in many open-sourced projects and even becomes their own patterns, one of such rules is the fact that case class's companion object has the same type signature of a unary or nullary function, and can be used in overriding properties of the super-types of its outer type:

  {
    //  unary function (without eta-expansion) overriden by unary case class constructor

    trait A {
      type CC
      def CC: Int => CC // only works in Scala 2
    }

    object AA extends A {

      case class CC(v: Int) {}
    }
  }

  {
    //  nullary function (without eta-expansion) overriden by nullary case class constructor

    trait A {
      type CC
      def CC: () => CC // only works in Scala 2
    }

    object AA extends A {

      case class CC() {}
    }
  }

In Scala 3, this rule was revoked, and no substitutes were proposed (tested with Scala 3.3 nightly):

[Error] /home/peng/git/dottyspike/src/main/scala/com/tribbloids/spike/dotty/CaseClassOverridingRule.scala:54:18: error overriding method CC in trait A of type => Int => com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC;
  object CC has incompatible type

Explanation
===========
I tried to show that
  object com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC
conforms to
  => Int => com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC
but none of the attempts shown below succeeded:

  ==> object com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC  <:  => Int => com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC class dotty.tools.dotc.core.Types$CachedTypeRef class dotty.tools.dotc.core.Types$CachedExprType  = false

The tests were made under the empty constraint

[Error] /home/peng/git/dottyspike/src/main/scala/com/tribbloids/spike/dotty/CaseClassOverridingRule.scala:69:18: error overriding method CC in trait A of type => () => com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC;
  object CC has incompatible type

Explanation
===========
I tried to show that
  object com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC
conforms to
  => () => com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC
but none of the attempts shown below succeeded:

  ==> object com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC  <:  => () => com.tribbloids.spike.dotty.CaseClassOverridingRule.AA.CC class dotty.tools.dotc.core.Types$CachedTypeRef class dotty.tools.dotc.core.Types$CachedExprType  = false

Most migration tools for Scala 3 won't rewrite it. As I result, I have to revise the above example manually, and here is the only working case:

  {
    //  unary function (without eta-expansion) overriden by unary case class constructor

    trait A {
      type CC
      def CC: { def apply(v: Int): CC }
    }

    object AA extends A {

      case class CC(v: Int) {}
    }
  }

  {
    //  nullary function (without eta-expansion) overriden by nullary case class constructor

    trait A {
      type CC
      def CC: { def apply(): CC }
    }

    object AA extends A {

      case class CC() {}
    }
  }

This is not an ideal substitute, defining CC in structural refined types causes JVM to verify it with slow reflection in runtime, the verification is also incomplete due to type erasure. In addition, refined types are not fully understood in DOT calculus and Scala compiler may change its behaviour in later versions.

If I need to migrate a library that uses the above rule, what type signature should I use?

1

There are 1 best solutions below

3
Silvio Mayolo On

Would you accept a typeclass-based solution?

We can define your desired trait, not as a traditional Java-style interface, but as a typeclass that takes a type parameter T and requires the CC type and constructor.

trait A[T] {
  type CC

  extension(self: T) def CC(n: Int): CC
}

Now we just write our singleton AA with no inheritance.

object AA {
  case class CC(v: Int) {}
}

We could manually implement the typeclass at this point.

given A[AA.type] with
  type CC = AA.CC
  extension(self: AA.type) def CC(n: Int) = AA.CC(n)

But that sounds like boilerplate, and if there's one thing Scala devs hate, it's boilerplate. So instead, let's try this.

import scala.deriving.Mirror

given [T, Aux](using
  sub: T <:< { type CC = Aux },
  mirror: Mirror.ProductOf[Aux],
  paramsSub: mirror.MirroredElemTypes <:< Tuple1[Int],
): A[T] with
  type CC = Aux
  extension(self: T) def CC(n: Int): CC =
    mirror.fromProduct(Tuple(n))

That's a mouthful. Let's break that down. We're writing a generic implementation of A[T] for any T that satisfies some basic constraints. Those constraints correspond to our contextual arguments, and they are:

  • sub: T must be a subtype of { type CC }, where we give the name Aux to the inner type CC so we can refer to it here. That is, T must have a type called CC defined on it. Note that structural types that are only used for type-level resolution such as this one do not have any overhead, unlike the ones you propose, which do incur a runtime reflection penalty as you noted.
  • mirror: The inner type Aux (or CC as it was originally called) must be a product type. That's either a tuple or a case class, as far as I know.
  • paramsSub: The constituents inside of that product type must be a single integer. That is, Aux must contain exactly one integer.

Once we have those constraints, we implement A[T]. We define type CC to be Aux, our auxiliary type. Then we write our constructor function. The mirror object I summoned a minute ago gives us the ability to construct our instance from a product, so if we have a correctly-typed tuple, we can make our instance.

Example usage:

def makeZero[T](t: T)(using a: A[T]): a.CC =
  t.CC(0)

@main def main() = {
  val x: AA.CC = makeZero(AA)
  println(x) // Prints CC(0)
}

Scala playground link