Typeclasses in Scala
I was reading the School of Haskell’s Type Families and Pokemon post, and thought this would be a nice example to attempt to translate into Scala. I’m fairly certain that the type families portion won’t be something I can implement, but this is good practice for Type Classes.
The general goal of this post is to:
- understand how to write a Type Class
- investigate their strengths over other implementations
Basic Design
So Pokemon
should have a type and a tier. For instance Charmander
is a
Fire
type with Tier1
. Also, the Pokemon have strengths vs some other types,
but weaknesses against others.
We also want to give users of this library some easy requirements that will leverage typeclasses. The basic requirements of a consumer should be something like:
- Define a Pokemon Type (eg “air”)
- Put pokemon in that type, with
Tier
- Provide a typeclass instance of something that tells how they stack up against other types
One smart way we could implement this is to model the Ord
type that Haskell
uses. Scala happens to have pretty much the same thing, in the Ordering
typeclass.
trait Hierarchy {
val order: Int
}
case object Tier1 extends Hierarchy {
override val order = 1
}
case object Tier2 extends Hierarchy {
override val order = 2
}
case object Tier3 extends Hierarchy {
override val order = 3
}
implicit val TierOrdering: Ordering[Hierarchy] = new Ordering[Hierarchy] {
override def compare(x: Hierarchy, y: Hierarchy): Int = x.order - y.order
}
Nice, now let’s test that this works.
it("should correctly compare Tiers") {
Tier1 > Tier2 shouldBe false
Tier2 > Tier1 shouldBe true
Tier1 < Tier2 shouldBe true
Tier3 == Tier3 shouldBe true
}
[info] Hierarchy
[info] - should correctly compare Tiers
[info] Run completed in 334 milliseconds.
[info] Total number of tests run: 1
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
Creating the Typeclass
First thing to do, is create a basic outline of how we want to fight (taking
advantage of the typeclass model). We know that we are going to need to leave
room for “users” to be able to add their own new types of Pokemon
, and all
they need to do is supply a typeclass.
def fight[A <: Pokemon, B <: Pokemon](a: A, b: B)(implicit rulesA: Rules[A], rulesB: Rules[B]) = ???
Ok so what should Rules
look like? This is our typeclass that we’re going to
ask users to implement for their new types. While we’re at it, we should
probably make a simple ADT that expresses the “decision” of the fights.
trait Decision
case class Winner(a: Pokemon) extends Decision
case object Draw extends Decision
case object NotHandled extends Decision
trait Rules[A] {
def compare(a1: A, a2: Pokemon): Decision
}
We need NotHandled
here so that our implementations leave room for others to
add new Rules
and Pokemon
types in the future. Speaking of pokemon, we need
to make a bunch.
sealed trait Pokemon {
val tier: Hierarchy
}
case class FirePokemon(override val tier: Hierarchy) extends Pokemon
val charmander = FirePokemon(Tier1)
val charmeleon = FirePokemon(Tier2)
val charizard = FirePokemon(Tier3)
case class WaterPokemon(override val tier: Hierarchy) extends Pokemon
val squirtle = WaterPokemon(Tier1)
val warTortle = WaterPokemon(Tier2)
val blastoise = WaterPokemon(Tier3)
case class GrassPokemon(override val tier: Hierarchy) extends Pokemon
val bulbasaur = GrassPokemon(Tier1)
val ivysaur = GrassPokemon(Tier2)
val venusaur = GrassPokemon(Tier3)
Now we have enough information to implement Rules
. Here is a first pass at
FirePokemon
.
implicit val FireRules: Rules[FirePokemon] = new Rules[FirePokemon] {
def compare(fire: FirePokemon, opponent: Pokemon): Decision = opponent match {
case water: WaterPokemon => Winner(water)
case grass: GrassPokemon => Winner(fire)
case anotherFire: FirePokemon => Draw
case _ => NotHandled
}
}
As you can see, we’re using the NotHandled
result for the case where we’re
fighting a type we don’t know about when we implemented FireRules
. However you
can see that there is a problem here with this approach. Are aren’t taking into
account the tier of the pokemon. A FirePokemon
is supposed to be a
GrassPokemon
, but what if the grass pokemon is a higher tier? We need to take
that into account.
We should also take this time to implement this same logic into Draw
(when
they are the same type Pokemon
but potentially different tiers).
implicit class RichTieredPokemon(a: Pokemon) {
import TierOrdering._
def considerTiers(b: Pokemon): Pokemon = if (a.tier < b.tier) b else a
}
private def handleSameType(same1: Pokemon, same2: Pokemon) =
if (same1.tier == same2.tier)
Draw
else
Winner(same2.considerTiers(same1))
The reason this is in an implicit class
is so that we can use nice dot
notation. It’s also nice because with get to finally use the Ordering
typeclass we implemented at the beginning.
Taking this new function, we can improve our FireRules
. While we’re at it, we
can implement the others as well.
implicit val FireRules: Rules[FirePokemon] = new Rules[FirePokemon] {
def compare(fire: FirePokemon, opponent: Pokemon): Decision = opponent match {
case water: WaterPokemon => Winner(water.considerTiers(fire))
case grass: GrassPokemon => Winner(fire.considerTiers(grass))
case anotherFire: FirePokemon => handleSameType(fire, anotherFire)
case _ => NotHandled
}
}
implicit val WaterRules: Rules[WaterPokemon] = new Rules[WaterPokemon] {
def compare(water: WaterPokemon, opponent: Pokemon): Decision = opponent match {
case fire: FirePokemon => Winner(water.considerTiers(fire))
case grass: GrassPokemon => Winner(grass.considerTiers(water))
case anotherWater: WaterPokemon => handleSameType(water, anotherWater)
case _ => NotHandled
}
}
implicit val GrassRules: Rules[GrassPokemon] = new Rules[GrassPokemon] {
def compare(grass: GrassPokemon, opponent: Pokemon): Decision = opponent match {
case fire: FirePokemon => Winner(fire.considerTiers(grass))
case water: WaterPokemon => Winner(grass.considerTiers(water))
case anotherGrass: GrassPokemon => handleSameType(grass, anotherGrass)
case _ => NotHandled
}
}
Awesome. Now we have all the things we need to finally implement fight
.
def fight[A <: Pokemon, B <: Pokemon](a: A, b: B)(implicit rulesA: Rules[A], rulesB: Rules[B]): Decision = {
val originalResult = rulesA.compare(a, b)
originalResult match {
case NotHandled => rulesB.compare(b, a)
case _ => originalResult
}
}
First we check to see if the first pokemon has all of the information it needs
in its Rules
, which is true in the case of the original fire, grass, and
water. If there is a new type that was added, we need to check the rules for
that new type to see if they’ve correctly implemented their element into the
strange pokemon taxonomy.
Let’s be sure that this works correctly.
describe("PokemonBattle") {
describe("Fire") {
it("is a draw with same tiered Fire") {
val newFireTier1 = FirePokemon(Tier1)
PokemonBattle.fight(Pokemodels.charmander, newFireTier1) shouldBe Draw
}
it("loses to water") {
val fight = PokemonBattle.fight(Pokemodels.charizard, Pokemodels.blastoise)
val fightReverse = PokemonBattle.fight(Pokemodels.blastoise, Pokemodels.charizard)
fight shouldBe Winner(Pokemodels.blastoise)
fightReverse shouldBe Winner(Pokemodels.blastoise)
}
it("wins vs grass") {
val fight = PokemonBattle.fight(Pokemodels.charizard, Pokemodels.venusaur)
val fightReverse = PokemonBattle.fight(Pokemodels.venusaur, Pokemodels.charizard)
fight shouldBe Winner(Pokemodels.charizard)
fightReverse shouldBe Winner(Pokemodels.charizard)
}
it("considers tier correctly") {
val grassTier3 = GrassPokemon(Tier3)
val fight = PokemonBattle.fight(Pokemodels.charmeleon, grassTier3)
val fightReverse = PokemonBattle.fight(grassTier3, Pokemodels.charmeleon)
fight shouldBe Winner(grassTier3)
fightReverse shouldBe Winner(grassTier3)
}
}
}
Running the tests with sbt
. We see they all pass.
[info] PokemonTypeClassesTest:
[info] Hierarchy
[info] - should correctly compare Tiers
[info] PokemonBattle
[info] Fire
[info] - is a draw with same tiered Fire
[info] - loses to water
[info] - wins vs grass
[info] - considers tier correctly
[info] Run completed in 129 milliseconds.
[info] Total number of tests run: 5
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 5, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
Adding a new Pokemon
If we’ve done everything as planned, we should be able to add a new pokemon
type and fight it against the original 3 types. Let’s go ahead and implement
Air
type Pokemon. Since I have no idea how pokemon actually work, I’m just
going to make stuff up for the taxonomy. Let’s just say it only wins against
water, because why not?
case class AirPokemon(override val tier: Hierarchy) extends Pokemon
val thinger = AirPokemon(Tier1)
val bigChungus = AirPokemon(Tier2)
val oleFarty = AirPokemon(Tier3)
implicit val AirRules: Rules[AirPokemon] = new Rules[AirPokemon] {
def compare(air: AirPokemon, opponent: Pokemon): Decision = opponent match {
case fire: FirePokemon => Winner(fire.considerTiers(air))
case water: WaterPokemon => Winner(air.considerTiers(water))
case grass: GrassPokemon => Winner(grass.considerTiers(air))
case anotherAir: AirPokemon => handleSameType(air, anotherAir)
case _ => NotHandled
}
}
# test it!
describe("Air") {
it("wins against water") {
val fight = PokemonBattle.fight(Pokemodels.oleFarty, Pokemodels.blastoise)
val fightReverse = PokemonBattle.fight(Pokemodels.blastoise, Pokemodels.oleFarty)
fight shouldBe Winner(Pokemodels.oleFarty)
fightReverse shouldBe Winner(Pokemodels.oleFarty)
}
it("loses to fire") {
val fight = PokemonBattle.fight(Pokemodels.bigChungus, Pokemodels.charmeleon)
val fightReverse = PokemonBattle.fight(Pokemodels.charmeleon, Pokemodels.bigChungus)
fight shouldBe Winner(Pokemodels.charmeleon)
fightReverse shouldBe Winner(Pokemodels.charmeleon)
}
}
They pass!
[info] PokemonTypeClassesTest:
[info] Hierarchy
[info] - should correctly compare Tiers
[info] PokemonBattle
[info] Fire
[info] - is a draw with same tiered Fire
[info] - loses to water
[info] - wins vs grass
[info] - considers tier correctly
[info] Air
[info] - wins against water
[info] - loses to fire
[info] Run completed in 132 milliseconds.
[info] Total number of tests run: 7
[info] Suites: completed 1, aborted 0
[info] Tests: succeeded 7, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
Take aways
This is a really nice illustration of why typeclasses work well for providing
extendable libraries. We could give the original grass/water/fire
implementation of the library to anyone. As long as they provide an instance of
the Rules
typeclass, everything works just like it did with the original.
There is no need to change the library at all!