Quest for composable immutable objects

(in Scala 2.11.5)

Artūras Šlajus

 

arturas@tinylabproductions.com

@arturaz_

Problem statement

Problem domain: turn based strategy game world

 

  • Allow composing objects from parts.
  • Encode properties of world objects statically.
  • Allow putting objects into performant collections. (no HLists!)

Simplified domain

S#0: Mutability

World object trait

case class Vect2(x: Int, y: Int)

trait WObject {
  val id = UUID.randomUUID()

  def position = _position
  protected var _position: Vect2

  def hp = _hp
  protected var _hp: Int

  def takeDamage(damage: Int): Unit = {
    _hp = (hp - damage) max 0
  }
}

Movable trait

trait Movable extends WObject {
  def moved = _moved
  private[this] var _moved = false

  def moveTo(pos: Vect2): Unit =
    if (moved) throw new IllegalStateException(
      "Already moved this turn!"
    )
    else {
      _moved = true
      _position = pos
    }
}

Fighter trait

trait Fighter extends WObject {
  def hasAttacked = _hasAttacked
  private[this] var _hasAttacked = false

  def attack(target: WObject, damage: Int): Unit = {
    if (hasAttacked)
      throw new IllegalStateException(
        "Already attacked this turn!"
      )
    else {
      _hasAttacked = true
      target.takeDamage(damage)
    }
  }
}

Defining objects

class Rock(var _position: Vect2, var _hp: Int)
  extends WObject
class Scout(var _position: Vect2, var _hp: Int)
  extends Movable
class Gunship(var _position: Vect2, var _hp: Int)
  extends Movable with Fighter
class LaserTower(var _position: Vect2, var _hp: Int)
  extends Fighter

val rock = new Rock(Vect2(1, 1), 10)
val scout = new Scout(Vect2(1, 2), 20)
val gunship = new Gunship(Vect2(1, 3), 50)
val laserTower = new LaserTower(Vect2(1, 4), 200)

val objects = collection.mutable.Set(
  rock, scout, gunship, laserTower
)

Operation that needs one trait

def move(from: Vect2, to: Vect2): Boolean = {
  val objOpt = objects.collectFirst {
    case obj: Movable if obj.position == from => obj
  }
  objOpt.foreach(_.moveTo(to))
  objOpt.isDefined
}

Operation that needs two traits

def moveAndAttack(
  from: Vect2, target: WObject, damage: Int
): Boolean = {
  val objOpt = objects.collectFirst {
    case obj: Movable with Fighter 
    if obj.position == from => 
      obj
  }
  objOpt.foreach { obj =>
    obj.moveTo(target.position)
    obj.attack(target, damage)
  }
  objOpt.isDefined
}

Mutable approach

Pros

Cons

  • Pretty short.
  • Can combine a lot of traits easily.
  • It is mutable!
  • Uncontrollable side effects.
  • Sneaky exceptions.
/* What do these methods do? */

def takeDamage(damage: Int): Unit
def moveTo(pos: Vect2): Unit
def attack(target: WObject, damage: Int): Unit

/* What happens if I want to return a log of events? */

def takeDamage(damage: Int): Seq[Event]
def moveTo(pos: Vect2): Seq[Event]
def attack(target: WObject, damage: Int): Seq[Event]

/* But nobody is listening! */

Purely functional approach

  • No in-place state mutation.
  • All objects are immutable.
  • All functions are pure.
def takeDamage(damage: Int): ???
def moveTo(pos: Vect2): ???
def attack[Target <: WObject](target: Target, damage: Int)
  : (???, Target)

/* What happens if I want to return a log of events? */

type Evented[A] = (A, Seq[Event])

def takeDamage(damage: Int): Evented[???]
def moveTo(pos: Vect2): Evented[???]
def attack[Target <: WObject](target: Target, damage: Int)
  : Evented[(???, Target)]

/* Compiler tells us where to fix things. */

Purely functional approach

The question is: what is ???

 

AKA MyType problem

Given a trait where some operation must return a new object of the same type, how do we express it?

def takeDamage(damage: Int): ???
def moveTo(pos: Vect2): ???
def attack[Target <: WObject](target: Target, damage: Int)
  : (???, Target)

S#1: F-Bounded Polymorphism

World Object trait

case class Vect2(x: Int, y: Int)

trait WObject[Self <: WObject[Self]] {
  // Properties could be bundled into objects 
  // and manipulated with lenses.
  def id: UUID
  def position: Vect2
  def hp: Int

  def withHp(hp: Int): Self

  def takeDamage(damage: Int): Self = withHp((hp - damage) max 0)
}

Movable trait

trait Movable[Self <: Movable[Self]] extends WObject[Self] {
  def moved: Boolean

