tribbloid
tribbloid

Reputation: 3838

In Scala 3, what's the canonical method for pattern match that uses an erased type?

Here is a simple example:


object MatchErasedType {

  trait Supe {
    self: Singleton =>

    type T1
    lazy val default: T1

    def process(v: Any): T1 = {
      v match {
        case vv: T1 => vv
        case _ => default
      }
    }
  }
}

It will throw a compiler warning:

MatchErasedType.scala:13:14: the type test for Supe.this.T1 cannot be checked at runtime

The case is interesting as any instance of Supe is guaranteed to be a Singleton. So a delayed reification of process won't have any erasure. As a result, this question actually entails 2 different cases:

UPDATE 1: It should be noted that the type of v: Any cannot be known in compile-time, the call-site won't be able to provide such information. Henceforth, This question is NOT for cases when T1 cannot be resolved to a concrete class and/or runtime condition.

Upvotes: 0

Views: 328

Answers (1)

Dmytro Mitin
Dmytro Mitin

Reputation: 51658

In Scala 2

trait Supe { self: Singleton =>
  type T1 <: Singleton
  def default: T1

  def process(v: Any): T1 =
    v match {
      case vv: T1 => println(1); vv
      case _      => println(2); default
    }
}

object Impl1 extends Supe {
  override type T1 = Impl1.type
  override lazy val default: T1 = this
}

object Impl2 extends Supe {
  override type T1 = Impl2.type
  override lazy val default: T1 = this
}

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 1

To overcome type erasure, in Scala 2 we use scala.reflect.ClassTag. Either (approach 1)

trait Supe { self: Singleton =>
  type T1 <: Singleton
  def default: T1

  def process(v: Any)(implicit ct: ClassTag[T1]): T1 =
    v match {
      case vv: T1 => println(1); vv
      case _      => println(2); default
    }
}

object Impl1 extends Supe {
  override type T1 = Impl1.type
  override lazy val default: T1 = this
}

object Impl2 extends Supe {
  override type T1 = Impl2.type
  override lazy val default: T1 = this
}

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 2

or (approach 2)

trait Supe { self: Singleton =>
  type T1 <: Singleton
  def default: T1

  implicit def ctag: ClassTag[T1]

  def process(v: Any): T1 =
    v match {
      case vv: T1 => println(1); vv
      case _      => println(2); default
    }
}

object Impl1 extends Supe {
  override type T1 = Impl1.type
  override lazy val default: T1 = this
  override implicit def ctag: ClassTag[T1] = {
    val ctag = null // hiding implicit by name, otherwise implicitly[...] returns the above ctag
    implicitly[ClassTag[T1]]
  }
}

object Impl2 extends Supe {
  override type T1 = Impl2.type
  override lazy val default: T1 = this
  override implicit def ctag: ClassTag[T1] = {
    val ctag = null // hiding implicit by name, otherwise implicitly[...] returns the above ctag
    implicitly[ClassTag[T1]]
  }
}

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 2

Similarly, in Scala 3

trait Supe { self: Singleton =>
  type T1 <: Singleton
  lazy val default: T1

  def process(v: Any): T1 =
    v match {
      case vv: T1 => println(1); vv
      case _      => println(2); default
    }
}

object Impl1 extends Supe {
  override type T1 = Impl1.type
  override lazy val default: T1 = this
}

object Impl2 extends Supe {
  override type T1 = Impl2.type
  override lazy val default: T1 = this
}

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 1

In Scala 3 scala.reflect.Typeable (scala.reflect.TypeTest) is used instead of ClassTag. The approach 1 works

trait Supe { self: Singleton =>
  type T1 <: Singleton
  lazy val default: T1

  def process(v: Any)(using Typeable[T1]): T1 =
    v match {
      case vv: T1 => println(1); vv
      case _      => println(2); default
    }
}

object Impl1 extends Supe {
  override type T1 = Impl1.type
  override lazy val default: T1 = this
}

object Impl2 extends Supe {
  override type T1 = Impl2.type
  override lazy val default: T1 = this
}

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 2

