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,139