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.
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
.