scalagenericstypesscala-3type-projection

Is there a relationship between an inner type hierarchy and the component type projections?


In the example below, is it possible to write updateShape with the proper constraints so I don't need the three casts, the two in updateShape and the one at the call site?

trait ShapeModule:
  module =>
  type D

  trait Shape:
    def id: String
    override def toString =
      s"${module.getClass.getSimpleName()}>${getClass.getSimpleName}:$id"

  class ShapeStart(val id: String, val dims: D) extends Shape:
    def area: Double = module.area(dims)
    def next: ShapeFinished = ShapeFinished(id, dims)

  class ShapeFinished(id: String, dims: D) extends ShapeStart(id, dims):
    override def area: Double = super.area + 10.0
    def restart: ShapeStart = ShapeStart(id, dims)

  def area(dims: D): Double

type ShapeBase = ShapeModule#Shape

case class Paper[S <: ShapeBase](shapes: Seq[S]):
  def updateShape[SS <: S, ST <: ShapeBase, T <: ShapeBase](
      id: String,
      f: SS => ST
  ): Paper[T] =
    val nextShapes =
      shapes.map(s => if s.id == id then f(s.asInstanceOf[SS]) else s)
    copy(shapes = nextShapes.asInstanceOf[Seq[T]])

object Circle extends ShapeModule:
  type D = Double
  def area(radius: Double) = Math.PI * radius * radius

object Rect extends ShapeModule:
  type D = (Double, Double)
  def area(dims: (Double, Double)) =
    val (w, h) = dims
    w * h

val circleStart = Circle.ShapeStart("C1", 2.0)
val rectStart = Rect.ShapeStart("R1", (2.0, 3.0))

val paperStart = Paper(Seq(circleStart, rectStart))
val paperFinish = paperStart.copy(
  paperStart.shapes.map(_.next)
)