  def withPosition(position: Vect2): Self
  def withMoved(moved: Boolean): Self

  def moveTo(pos: Vect2): Either[String, Self] =
    if (moved) Left("Already moved this turn!")
    // F-Bound proves useful here
    else Right(withMoved(true).withPosition(pos))
}
object Movable {
  val InitialMoved = false
}

Fighter trait

trait Fighter[Self <: Fighter[Self]] extends WObject[Self] {
  def hasAttacked: Boolean

  def withHasAttacked(hasAttacked: Boolean): Self

  def attack[Target <: WObject[Target]](
    target: Target, damage: Int
  ): Either[String, (Self, Target)] =
    if (hasAttacked) Left("Already attacked this turn!")
    else Right((withHasAttacked(true), target.takeDamage(damage)))
}
object Fighter {
  val InitialHasAttacked = false
}

Defining objects

def uuid = UUID.randomUUID()

case class Rock(
  position: Vect2, hp: Int, id: UUID=uuid
) extends WObject[Rock] {
  def withHp(hp: Int) = copy(hp = hp)
}

case class Scout(
  position: Vect2, hp: Int, 
  moved: Boolean=Movable.InitialMoved, id: UUID=uuid
) extends Movable[Scout] {
  def withMoved(moved: Boolean) = copy(moved = moved)
  def withPosition(position: Vect2) = copy(position = position)
  def withHp(hp: Int) = copy(hp = hp)
}

case class Gunship(
  position: Vect2, hp: Int,
  moved: Boolean=Movable.InitialHasMoved,
  hasAttacked: Boolean=Fighter.InitialHasAttacked, id: UUID=uuid
) extends Movable[Gunship] with Fighter[Gunship] {
  def withPosition(position: Vect2) = copy(position = position)
  def withMoved(moved: Boolean) = copy(moved = moved)
  def withHasAttacked(hasAttacked: Boolean) = copy(hasAttacked = hasAttacked)
  def withHp(hp: Int) = copy(hp = hp)
}

case class LaserTower(
  position: Vect2, hp: Int, 
  hasAttacked: Boolean=Fighter.InitialHasAttacked, id: UUID=uuid
) extends Fighter[LaserTower] {
  def withHasAttacked(hasAttacked: Boolean) = copy(hasAttacked = hasAttacked)
  def withHp(hp: Int) = copy(hp = hp)
}

val rock = Rock(Vect2(1, 1), 10)
val scout = Scout(Vect2(1, 2), 20)
val gunship = Gunship(Vect2(1, 3), 50)
val laserTower = LaserTower(Vect2(1, 4), 200)

The problems start

val objects: Set[WObject[A] forSome { type A <: WObject[A] }] =
  Set(rock, scout, gunship, laserTower)

You need an existential type to specify the type of collection precisely.

But can't you just type it as Set[WObject[_]]?

Yes, it is that horrible.

scala> val objects: Set[WObject[_]] = Set(rock, scout, gunship, laserTower)

// NOPE!
scala> objects.head.takeDamage(1).takeDamage(1)
<console>:29: error: value takeDamage is not a member of _$1
              objects.head.takeDamage(1).takeDamage(1)
                                         ^

And there's more!

// Operation that needs one trait.
def move(from: Vect2, to: Vect2): Either[
  String, (Movable[A] forSome { type A <: Movable[A] })
] = {
  val objOpt = objects.collectFirst {
    case obj: Movable[_] if obj.position == from => obj
  }
  objOpt.toRight(s"Can't find movable @ $from").right.flatMap(_.moveTo(to))
}

Let's try to write an operation!

Seems legit, right?

(if we ignore the horrible return type signature)

"HA, HA, FU!" - sincerely yours, the compiler.

