Day 2 is another parse-and-compute problem. I opted for a simple lazy list processing parser, with a couple of neat tricks thanks to Arrow combinators.
On a hunch, I wrote the first part in the form of a higher order function. It payed up: the second part was trivial to write.
The Problem
We are given a dataset of unknown length, with the following
structure. The explicit newlines (\n
) are there to make
the explanation easier to follow.
A Y\n
B X\n
C Z\n
...
Each line correspons to a round of Rock-Paper-Scissors.
The first column will always contain A
, B
, or C
, which
correspond to Paper, Rock, and Scissors, as played by your
opponent. The second column will always contain X
, Y
, or Z
.
For the first part, they represent a «cheat code» of what we
should play, meaning Rock, Paper, and Scissors, respectively.
Each round is scored depending on its outcome, and what you play. The scoring should be obvious from the code below.
Part A wants the total score for all the games.
Main Program
The main program is pretty much the same as Day 1.
Playing the game
The solution for Part A has the signature
partA :: String -> Int
and we can break its implementation in three parts:
Parse the input String into a list of «turns»
turns :: String -> [Turn]
Have a way to «score» each turn, and
map
it over the list.score :: Turn -> Int
Add the list.
This leads to a high level implemenation like so
partA = sum . map score . turns
You'll notice I'm using Int
instead of Integer
. This has to do
with my wanting to illustrate the use of the Enum
typeclass for scoring.
Strong static typing go brrr
Let's create a simple model for Rock-Paper-Scissors using
Haskell types. I've already mentioned the notion of Turn
to represent each line to process. And every Turn
is actually
comprised of what the opponent and we will Play
.
This suggest the following sum type to model each player's action
data Play = Rock | Paper | Scissors
deriving (Show,Eq,Enum)
with the natural relation
beats :: Play -> Play -> Bool
Rock `beats` Scissors = True
Scissors `beats` Paper = True
Paper `beats` Rock = True
_ `beats` _ = False
As mentioned before, the scoring rules given in the problem's
description assign specific values to each Play
. When a Haskell
sum type has an Enum
instance, a bijection with the Int
datatype
is created, counting from 0. So, for scoring as per the problems
description we write
valueOf :: Play -> Int
valueOf = succ . fromEnum
A Turn
is nothing more than a pair with Play
s from the opponent
and ourselves. Instead of using a plain tuple, I chose a product
type with record notation for simplicity
data Turn = Turn { they :: Play
, we :: Play
}
deriving (Show)
I was able to complete the scoring rules by writing
score :: Turn -> Int
score turn | they turn == we turn = valueOf (we turn) + 3
score turn | they turn `beats` we turn = valueOf (we turn)
score turn | we turn `beats` they turn = valueOf (we turn) + 6
This combines the valueOf
our Play
for the Turn
, with the offset
depending on the outcome of the turn, thus solving (2)
Parsing the input
I built my parser using pure list manipulation functions, step by step using the REPL. It went like this
ghci> f <- readFile "input.txt"
f <- String
ghci> take 3 $ lines f
["A Y","B Y","B Z"]
Using lines
gives us a list with each line; I always use take
so I can
look at the beginning instead of the whole thing. Now I need to
get the first and third letters. Here's how knowing about
Arrows makes it easy to write.
Granted operator
(&&&) :: Arrow a => a b c -> a b c' -> a b (c,c')
has a strange signature when you look at it. Regular functions happen to be arrows, so the above operator, in the context of plain functions can be thought of as
(&&&) :: (b -> c) -> (b -> c') -> b -> (c,c')
That is, if you have two functions that can work on the same domain type, this operator gives you a new function that operates on a single argument producing a tuple with both results, e.g.
ghci> ((*2) &&& even) 21
(42,False)
Back to our parsing problem, I need the first and third character from each line. So I write
ghci> take 3 $ map ((!! 0) &&& (!! 2)) $ lines f
[('A','Y'),('B','Y'),('B','Z')]
Neat!
The last step is to actually use our Turn
type. At this point
I was about to write a simple helper function with signature
(Char,Char) -> (Play,Play)
or even
(Char,Char) -> Turn
but my laziness kicked in.
Recall that you don't know about Part B until you've successfully completed Part A. While reading the latter, I had a hunch that the «meaning» of the second column was going to change for Part B. Instead of «hardcoding» transformations inside the parsing code, I decided to abstract it in the form of a higher order function:
playWith :: ((Char,Char) -> (Play,Play))
-> String
-> [Turn]
playWith style = map (tupleToTurn . style . (!! 0) &&& (!! 2)) . lines
where
tupleToTurn (t,m) = Turn t m
so I could pass any function style
able to «change the meaning» of
the second column, before turning it into an actual Turn
.
Back to solving Part A
For Part A, we're told to interpret A
, B
, and C
, as
Rock, Paper, and Scissors, respectively, and to interpret
X
, Y
, or Z
, as Rock, Paper, and Scissors, respectively.
This results in
charToPlay :: Char -> Play
charToPlay c | c == 'A' || c == 'X' = Rock
charToPlay c | c == 'B' || c == 'Y' = Paper
charToPlay c | c == 'C' || c == 'Z' = Scissors
Recall the function
playWith :: ((Char,Char) -> (Play,Play))
-> String
-> [Turn]
where the first argument is the transformation we want from our
source tuple to the actual Play
interpretation. That is, we
need to write a function with type
(Char,Char) -> (Play,Play)
But we already have charToPlay
! We would need to apply it to
each argument of the tuple, and produce the result as a tuple.
That is something that you can definitely do via pattern-matching,
and that's left as an exercise to the reader. Arrow
makes it
very easy, thanks to operator
(***) :: Arrow a => a b c -> a b' c' -> a (b, b') (c, c')
which, in the context of functions, can be thought of as
(***) :: (b -> c) -> (b' -> c') -> (b, b') -> (c, c')
This allows us to write
turns :: String -> [Turn]
turns = playWith (charToPlay *** charToPlay)
and now partA
is complete!
How does that help with Part B?
Once Part A was solved using the preceding implementation, the
specification for Part B became available. We're told to keep
interpreting A
, B
, and C
as they were in Part A, but the meanings
for X
, Y
, and Z
have now changed. Instead of being interpreted
as what to play, they are to be interpreted as the desired outcome
for each round: an X
means «play to lose», Y
means «play to draw»,
and Z
means «play to win».
So I wrote a couple of helper functions for figuring out how
to win or lose to a particular Play
,
toBeat :: Play -> Play
toBeat Rock = Paper
toBeat Paper = Scissors
toBeat Scissors = Rock
toLose :: Play -> Play
toLose Rock = Scissors
toLose Paper = Rock
toLose Scissors = Paper
Now, the solution for partB
becomes
partB = sum . map score . predict
predict :: String -> [Turn]
predict = playWith fix
where
fix :: (Char,Char) -> (Play,Play)
fix (t,c) = let they = charToPlay t
in case c of
'X' -> (they, toLose they)
'Y' -> (they, they)
'Z' -> (they, toBeat they)
passing to playWith
a helper function that «fixes» the Turn
to have the desired outcome.
Conclusion
There are many Haskell standard type-classes that have somewhat
obscure names. Instead of focusing on their names, or trying to
fathom their arcane origins in
Category Theory,
it's way better
to know their practical use cases. fmap
over Functor
, bimap
over Bifunctor
, mconcat
on Monoid
, and Arrow
combinators in
function contexts are some of the more useful ones. You find
them everywhere and anywhere...
The use of higher-order functions, such as playWith
, is pervasive
in functional programming (think of map
, foldr
, etc.). It's one
of the basic techniques that help build reusable components.
Instead of hard-coding functionality to an unrelated process,
try abstracting it out and creating higher-order functions.
Granted, I did this «on a hunch» before knowing what Part B was about, but then again, this is not my first rodeo.