val paperStart2 = paperFinish.updateShape("C1", _.restart)
paperStart2.asInstanceOf[Paper[ShapeModule#ShapeStart]].shapes.map(_.area)

All three type parameters in updateShape would ideally be more specific. It seems there are two type hierarchies in this example, one in the inner Shape > ShapeStart > ShapeFinished hierarchy, and one described with type projections across all modules (e.g. ShapeModule#ShapeStart). Is there any relationship between these two that can be used to describe updateShape? Are there good blogs/papers on the subject? Thanks.


Solution

  • Since ShapeStart has def area and ShapeFinished has def area, it makes sense to add def area to Shape, doesn't it?

    In if s.id == id then f(s) else s there's not much sense to consider f: S => S1 because then-branch returns S1 while else-branch returns S, so totally if-then-else returns the parent type ShapeBase (or S | S1). So it's enough to have f: S => ShapeBase, anyway .map returns Seq[ShapeBase] (or Seq[S | S1]).

    Otherwise if you want to return different subtypes from different branches then you need to know at compile time whether s.id == id i.e. to move id to type level. If you want to return different subtypes for different elements of the collection then Seq is not a proper data type, you need heterogeneous list (Tuple) and polymorphic function.

    I tried to minimize the number of generics:

    trait ShapeModule:
      module =>
      type D
    
      trait Shape:
        def id: String
        def area: Double
        override def toString =
          s"${module.getClass.getSimpleName}>${getClass.getSimpleName}:$id"
    
      class ShapeStart(val id: String, val dims: D) extends Shape:
        def area: Double = module.area(dims)
        def next: ShapeFinished = ShapeFinished(id, dims)
    
      class ShapeFinished(id: String, dims: D) extends ShapeStart(id, dims):
        override def area: Double = super.area + 10.0
        def restart: ShapeStart = ShapeStart(id, dims)
    
      def area(dims: D): Double
    
    type ShapeBase = ShapeModule#Shape
    
    case class Paper[S <: ShapeBase](shapes: Seq[S]):
      def updateShape(id: String, f: S => ShapeBase): Paper[ShapeBase] =
        val nextShapes: Seq[ShapeBase] =
          shapes.map(s => if s.id == id then f(s) else s)
        copy(shapes = nextShapes)
    
    object Circle extends ShapeModule:
      type D = Double
      def area(radius: Double) = Math.PI * radius * radius
    
    object Rect extends ShapeModule:
      type D = (Double, Double)
      def area(dims: (Double, Double)) =
        val (w, h) = dims
        w * h
    
    val circleStart = Circle.ShapeStart("C1", 2.0)
    val rectStart = Rect.ShapeStart("R1", (2.0, 3.0))
    
    val paperStart: Paper[ShapeModule#ShapeStart] = Paper(Seq(circleStart, rectStart))
    val paperFinish: Paper[ShapeModule#ShapeFinished] = paperStart.copy(
      paperStart.shapes.map(_.next)
    )
    
    val paperStart2: Paper[ShapeBase] = paperFinish.updateShape("C1", _.restart)
    paperStart2.shapes.map(_.area)
    

    The reason to have more generics is additional type safety, but additional type safety is an illusion if you have to cast.

    Or we can move the default implementation of area to Shape if we add def dims to Shape since both ShapeStart and ShapeFinished have def dims

    trait Shape:
      def id: String
      def dims: D
      def area: Double = module.area(dims)
    
      override def toString =
        s"${module.getClass.getSimpleName}>${getClass.getSimpleName}:$id"
    
    class ShapeStart(val id: String, val dims: D) extends Shape:
      def next: ShapeFinished = ShapeFinished(id, dims)
    
    class ShapeFinished(id: String, dims: D) extends ShapeStart(id, dims):
      override def area: Double = super.area + 10.0
      def restart: ShapeStart = ShapeStart(id, dims)
    

    If you can't add area to Shape because there can be other inheritors of Shape without area then here is an implementation with union types

    trait ShapeModule:
      module =>
      type D
    
      trait Shape:
        def id: String
        override def toString =
          s"${module.getClass.getSimpleName}>${getClass.getSimpleName}:$id"
    
      class ShapeStart(val id: String, val dims: D) extends Shape:
        def area: Double = module.area(dims)
        def next: ShapeFinished = ShapeFinished(id, dims)
    
      class ShapeFinished(id: String, dims: D) extends ShapeStart(id, dims):
        override def area: Double = super.area + 10.0
        def restart: ShapeStart = ShapeStart(id, dims)
    
      def area(dims: D): Double
    
    type ShapeBase = ShapeModule#Shape
    
    case class Paper[S <: ShapeBase](shapes: Seq[S]):
      def updateShape[S1 <: ShapeBase](id: String, f: S => S1): Paper[S | S1] =
        val nextShapes: Seq[S | S1] =
          shapes.map(s => if s.id == id then f(s) else s)
        copy(shapes = nextShapes)
    
    object Circle extends ShapeModule:
      type D = Double
      def area(radius: Double) = Math.PI * radius * radius
    
    object Rect extends ShapeModule:
      type D = (Double, Double)
      def area(dims: (Double, Double)) =
        val (w, h) = dims
        w * h
    
    val circleStart = Circle.ShapeStart("C1", 2.0)
    val rectStart = Rect.ShapeStart("R1", (2.0, 3.0))
    
    val paperStart: Paper[ShapeModule#ShapeStart] = Paper(Seq(circleStart, rectStart))
    val paperFinish: Paper[ShapeModule#ShapeFinished] = paperStart.copy(
      paperStart.shapes.map(_.next)
    )
    
    val paperStart2: Paper[ShapeModule#ShapeStart /*ShapeModule#ShapeStart | ShapeModule#ShapeFinished*/] =
      paperFinish.updateShape("C1", _.restart)
    paperStart2.shapes.map(_.area)
    

    Since ShapeFinished extends ShapeStart, ShapeModule#ShapeStart | ShapeModule#ShapeFinished is just ShapeModule#ShapeStart.


    Since you apply updateShape to _.restart : ShapeFinished => ShapeStart i.e. S=ShapeFinished, S1=ShapeStart, ShapeFinished <: ShapeStart, we can add bound S1 >: S, then S | S1 = S1

    case class Paper[S <: ShapeBase](shapes: Seq[S]):
      def updateShape[S1 >: S <: ShapeBase](id: String, f: S => S1): Paper[S1] =
        val nextShapes: Seq[S1] =
          shapes.map(s => if s.id == id then f(s) else s)
        copy(shapes = nextShapes)
    

    Maybe that's what you were looking for.

    Most general signature is

    case class Paper[S <: ShapeBase](shapes: Seq[S]):
      def updateShape[SS >: S /*<: ShapeBase*/,
                      ST /*<: ShapeBase*/,
                      T >: (SS | ST) <: ShapeBase
                     ](
        id: String,
        f: SS => ST
      ): Paper[T] =
        val nextShapes: Seq[T] =
          shapes.map(s => if s.id == id then f(s) else s)
        copy(shapes = nextShapes)
    

    But then you'll have to specify the type of lambda: (_: ShapeModule#ShapeFinished).restart instead of just _.restart.