[error] /home/arturas/work/playground/src/main/scala/playground/FBounded.scala:88: no type parameters for method flatMap: (f: playground.FBounded.WObject[_$1] with playground.FBounded.Movable[_(in method applyOrElse)] forSome { type _$1; type _(in method applyOrElse) <: playground.FBounded.Movable[_(in method applyOrElse)] } => scala.util.Either[AA,Y])scala.util.Either[AA,Y] exist so that it can be applied to arguments (playground.FBounded.WObject[_$1] with playground.FBounded.Movable[_(in method applyOrElse)] forSome { type _$1; type _(in method applyOrElse) <: playground.FBounded.Movable[_(in method applyOrElse)] } => scala.util.Either[String,(some other)_(in method applyOrElse)] forSome { type (some other)_(in method applyOrElse) <: playground.FBounded.Movable[(some other)_(in method applyOrElse)] })
[error]  --- because ---
[error] argument expression's type is not compatible with formal parameter type;
[error]  found   : playground.FBounded.WObject[_$1] with playground.FBounded.Movable[_(in method applyOrElse)] forSome { type _$1; type _(in method applyOrElse) <: playground.FBounded.Movable[_(in method applyOrElse)] } => scala.util.Either[String,(some other)_(in method applyOrElse)] forSome { type (some other)_(in method applyOrElse) <: playground.FBounded.Movable[(some other)_(in method applyOrElse)] }
[error]  required: playground.FBounded.WObject[_$1] with playground.FBounded.Movable[_(in method applyOrElse)] forSome { type _$1; type _(in method applyOrElse) <: playground.FBounded.Movable[_(in method applyOrElse)] } => scala.util.Either[?AA,?Y]
[error]   objOpt.toRight(s"Can't find movable @ $from").right.flatMap(_.moveTo(to))
[error]                                                       ^
[error] /home/arturas/work/playground/src/main/scala/playground/FBounded.scala:88: type mismatch;
[error]  found   : playground.FBounded.WObject[_$1] with playground.FBounded.Movable[_(in method applyOrElse)] forSome { type _$1; type _(in method applyOrElse) <: playground.FBounded.Movable[_(in method applyOrElse)] } => scala.util.Either[String,(some other)_(in method applyOrElse)] forSome { type (some other)_(in method applyOrElse) <: playground.FBounded.Movable[(some other)_(in method applyOrElse)] }
[error]  required: playground.FBounded.WObject[_$1] with playground.FBounded.Movable[_(in method applyOrElse)] forSome { type _$1; type _(in method applyOrElse) <: playground.FBounded.Movable[_(in method applyOrElse)] } => scala.util.Either[AA,Y]
[error]   objOpt.toRight(s"Can't find movable @ $from").right.flatMap(_.moveTo(to))
[error]                                                                       ^
[error] /home/arturas/work/playground/src/main/scala/playground/FBounded.scala:88: type mismatch;
[error]  found   : scala.util.Either[AA,Y]
[error]  required: Either[String,playground.FBounded.Movable[A] forSome { type A <: playground.FBounded.Movable[A] }]
[error]   objOpt.toRight(s"Can't find movable @ $from").right.flatMap(_.moveTo(to))
[error]                                                              ^
[error] three errors found
[error] (compile:compile) Compilation failed

In case you are not impressed

Lets write an operation that needs a compound type!

// Operation that needs two traits.
def moveAndAttack[Target <: WObject[Target]](
  from: Vect2, target: Target, damage: Int
): Either[
  String,
  (
    Movable[A] with Fighter[A]
        forSome { type A <: Movable[A] with Fighter[A] },
    Target
  )
] = {
  val objOpt: Option[
    Movable[A] with Fighter[A]
        forSome { type A <: Movable[A] with Fighter[A] }
  ] = objects.collectFirst {
    // Generics are erased at runtime, can't match on concrete type.
    case obj: Movable[_] with Fighter[_] if obj.position == from => obj
  }
  objOpt.toRight(s"Can't find movable+fighter @ $from")
    .right.flatMap(_.moveTo(target.position))
    .right.flatMap(obj /* obj is Any here :( */ => ???)
}

Why do you hate me?

[error] /home/arturas/work/playground/src/main/scala/playground/FBounded.scala:107: type mismatch;
[error]  found   : playground.FBounded.WObject[_$1] with playground.FBounded.Movable[_] with playground.FBounded.Fighter[_]
[error]  required: playground.FBounded.Movable[A] with playground.FBounded.Fighter[A] forSome { type A <: playground.FBounded.Movable[A] with playground.FBounded.Fighter[A] }
[error]     case obj: Movable[_] with Fighter[_] if obj.position == from => obj
[error]                                                                     ^
[error] /home/arturas/work/playground/src/main/scala/playground/FBounded.scala:110: no type parameters for method flatMap: (f: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] forSome { type A(in value objOpt) <: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] } => scala.util.Either[AA,Y])scala.util.Either[AA,Y] exist so that it can be applied to arguments (playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] forSome { type A(in value objOpt) <: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] } => scala.util.Either[String,(some other)A(in value objOpt)] forSome { type (some other)A(in value objOpt) <: playground.FBounded.Movable[(some other)A(in value objOpt)] with playground.FBounded.Fighter[(some other)A(in value objOpt)] })
[error]  --- because ---
[error] argument expression's type is not compatible with formal parameter type;
[error]  found   : playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] forSome { type A(in value objOpt) <: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] } => scala.util.Either[String,(some other)A(in value objOpt)] forSome { type (some other)A(in value objOpt) <: playground.FBounded.Movable[(some other)A(in value objOpt)] with playground.FBounded.Fighter[(some other)A(in value objOpt)] }
[error]  required: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] forSome { type A(in value objOpt) <: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] } => scala.util.Either[?AA,?Y]
[error]     .right.flatMap(_.moveTo(target.position))
[error]            ^
[error] /home/arturas/work/playground/src/main/scala/playground/FBounded.scala:110: type mismatch;
[error]  found   : playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] forSome { type A(in value objOpt) <: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] } => scala.util.Either[String,(some other)A(in value objOpt)] forSome { type (some other)A(in value objOpt) <: playground.FBounded.Movable[(some other)A(in value objOpt)] with playground.FBounded.Fighter[(some other)A(in value objOpt)] }
[error]  required: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] forSome { type A(in value objOpt) <: playground.FBounded.Movable[A(in value objOpt)] with playground.FBounded.Fighter[A(in value objOpt)] } => scala.util.Either[AA,Y]
[error]     .right.flatMap(_.moveTo(target.position))
[error]                            ^
[error] three errors found
[error] (compile:compile) Compilation failed

