Extreme branchless: Bowling
Continuing my branchless journey,
Let's refactor the bowling kata. As a reminder, we had this implementation:
bowlingScore = sum . take 10 . unfoldr go . toBowlingZipper
where
go =
\case
Z x y (z : nexts)
| x == 10 -> Just (10 + y + z, Z y z nexts)
| x + y == 10 -> Just (10 + z, toBowlingZipper (z : nexts))
| otherwise -> Just (x + y, toBowlingZipper (z : nexts))
Z x y nexts -> Just (x + y, toBowlingZipper nexts)
toBowlingZipper (x : y : nexts) = Z x y nexts
data BowlingZipper a = Z a a [a]
We have several branching spots:
go
is the worst as it contains many checks on integersunfoldr
which creates a list, consumed directly
Unlike our previous attempts, we can't simply count the number of pins down, describe a strategy, apply the behavior, as we have to either count one or two pins.
Instead, we'll have to reuse a technique introduced for FizzBuzz: compute every possible solution, prioritizing the most relevant.
Which means, we'll list our strategies, giving the value for both a first frame and the sum of two frames:
data PinsDown = PinsDown
{ firstFrame :: BowlingZipper Natural -> Maybe (Natural, BowlingZipper Natural)
, bothFrames :: BowlingZipper Natural -> Maybe (Natural, BowlingZipper Natural)
, next :: PinsDown
}
Then, we can list our strategies:
pins0, pins1, pins2, pins3, pins4, pins5, pins6, pins7, pins8, pins9, pins10 :: PinsDown
pins0 = pinOthers pins1
pins1 = pinOthers pins2
pins2 = pinOthers pins3
pins3 = pinOthers pins4
pins4 = pinOthers pins5
pins5 = pinOthers pins6
pins6 = pinOthers pins7
pins7 = pinOthers pins8
pins8 = pinOthers pins9
pins9 = pinOthers pins10
pins10 =
PinsDown
{ firstFrame = \(Z x y (z : nexts)) -> Just (10 + y + z, Z y z nexts)
, bothFrames = \(Z x y (z : nexts)) -> Just (10 + z, toBowlingZipper (z : nexts))
, next = pins10
}
pinOthers nextPinsDown =
PinsDown
{ firstFrame = const Nothing
, bothFrames = const Nothing
, next = nextPinsDown
}
Note:
- Only we only have a special behavior when all pins are down, in all other scenarios, we only sum pins
toBowlingZipper
has been extracted as follows, even though it's a partial function, only working with our inputs' structure, it's fine in our use-case, in real-world systems, I would protect (not export) the function and strong-type the input.
toBowlingZipper (x : y : nexts) = Z x y nexts
Finally, we can rewrite our main function:
bowlingScore = sum . take 10 . unfoldr (Just . go) . toBowlingZipper
where
go zipper@(Z x y nexts) =
fromMaybe (x + y, toBowlingZipper nexts) $
(strategyFor x).firstFrame zipper <|> (strategyFor (x + y)).bothFrames zipper
strategyFor (Natural f) = f (.next) pins0
Note:
strategyFor
pick the relevant strategy, as we've seen it few time in previous logs- Then we pick the result as follows:
- Pick the strategy for the first frame, look at the strategy result for the first frame
- Otherwise, pick the strategy for both frames summed, look at the strategy result for both frames
- Otherwise, it simply sums the two first frames
It's a good first strep, we could go further, rebuilding "low-level" abstraction
to unfoldr
/take
/sum
, but let's assume we use branchless implementations.