One of my favourite ways of improving my coding is by working on Katas. Katas are small coding problems that are used to practise a technique. The concept derives from Japanese martial arts where students continuously repeat a certain sequence to refine their technique. There is an early Playful Python article that goes into this concept more
The point of a software kata is not to arrive at a "correct" solution, but as a playground to experiment with different approaches. Since we just learned about the multi-value monad in the previous article, how about we use it to solve the Blackjack scoring kata?
The Kata
One of the key aspects of the game of Blackjack is to determine the score for a hand of cards.
- Cards from
2
to10
are worth the points equal to their face value - The cards Jack
J
, QueenQ
and KingK
are worth 10 points each - The Ace
Ace
can be worth either 1 point, or 11 points. Usually the higher the score is better, but if your score crosses 21 then you lose (its called going "bust"). So you want to select the points for theA
in such a way that it is as high as possible without crossing 21
Here are some examples of hands and their scores:
2
,5
,7
,2
- 16 points, just add up all the numbers2
,5
,7
,10
- bust2
,5
,7
,A
- 15 points. Here if we takeA
as 11 then the hand will bust. So we takeA
as a 12
,A
,A
- 14 points. We take the firstA
as 11 points. Taking the secondA
as 11 will bust, so we consider the secondA
as 1 pointA
,A
,A
- 13 points
Generally in blackjack you can only consider one A
as 11 as any more will definitely bust. However if we keep the bust threshold at a higher value, say 25, then there could be situations where more than one A
could be counted as 11.
So here is the problem statement
Given a hand of cards, and a bust threshold, determine the score of the hand
We are going to solve this problem in two ways: one using functions (this article), and the second one using classes (next article). We will use the multi-value monad in both approaches.
The Multi-Value Monad
For reference, here is the code we derived last time for implementing the multi-value monad.
from itertools import chain
class MultiValue:
def __init__(self, values):
self.values = set(values)
def map(self, fn):
out = {fn(val) for val in self.values}
return MultiValue(out)
def flatmap(self, fn):
out = (fn(val).values for val in self.values)
return MultiValue(chain(*out))
Now let us see the two approaches, starting with the functional approach
The Functional Approach
Okay, so we have a hand of cards in a list like this: [2, 5, 7, 'A']
. We need to loop through these and sum them up.
The tricky part is the ace which could be counted as a 1 or 11. Given that it has two values, it makes sense to represent card values as MultiValue
. Lets start with a function to get the value of a card.
def get_card_value(card):
if card == "A":
return MultiValue({1, 11})
elif card in ("J", "Q", "K"):
return MultiValue({10})
return MultiValue({card})
Adding the values
Next, we need to be able to add up the card values, so we need a function to take two MultiValue
and add them up, like this: MultiValue({1, 2}) + MultiValue({10, 20})
Lets build up to that step by step. We start with a function that adds two int
def add(a: int, b: int) -> int:
return a + b
That function is quite straightforward. I've added type hints to make it clear what data types are involved. Actually, we don't even need to write this function ourselves. The operator
package in python already has it.
from operator import add
Adding a MultiValue and an int
Now lets move on to a function to add a MultiValue
with an int
. We can do that using the map
method on the MultiValue
.
The catch is that the map
method needs a function that takes one argument. Our add
function takes two parameters. We can fix this by partially applying the first parameter. Here is how it works
from functools import partial
plus_2 = partial(add, 2)
print(plus_2(10)) # will add 2 to give 12
print(plus_2(6)) # 8
partial
allows us to take add
and set the first argument ahead of time. The return value plus_2
is a function that takes the remaining argument.
And since plus_2
only takes one argument, it can be applied via map
. Here is an example
mv = MultiValue({4, 10})
plus_2 = partial(add, 2)
mv.map(plus_2) # MultiValue({6, 12})
and the generalised function
def add_mv_and_int(mv: MultiValue, b: int) -> MultiValue:
return mv.map(partial(add, b))
Note the type signature. This function returns a MultiValue
as an output
Adding two MultiValues
Now we have to add two multivalues. Here is an example: MultiValue({1, 2}) + MultiValue({10, 20})
which will equal MultiValue({11, 21, 12, 22})
To do this, we apply the add_mv_and_int
function. Since that function returns a MultiValue
as an output, we need to use flatmap
here instead of map
. Also, add_mv_and_int
takes two parameters, so we need to use partial
as before. Here is an example
a = MultiValue({1, 2})
b = MultiValue({10, 20})
a.flatmap(partial(add_mv_and_int, b)) # MultiValue({11, 21, 12, 22})
and the generic function
def add_multivalue(a: MultiValue, b: MultiValue) -> MultiValue:
return a.flatmap(partial(add_mv_and_int, b))
Solving the problem
Alright now that we have a function for adding two MultiValue
, how do we add all the values? Well, we can start with an identity value (MultiValue({0})
) and adding it with the first number, take that sum and add it with the second, then take that sum and add it with the third and so on.
This kind of operation is called a reduce
and Python has it in the functools
module.
from functools import reduce
def hand_value(hand):
values = [get_card_value(card) for card in hand]
total = reduce(add_multivalue, values, MultiValue({0}))
Now that we have all the possible totals it is simple to complete the function. Just filter out the values that don't bust and find the maximum one. That is the score of the hand.
Here is what the final code looks like
from functools import partial
from operator import add
from functools import reduce
def add_mv_and_int(mv: MultiValue, b: int) -> MultiValue:
return mv.map(partial(add, b))
def add_multivalue(a: MultiValue, b: MultiValue) -> MultiValue:
return a.flatmap(partial(add_mv_and_int, b))
def get_card_value(card):
if card == "A":
return MultiValue({1, 11})
elif card in ("J", "Q", "K"):
return MultiValue({10})
return MultiValue({card})
def hand_value(hand):
if len(hand) == 0:
return 0
values = [get_card_value(card) for card in hand]
total = reduce(add_multivalue, values, MultiValue({0}))
no_bust = {value for value in total.values if value <= 21}
try:
return max(no_bust)
except ValueError:
return None # all possible scores are bust
The code in hand_value
reads almost like pseudocode
- If the hand is empty the score is zero
- Get the values of the cards
- Add them all up
- Get the possibilities that do not bust
- Find the highest of the possibilities. If no possibilities, return
None
We don't need to handle the complexity that some variables are multi-valued, making the code clean and easy to read.
Did you like this article?
If you liked this article, consider subscribing to this site. Subscribing is free.
Why subscribe? Here are three reasons:
- You will get every new article as an email in your inbox, so you never miss an article
- You will be able to comment on all the posts, ask questions, etc
- Once in a while, I will be posting conference talk slides, longer form articles (such as this one), and other content as subscriber-only