Enough is enough

This relationship is not good for you.

It's not going to work out.

Stop sobbing and let it go.

It is time to move on...

S#2: Abstract Type Members

World Object trait

case class Vect2(x: Int, y: Int)

trait WObject {
  type Self <: WObject

  def id: UUID
  def position: Vect2
  def hp: Int

  def withHp(hp: Int): Self

  def takeDamage(damage: Int): Self = withHp((hp - damage) max 0)
}

Movable trait

trait Movable extends WObject {
  type Self <: Movable

  def moved: Boolean

  def withPosition(position: Vect2): Self
  def withMoved(moved: Boolean): Self

  def moveTo(pos: Vect2): Either[String, Self] =
    if (moved) Left("Already moved this turn!")
    else Right(withMoved(true).withPosition(pos))
}
object Movable {
  val InitialMoved = false
}

Fighter trait

trait Fighter extends WObject {
  type Self <: Fighter

  def hasAttacked: Boolean

  def withHasAttacked(hasAttacked: Boolean): Self

  // Use type refinement to only allow types, where Self is Target.
  def attack[Target <: WObject { type Self = Target }](
    target: Target, damage: Int
  ): Either[String, (Self, Target)] =
    if (hasAttacked) Left("Already attacked this turn!")
    else Right((withHasAttacked(true), target.takeDamage(damage)))
}
object Fighter {
  val InitialHasAttacked = false
}

Defining objects

def uuid = UUID.randomUUID()

case class Rock(
  position: Vect2, hp: Int, id: UUID=uuid
) extends WObject {
  type Self = Rock
  def withHp(hp: Int) = copy(hp = hp)
}

case class Scout(
  position: Vect2, hp: Int,
  moved: Boolean=Movable.InitialMoved, id: UUID=uuid
) extends Movable {
  type Self = Scout
  def withMoved(moved: Boolean) = copy(moved = moved)
  def withPosition(position: Vect2) = copy(position = position)
  def withHp(hp: Int) = copy(hp = hp)
}

case class Gunship(
  position: Vect2, hp: Int, 
  moved: Boolean=Movable.InitialMoved,
  hasAttacked: Boolean=Fighter.InitialHasAttacked, id: UUID=uuid
) extends Movable with Fighter {
  type Self = Gunship
  def withPosition(position: Vect2) = copy(position = position)
  def withMoved(moved: Boolean) = copy(moved = moved)
  def withHasAttacked(hasAttacked: Boolean) = copy(hasAttacked = hasAttacked)
  def withHp(hp: Int) = copy(hp = hp)
}

case class LaserTower(
  position: Vect2, hp: Int, 
  hasAttacked: Boolean=Fighter.InitialHasAttacked, id: UUID=uuid
) extends Fighter {
  type Self = LaserTower
  def withHasAttacked(hasAttacked: Boolean) = copy(hasAttacked = hasAttacked)
  def withHp(hp: Int) = copy(hp = hp)
}

val rock = Rock(Vect2(1, 1), 10)
val scout = Scout(Vect2(1, 2), 20)
val gunship = Gunship(Vect2(1, 3), 50)
val laserTower = LaserTower(Vect2(1, 4), 200)

val objects: Set[WObject] = Set(rock, scout, gunship, laserTower)

Operation that needs one trait

def move(from: Vect2, to: Vect2): Either[String, Movable] = {
  val objOpt = objects.collectFirst {
    case obj: Movable if obj.position == from => obj
  }
  objOpt.toRight(s"Can't find movable @ $from")
    .right.flatMap(_.moveTo(to))
}

Operation that needs two traits

