4
votes

I'm having a problem modelling our domain with immutable objects.

Mutable design

One base trait WObject (world object) and loads of traits for implementing specific operations like OwnedObj (hp/owner/takeDamage), Movable (movementLeft/moveTo), Fighter (attacked/attack).

At the end of the hierarchy you have a mutable class which mixes in the appropriate traits:

class Corvette(var position: Vect2) extends WObject with OwnedObj with Movable with Fighter

If a user wants to do an operation (lets say move a ship), you do:

val opt = objects.collectFirst { case obj: Movable if obj.position == position => obj }
opt.fold(Log.error(s"Movable at $position not found!")) { obj =>
  obj.moveTo(position) // return type is Unit
}

Immutable design

If moveTo has to return a new object, what type does it return?

I've tried using trait Movable[Self <: Movable[Self]] approach, but this requires to take Movable[_] everywhere and those existential types get out of hand quickly. What if I want Movable[_] with Fighter[_]? Are _ the same type?

I've also tried abstract type Self inside the traits with type bounds approach, but this starts to get hairy in following scenarios:

def takeDamage(obj: OwnedObj): obj.Self = if (Random.nextDouble()) obj.takeDamage else obj.self

Nest this a bit and you get types like

  def attackReachable(
    data: WObject.WorldObjUpdate[Self]
  ): WObject.WorldObjUpdate[data.value._2.Self]

Which are just horrible.

I was thinking about ditching inheritance and using composition + typeclasses, but I'm not quite sure how to do it.

For example:

case class WObject(position: Vect2, id: UUID=UUID.randomUUID())
case class OwnedObj(owner: Owner)
case class Movable(movementLeft: Int)
case class Fighter(attacked: Boolean)
case class Corvette(obj: WObject, owned: OwnedObj, movable: Movable, fighter: Fighter)

// Something that has both WObject and Movable
trait MovableOps[A <: ???] {
  def moveTo(obj: A, target: Vect2): A
}

And then define operations in typeclasses, which would be implemented in Corvette companion object.

But I am not sure how to specify the restrains.

More over how do you implement the move operation from client side?

val opt = objects.collectFirst { case obj: ??? if obj.position == position => obj }
opt.fold(Log.error(s"Movable at $position not found!")) { obj =>
  objects = objects - obj + obj.moveTo(position)
}

Help appreciated :)

Related: Polymorphic updates in an immutable class hierarchy

1

1 Answers

3
votes

You can write your "same _ in with" case using an existential: (Movable[T] with Fighter[T] forSome {type T}).

If I've understood your attackReachable example correctly, I wouldn't worry about the path-dependent types too much. You can usually allow them to be inferred, and the concrete calls will have "actual" types. Strategic use of implicit =:= or Leibniz parameters where you know the types are actually the same can stop things getting out of hand. Or more simply, you can just require that the type be the same:

def doSomething[T <: Moveable { type Self = T }](t: T): T =
  t.somethingThatReturnsTDotSelf()

If you want to go the composition route, the nicest approach I can think of is using shapeless Lenses (I can't compare with monocle Lenses as I haven't used them):

trait Move[A] {
  val lens: Lens[A, (WObject, Movable)]
}
/** This could be implicitly derived with Generic if you really want to -
or you could use Records. */
implicit def moveCorvette = new Move[Corvette] {
  val lens = lens[Corvette].obj ~ lens[Corvette].movable
}

def moveTo[A: Move](obj: A, target: Vect2) = {
  val l = Lens[A, (Wobject, Movable)]
  val remainingMoves = l.get(obj)._2.movementLeft - 1
  l.set(obj)((target, remainingMoves))
}

To apply this to a list, you either keep the list as a HList so that you know the types of all the elements (e.g. your list is of type Fighter :: Corvette :: HNil), or include the evidence in the list entries with an existential (e.g. trait ObjAndMove {type T; val obj: T; val evidence: Move[T]} and then use a List[ObjAndMove])