Regarding the approach 2, in Scala 3 there is no way to hide implicit by name, so let's put it into a local scope so that it will not be found in object implicit scope

trait Supe { self: Singleton =>
  type T1 <: Singleton
  lazy val default: T1

  def tt: Typeable[T1]

  def process(v: Any): T1 = {
    given Typeable[T1] = tt

    v match {
      case vv: T1 => println(1); vv
      case _      => println(2); default
    }
  }
}

object Impl1 extends Supe {
  override type T1 = Impl1.type
  override lazy val default: T1 = this
  override def tt: Typeable[T1] = summon
}

object Impl2 extends Supe {
  override type T1 = Impl2.type
  override lazy val default: T1 = this
  override def tt: Typeable[T1] = summon
}

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 2

I still think given or using could be avoided in all instances. Either method requires a lot of boilerplate, particularly if the pattern matching contains a lot of union or intersection types

In your last example, adding every new case using types like T1 with Int or T1 {def v: Int} necessitates a Typeable. This is infeasible in many cases.

ClassTag, TypeTag, shapeless.Typeable/TypeCase (Scala 2), TypeTest/Typeable, Shapeless-3 Typeable (Scala 3) are standard tools to overcome type erasure. Matching types at runtime is not typical for pattern matching. If your business logic is based on types then maybe you don't need pattern matching at all, maybe type classes would be a better choice

trait Supe:
  self: Singleton =>

  type T1 <: Singleton
  lazy val default: T1

  trait Process[A]:
    def process(a: A): T1

  trait LowPriorityProcess:
    given low[A]: Process[A] with
      def process(a: A): T1 = { println(2); default }
  object Process extends LowPriorityProcess:
    given [A <: T1]: Process[A] with
      def process(a: A): T1 = { println(1); a }

  def process[A: Process](v: A): T1 =
    summon[Process[A]].process(v)

object Impl1 extends Supe:
  override type T1 = Impl1.type
  override lazy val default: T1 = this

object Impl2 extends Supe:
  override type T1 = Impl2.type
  override lazy val default: T1 = this

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 2

or

trait Process[A, T1 <: Singleton]:
  def process(a: A, default: T1): T1

trait LowPriorityProcess:
  given low[A, T1 <: Singleton]: Process[A, T1] with
    def process(a: A, default: T1): T1 =
      {println(2); default}
object Process extends LowPriorityProcess:
  given[A <: T1, T1 <: Singleton]: Process[A, T1] with
    def process(a: A, default: T1): T1 =
      {println(1); a }

trait Supe:
  self: Singleton =>

  type T1 <: Singleton
  lazy val default: T1

  def process[A](v: A)(using p: Process[A, T1]): T1 =
    p.process(v, default)

object Impl1 extends Supe:
  override type T1 = Impl1.type
  override lazy val default: T1 = this

object Impl2 extends Supe:
  override type T1 = Impl2.type
  override lazy val default: T1 = this

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 2

adding every new case using types like T1 with Int or T1 {def v: Int} necessitates a Typeable. This is infeasible in many cases.

trait Supe:
  self: Singleton =>

  type T1 <: Singleton
  lazy val default: T1

  def tt[S <: T1]: Typeable[S]

  def process(v: Any): T1 =
    given Typeable[T1] = tt
    given tt1: Typeable[T1 with SomeTrait] = tt
    given tt2: Typeable[T1 {def v: Int}] = tt
    //...

    v match
      case vv: T1 => {println(1); vv}
      case _      => {println(2); default}

trait SomeTrait

object Impl1 extends Supe:
  override type T1 = Impl1.type with SomeTrait
  override lazy val default: T1 = this.asInstanceOf[T1]
  override def tt[S <: T1]: Typeable[S] = summon[Typeable[S @unchecked]]

object Impl2 extends Supe:
  override type T1 = Impl2.type {def v: Int}
  override lazy val default: T1 = this.asInstanceOf[T1]
  override def tt[S <: T1]: Typeable[S] = summon[Typeable[S @unchecked]]

Impl1.process(Impl1) // 1
Impl1.process(Impl2) // 2

Upvotes: 1

Related Questions