def moveAndAttack[Target <: WObject { type Self = Target }](
  from: Vect2, target: Target, damage: Int
): Either[String, (Movable with Fighter, Target)] = {
  val objOpt = objects.collectFirst {
    case obj: Movable with Fighter 
    if obj.position == from => 
      obj
  }
  objOpt.toRight(s"Can't find movable+fighter @ $from")
    .right.flatMap(_.moveTo(target.position))
    .right.flatMap(_.attack(target, damage))
}

Is this it?

It looks pretty great! Lets compile!

two errors found :|

[error] /home/arturas/work/playground/src/main/scala/playground/AbstractTypeMember.scala:35: type mismatch;
[error]  found   : Movable.this.Self#Self
[error]  required: Movable.this.Self
[error]     else Right(withMoved(true).withPosition(pos))
[error]                                            ^
[error] /home/arturas/work/playground/src/main/scala/playground/AbstractTypeMember.scala:109: type mismatch;
[error]  found   : Either[String,(x$3.Self, Target)]
[error]  required: scala.util.Either[String,(playground.AbstractTypeMember.Movable with playground.AbstractTypeMember.Fighter, Target)]
[error]     .right.flatMap(_.attack(target, damage))
[error]                            ^
[error] two errors found
[error] (compile:compile) Compilation failed

:|

Lets analyze

[error] /home/arturas/work/playground/src/main/scala/playground/AbstractTypeMember.scala:35: type mismatch;
[error]  found   : Movable.this.Self#Self
[error]  required: Movable.this.Self
[error]     else Right(withMoved(true).withPosition(pos))

What is happening here?

Basically Movable#Self doesn't have to be same object as far as compiler is concerned.

Do not trust me?

scala> :paste
// Entering paste mode (ctrl-D to finish)

case class Evil1(
  position: Vect2, hp: Int, id: UUID=uuid
) extends WObject {
  type Self = Evil2
  def withHp(hp: Int) = Evil2(position, hp, id)
}
case class Evil2(
  position: Vect2, hp: Int, id: UUID=uuid
) extends WObject {
  type Self = Evil1
  def withHp(hp: Int) = Evil1(position, hp, id)
}

// Exiting paste mode, now interpreting.

defined class Evil1
defined class Evil2

scala> Evil1(Vect2(1,1), 10).withHp(9)
res0: Evil2 = Evil2(Vect2(1,1),9,3e05e82c-11f0-466a-83c8-0e4ebb1e9d6a)

scala> res0.withHp(8)
res1: Evil1 = Evil1(Vect2(1,1),8,3e05e82c-11f0-466a-83c8-0e4ebb1e9d6a)

// I know! Isn't this great?

Why does it happen?

Basically nothing in the type definition of

trait WObject {
  type Self <: WObject
}

implies that Self must be of the same type.

But... this is fixable.

trait WObject { self =>
  type Self <: WObject { type Self = self.Self }
  // or type Self >: this.type <: WObject
}
// And so on.

Now the second error is different

[error] /home/arturas/work/playground/src/main/scala/playground/AbstractTypeMember.scala:122: type mismatch;
[error]  found   : Either[String,(_8.Self, Target)]
[error]  required: scala.util.Either[String,(playground.AbstractTypeMember.Movable with playground.AbstractTypeMember.Fighter, Target)]
[error]     .right.flatMap(_.attack(target, damage))
[error]                            ^
[error] one error found
[error] (compile:compile) Compilation failed

Turns out when you combine traits, the types lose their "magical" properties.

A dirty workaround is possible

// Operation that needs two traits.
def moveAndAttack[Target <: WObject { type Self = Target }](
  from: Vect2, target: Target, damage: Int
): Either[String, (Movable with Fighter, Target)] = {
  val objOpt = objects.collectFirst {
    case obj: Movable with Fighter if obj.position == from => obj
  }
  objOpt.toRight(s"Can't find movable+fighter @ $from")
    .right.flatMap(_.moveTo(target.position))
    .right.flatMap(_.attack(target, damage))
    .right.map { case (self, target) =>
      (/* !!! */ self.asInstanceOf[Movable with Fighter] /* !!! */, target)
    }
}

But these tend to get ugly when you have deeply nested data structures.

.map(_.right.map(_.map(_.map(_.asInstanceOf[...])))) anyone?

We could introduce a new trait

trait MovableFighter extends Movable with Fighter { self =>
  type Self <: MovableFighter { type Self = self.Self }
}

// Operation that needs two traits.
def moveAndAttack[Target <: WObject { type Self = Target }](
  from: Vect2, target: Target, damage: Int
): Either[String, (MovableFighter, Target)] = {
  val objOpt = objects.collectFirst {
    case obj: MovableFighter if obj.position == from => obj
  }
  objOpt.toRight(s"Can't find movable+fighter @ $from")
    .right.flatMap(_.moveTo(target.position))
    .right.flatMap(_.attack(target, damage))
}

But now every combination of traits needs another type.

And once we create it we have not to forget to add it to every class which has these traits. This gets messy, fast.

We can do better

S#3: Typeclasses

World Object trait

case class Vect2(x: Int, y: Int)

// This is a nifty helper for using curried functions!
implicit class AnyExts[A](val a: A) extends AnyVal {
  // `foo(3)(bar(4)(a))` vs `a |> bar(4) |> foo(3)`
  @inline def |>[B](f: A => B) = f(a)
}

sealed trait WObject {
  def id: UUID
  def position: Vect2
  def hp: Int
}

trait WObjectOps[Self <: WObject] {
  def withHp(hp: Int)(self: Self): Self
  def takeDamage(damage: Int)(self: Self): Self =
    self |> withHp((self.hp - damage) max 0)
}

Movable trait

sealed trait Movable extends WObject {
  def moved: Boolean
}
object Movable {
  val InitialMoved = false
}

trait MovableOps[Self <: Movable] extends WObjectOps[Self]  {
  def withPosition(position: Vect2)(self: Self): Self
  def withMoved(moved: Boolean)(self: Self): Self

  def moveTo(pos: Vect2)(self: Self): Either[String, Self] =
    if (self.moved) Left("Already moved this turn!")
    else Right(self |> withMoved(true) |> withPosition(pos))
}

Fighter trait

sealed trait Fighter extends WObject {
  def hasAttacked: Boolean
}
object Fighter {
  val InitialHasAttacked = false
}

trait FighterOps[Self <: Fighter] extends WObjectOps[Self] {
  def withHasAttacked(hasAttacked: Boolean)(self: Self): Self

  def attack[Target <: WObject]
  (target: Target, damage: Int)(self: Self)
  (implicit tc: WObjectOps[Target])
  : Either[String, (Self, Target)] =
    if (self.hasAttacked)
      Left("Already attacked this turn!")
    else
      Right((
        self |> withHasAttacked(true), 
        target |> tc.takeDamage(damage)
      ))
}

Defining objects

def uuid = UUID.randomUUID()

case class Rock(
  position: Vect2, hp: Int, id: UUID=uuid
) extends WObject
implicit object RockOps extends WObjectOps[Rock] {
  def withHp(hp: Int)(self: Rock) = self.copy(hp = hp)
}

case class Scout(
  position: Vect2, hp: Int, 
  moved: Boolean=Movable.InitialMoved, id: UUID=uuid
) extends Movable
implicit object ScoutOps extends MovableOps[Scout] {
  def withPosition(pos: Vect2)(self: Scout) = self.copy(position = pos)
  def withMoved(moved: Boolean)(self: Scout) = self.copy(moved = moved)
  def withHp(hp: Int)(self: Scout) = self.copy(hp = hp)
}

case class Gunship(
  position: Vect2, hp: Int, 
  moved: Boolean=Movable.InitialMoved,
  hasAttacked: Boolean=Fighter.InitialHasAttacked, id: UUID=uuid
) extends Movable with Fighter
implicit object GunshipOps 
extends MovableOps[Gunship] with FighterOps[Gunship] {
  def withPosition(pos: Vect2)(self: Gunship) = self.copy(position = pos)
  def withMoved(moved: Boolean)(self: Gunship) = self.copy(moved = moved)
  def withHasAttacked(v: Boolean)(self: Gunship) = self.copy(hasAttacked = v)
  def withHp(hp: Int)(self: Gunship) = self.copy(hp = hp)
}

case class LaserTower(
  position: Vect2, hp: Int, 
  hasAttacked: Boolean=Fighter.InitialHasAttacked, id: UUID=uuid
) extends Fighter
implicit object LaserTowerOps extends FighterOps[LaserTower] {
  def withHasAttacked(hasAttacked: Boolean)(self: LaserTower) =
    self.copy(hasAttacked = hasAttacked)
  def withHp(hp: Int)(self: LaserTower) = self.copy(hp = hp)
}

val rock = Rock(Vect2(1, 1), 10)
val scout = Scout(Vect2(1, 2), 20)
val gunship = Gunship(Vect2(1, 3), 50)
val laserTower = LaserTower(Vect2(1, 4), 200)

val objects: Set[WObject] = Set(rock, scout, gunship, laserTower)

Converting objects to ops

def toWObjectOps[A <: WObject](obj: A): WObjectOps[A] = (
  ((obj: WObject /* downcast for sealed pattern matching */) match {
    case o: Rock => RockOps
    case o: Scout => ScoutOps
    case o: Gunship => GunshipOps
    case o: LaserTower => LaserTowerOps
  }): WObjectOps[_] /* This ensures our return type is WObjectOps */
).asInstanceOf[WObjectOps[A]] /* We need this cast to turn _ into A */
// We have pattern matched all cases, thus this will never fail in runtime

def toMovableOps[A <: Movable](obj: A): MovableOps[A] =
  (((obj: Movable) match {
    case o: Scout => ScoutOps
    case o: Gunship => GunshipOps
  }): MovableOps[_]).asInstanceOf[MovableOps[A]]

def toFighterOps[A <: Fighter](obj: A): FighterOps[A] =
  (((obj: Fighter) match {
    case o: Gunship => GunshipOps
    case o: LaserTower => LaserTowerOps
  }): FighterOps[_]).asInstanceOf[FighterOps[A]]

Operation that needs one trait

def move(from: Vect2, to: Vect2): Either[String, Movable] = {
  val objOpt = objects.collectFirst {
    case obj: Movable if obj.position == from => obj
  }
  objOpt.toRight(s"Can't find movable @ $from")
    .right.flatMap(o => o |> toMovableOps(o).moveTo(to))
}

Operation that needs two traits

def moveAndAttack[Target <: WObject : WObjectOps](
  from: Vect2, target: Target, damage: Int
): Either[String, (Movable with Fighter, Target)] = {
  val objOpt = objects.collectFirst {
    case obj: Movable with Fighter 
    if obj.position == from => 
      obj
  }
  objOpt.toRight(s"Can't find movable+fighter @ $from")
    .right.flatMap { o => 
      o |> toMovableOps(o).moveTo(target.position)
    }.right.flatMap { o => 
      o |> toFighterOps(o).attack(target, damage)
    }
}

val target = objects.head
moveAndAttack(Vect2(1, 3), target, 10)(toWObjectOps(target))

Seems pretty good?

def move2(from1: Vect2, from2: Vect2, to: Vect2)
: Either[String, (Movable, Movable)] = {
  def find(pos: Vect2) = objects.collectFirst {
    case obj: Movable if obj.position == pos => obj
  }
  val (obj1Opt, obj2Opt) = (find(from1), find(from2))

  obj1Opt.toRight(s"Can't find movable @ $from1").right.flatMap { obj1 =>
  obj2Opt.toRight(s"Can't find movable @ $from2").right.flatMap { obj2 =>
    val ops1 = toMovableOps(obj1)
    val ops2 = toMovableOps(obj2)
    // Runtime error here.
    val moved1 = obj1 |> ops2.moveTo(to)
    // And here - can you spot why?
    val moved2 = obj2 |> ops1.moveTo(to)
    moved1.right.flatMap(m1 => moved2.right.map(m2 => (m1, m2)))
  } }
}

However it's a bit clunky and stupid errors can still happen.

Let's get rid of that.

S#4: Extension Typeclasses?

I am not really sure about the name

World Object trait

case class Vect2(x: Int, y: Int)

sealed trait WObject {
  def id: UUID
  def position: Vect2
  def hp: Int
}
object WObject {
  implicit def toOps[A <: WObject](obj: A): WObjectOps[A] =
    (((obj: WObject) match {
      case o: Rock => RockOps(o)
      case o: Scout => ScoutOps(o)
      case o: Gunship => GunshipOps(o)
      case o: LaserTower => LaserTowerOps(o)
    }): WObjectOps[_]).asInstanceOf[WObjectOps[A]]
}

trait WObjectOps[Self <: WObject] {
  def self: Self
  def withHp(hp: Int): Self
  def takeDamage(damage: Int): Self = withHp((self.hp - damage) max 0)
}

Movable trait

sealed trait Movable extends WObject {
  def moved: Boolean
}
object Movable {
  val InitialMoved = false
  implicit def toOps[A <: Movable](obj: A): MovableOps[A] =
    (((obj: Movable) match {
      case o: Scout => ScoutOps(o)
      case o: Gunship => GunshipOps(o)
    }): MovableOps[_]).asInstanceOf[MovableOps[A]]
}

trait MovableOps[Self <: Movable] extends WObjectOps[Self]  {
  import Movable.toOps

  def withPosition(position: Vect2): Self
  def withMoved(moved: Boolean): Self

  def moveTo(pos: Vect2): Either[String, Self] =
    if (self.moved) Left("Already moved this turn!")
    else Right(withMoved(true).withPosition(pos))
}

Fighter trait

sealed trait Fighter extends WObject {
  def hasAttacked: Boolean
}
object Fighter {
  val InitialHasAttacked = false
  implicit def toOps[A <: Fighter](obj: A): FighterOps[A] =
    (((obj: Fighter) match {
      case o: Gunship => GunshipOps(o)
      case o: LaserTower => LaserTowerOps(o)
    }): FighterOps[_]).asInstanceOf[FighterOps[A]]
}

trait FighterOps[Self <: Fighter] extends WObjectOps[Self] {
  import WObject.toOps

  def withHasAttacked(hasAttacked: Boolean): Self

  def attack[Target <: WObject](target: Target, damage: Int)
  : Either[String, (Self, Target)] =
    if (self.hasAttacked)
      Left("Already attacked this turn!")
    else
      Right((withHasAttacked(true), target.takeDamage(damage)))
}

Defining objects

def uuid = UUID.randomUUID()

case class Rock(
  position: Vect2, hp: Int, id: UUID=uuid
) extends WObject
case class RockOps(self: Rock) extends WObjectOps[Rock] {
  def withHp(hp: Int) = self.copy(hp = hp)
}

case class Scout(
  position: Vect2, hp: Int, 
  moved: Boolean=Movable.InitialMoved, id: UUID=uuid
) extends Movable
case class ScoutOps(self: Scout) extends MovableOps[Scout] {
  def withPosition(pos: Vect2) = self.copy(position = pos)
  def withMoved(moved: Boolean) = self.copy(moved = moved)
  def withHp(hp: Int) = self.copy(hp = hp)
}

case class Gunship(
  position: Vect2, hp: Int, 
  moved: Boolean=Movable.InitialMoved,
  hasAttacked: Boolean=Fighter.InitialHasAttacked, id: UUID=uuid
) extends Movable with Fighter
case class GunshipOps(self: Gunship)
extends MovableOps[Gunship] with FighterOps[Gunship] {
  def withPosition(pos: Vect2) = self.copy(position = pos)
  def withMoved(moved: Boolean) = self.copy(moved = moved)
  def withHasAttacked(v: Boolean) = self.copy(hasAttacked = v)
  def withHp(hp: Int) = self.copy(hp = hp)
}

case class LaserTower(
  position: Vect2, hp: Int, 
  hasAttacked: Boolean=Fighter.InitialHasAttacked, id: UUID=uuid
) extends Fighter
case class LaserTowerOps(self: LaserTower) extends FighterOps[LaserTower] {
  def withHasAttacked(hasAttacked: Boolean) =
    self.copy(hasAttacked = hasAttacked)
  def withHp(hp: Int) = self.copy(hp = hp)
}

val rock = Rock(Vect2(1, 1), 10)
val scout = Scout(Vect2(1, 2), 20)
val gunship = Gunship(Vect2(1, 3), 50)
val laserTower = LaserTower(Vect2(1, 4), 200)

val objects: Set[WObject] = Set(rock, scout, gunship, laserTower)

Operation that needs one trait

def move(from: Vect2, to: Vect2): Either[String, Movable] = {
  val objOpt = objects.collectFirst {
    case obj: Movable if obj.position == from => obj
  }
  objOpt.toRight(s"Can't find movable @ $from").right.flatMap(_.moveTo(to))
}

Operation that needs two traits

def moveAndAttack[Target <: WObject](
  from: Vect2, target: Target, damage: Int
): Either[String, (Movable with Fighter, Target)] = {
  val objOpt = objects.collectFirst {
    case obj: Movable with Fighter if obj.position == from => obj
  }
  objOpt.toRight(s"Can't find movable+fighter @ $from")
    .right.flatMap(_.moveTo(target.position))
    .right.flatMap(_.attack(target, damage))
}

val target = objects.head
moveAndAttack(Vect2(1, 3), target, 10)

Our potentially troubling case

def move2(from1: Vect2, from2: Vect2, to: Vect2)
: Either[String, (Movable, Movable)] = {
  def find(pos: Vect2) = objects.collectFirst {
    case obj: Movable if obj.position == pos => obj
  }
  val (obj1Opt, obj2Opt) = (find(from1), find(from2))

  obj1Opt.toRight(s"Can't find movable @ $from1").right.flatMap { obj1 =>
  obj2Opt.toRight(s"Can't find movable @ $from2").right.flatMap { obj2 =>
    for {
      m1 <- obj1.moveTo(to).right
      m2 <- obj2.moveTo(to).right
    } yield (m1, m2)
  } }
}

We can't mess it up no more!

We did it!

Pros

  • Safe - won't fail at runtime with new class because of sealed pattern matching.
  • Easy - objects just have methods. Or at least it looks so.
  • Simple - no complicated errors.

Cons

  • Each method invocation creates a wrapper object. But let's hope really hard that JVM does escape analysis and puts that in stack ^_^

Thank you!

Slides

https://slides.com/arturasslajus/quest-for-composable-immutable-objects

 

Contacts

Artūras Šlajus

arturas@tinylabproductions.com

@arturaz_

Quest for composable immutable objects (in Scala)

By Artūras Šlajus

Quest for composable immutable objects (in Scala)

  • 4,180