Debugging in Python, part 9: Hands and Decks

MP 150: Modeling a hand of playing cards, and a deck as well.

Note: This post is part of an ongoing series about debugging in Python. The posts in this series will only be available to paid subscribers for the first 6 weeks. After that they will be available to everyone. Thank you to everyone who supports my ongoing work on Mostly Python.

We want to look at logical errors, but we need an appropriate codebase to practice against. In the last post we modeled a playing card; in this post we'll build out models for a hand of cards, and a deck of cards. We'll be able to use this codebase in the next few posts to look at a number of interesting logical errors.

Modeling a hand of cards

In many card games, players are dealt a hand of cards. A lot of games start with 5 or 7 cards, but the number of cards in a hand can vary widely.

Here's a first pass at the Hand class:

class Hand:

    def __init__(self, cards=None):
        if cards:
            self.cards = cards
        else:
            self.cards = []
card_models.py

We want to be able to pass a list of cards in when creating an instance of Hand, but we want an empty hand by default. So if a hand is created with some cards, we assign that list to self.cards. If no cards are passed, we assign an empty list to self.cards.

We'll want to be able to show our hand, so we can see what's in it:

class Hand:
    ...
    def show(self):
        hand_string = " ".join([str(card) for card in self.cards])
        print(hand_string)

The show() method builds a string representing the hand. When you call str() on an instance of a class, Python looks for a __str__() method. If it doesn't find one, it looks for a __repr__() method. The call to str(card) here ends up using the __repr__() method we defined for Card in the last post. Each card in the hand is represented by the compact string generated by that __repr__() method. The " ".join() call inserts a single space between each card, which creates a single string that includes every card in the hand. The method calls print(), so we can just call hand.show().

An example hand

Let's see if this works. Here's a new file called demo_cards.py, which we can use as a playground for working with our models:

from card_models import Card, Hand

cards = [
    Card("A", "spades"),
    Card("A", "clubs"),
    Card("10", "spades"),
    Card("7", "diamonds"),
    Card("K", "clubs"),
]

print("A simple hand:")
hand = Hand(cards)
hand.show()
demo_cards.py

We make a list containing five cards, and use it to create an instance of Hand. We then call the show() method:

$ python demo_cards.py
A simple hand:
A♠ A♣ 10♠ 7♦ K♣

This is good, but there's one more thing we should do before moving on to building a deck.

Organizing a hand

Most players, when dealt a hand, will organize their cards in a way that makes it easier to keep track of what they're holding. How you organize your cards will depend somewhat on the game you're playing, but we can improve the Hand model by allowing players to sort their cards in a way that's helpful in most games.

Here's a one-line organize() method:

import random
from operator import attrgetter
...

class Hand:
    ...
    def organize(self):
        """Put cards in a more usable order."""
        self.cards.sort(key=attrgetter("value", "suit"))
card_models.py

The key argument in the sort() call tells Python how to use each card's attributes when sorting the list of cards. The function attrgetter() retrieves the specified attributes from an object. The attrgetter() call here pulls the value and suit from each card. The cards will be sorted first by their value, and if multiple cards have the same value they'll then be sorted according to their suit.

Now we can organize the hand we created in the demo program:

...
print("\nOrganized hand:")
hand.organize()
hand.show()
demo_cards.py

We see the original hand, and the nicely ordered hand:

$ python demo_cards.py
A simple hand:
A♠ A♣ 10♠ 7♦ K♣

Organized hand:
7♦ 10♠ K♣ A♣ A♠

The cards are increasing in value across the hand, and the pair of aces is sorted alphabetically by suit.

Modeling a deck of cards

Now we're ready to model a full deck:

...
class Deck:
    """Bottom of the deck is the start of the list, top of the deck is the end

    This makes drawing a card efficient.
    """

    def __init__(self):
        self.cards = []
        self.reset_deck()
card_models.py

We're going to store the cards as a list, just like we did for Hand. It's important to note that the end of the list will represent the top of the deck. That's where most of the action will take place, such as drawing cards. Lists are highly optimized for adding and removing items at the end, not at the beginning, so this should be a reasonable approach.

The __init__() method doesn't take any arguments, because we're going to build the deck every time someone makes a new instance. I don't want to put the logic for creating all the cards in a deck in __init__(), so we're just making an empty list to hold the cards in the deck. We then call reset_deck(). Once a Deck instance has been created, it can be reused by calling reset_deck() as often as needed.

Here's reset_deck():

...
class Deck:
    ...
    def reset_deck(self):
        """Build a standard, unshuffled deck."""
        ranks = card_data.ranks_values.keys()
        suits = card_data.suits_symbols.keys()

        for rank in ranks:
            for suit in suits:
                card = Card(rank, suit)
                self.cards.append(card)
card_models.py

The ranks and suits are pulled from the dictionaries in card_data.py, which are already being used by Card. This is important, because it means the actual names used for ranks and suits only appear once in the project. This is much better than hard-coding a list of ranks and a list of suits here. If we decide to change the way ranks and suits are represented, we can just change it in card_data.py, and our Deck class should still work.

A deck of cards is a combination of all possible ranks with all possible suits. That's a nested loop; the outer loop cycles over all the ranks, and the inner loop cycles over all the suits. On each pass through the inner loop we create a card using the current rank and suit, and append it to the list of cards in the deck.

Let's add a show() method, just like we included in Hand:

...
class Deck:
    ...
    def show(self):
        deck_string = " ".join([str(card) for card in self.cards])
        print(deck_string)
card_models.py

Now we should be able to make a full deck of cards in the demo program:

...
print("\nA fresh deck:")
deck = Deck()
deck.show()
demo_cards.py

Here's the output:

$ python demo_cards.py
...
A fresh deck:
2♠ 2♥ 2♦ 2♣ 3♠ 3♥ ... K♦ K♣ A♠ A♥ A♦ A♣

The full output shows all 52 cards in a standard deck.

Shuffling the deck

A well-ordered deck is useful for making sure you've built a correct deck, but it's not that useful for games. Let's add a shuffle() method:

import random
...

class Deck:
    ...
    def shuffle(self):
        random.shuffle(self.cards)
card_models.py

The random.shuffle() function is quite well-suited to this task! Let's see if it works:

...
print("\nA shuffled deck:")
deck.shuffle()
deck.show()
demo_cards.py

We use the same deck instance we were using earlier, and shuffle the deck.

$ python demo_cards.py
...
A shuffled deck:
2♦ 3♠ 4♦ J♥ 9♦ 5♠ ... 9♣ 3♦ 4♥ 8♠ 7♥ J♠

Drawing cards

Something we'll want to do with a shuffled deck is draw a single card, or a number of cards. Here's draw():

...
class Deck:
    ...
    def draw(self, num_cards=1):
        """Draw one or more cards from top of the deck.
        Return a single card, or a list of cards.
        """
        if num_cards == 1:
            return self.cards.pop()

        return [self.cards.pop() for _ in range(num_cards)]
card_models.py

If we're drawing a single card, we'll just return the instance of Card that's popped from the end of the list. If we're drawing multiple cards, we'll build a list of popped cards and return that list.

Let's try it out:

...
print("\nDraw a single card:")
card = deck.draw()
print(card)

print("\nDraw five cards to make a hand:")
cards = deck.draw(5)
hand = Hand(cards)
hand.organize()
hand.show()
demo_cards.py

We draw a single card from the existing deck, and print it. Then we draw five cards, make a hand, organize the hand, and finally show it:

$ python demo_cards.py
...
Draw a single card:
J♦

Draw five cards to make a hand:
2♥ 5♠ 6♣ 9♥ Q♥

This works, but we don't want to have to make hands this way. Let's add a deal() method.

Dealing hands

Dealing from a deck is an interesting action to model. When you deal to a group of players, you typically draw one card at a time from the top of the deck, adding to each player's hand as you go around the table.

We can use the draw() method to build a realistic deal() method:

...
class Deck:
    ...
    def deal(self, num_hands=2, num_cards=5):
        """Return a single Hand, or list of Hands."""
        # Build empty hands.
        hands = [Hand() for _ in range(num_hands)]

        # Add cards to each hand, one at a time.
        for _ in range(num_cards):
            for hand in hands:
                hand.cards.append(self.draw())

        if len(hands) == 1:
            return hands[0]

        return hands
card_models.py

First, the method defaults to dealing two hands, with five cards each. These values can be overridden by passing different arguments, to deal any number of hands of any size, as long as the deck has enough cards. Inside the function, we create the requested number of empty hands.

There's some subtle logic in this method. To simulate real-world dealing, we set up a nested loop. We add a single card to each hand, until all hands have the correct number of cards. Note the inner loop:

            for hand in hands:
                hand.cards.append(self.draw())

This loop draws a card and adds it to a hand, once for each hand in the group. With the default arguments, this loop runs five times, so each hand ends up with five cards.

If you used for hand in hands as the outer loop you'd still get the correct number of cards in each hand. However, the inner loop would look like this:

            for _ in range(num_cards):
                hand.cards.append(self.draw())

In this version one hand would get five cards in a row, then the next hand would get five cards, and so forth. You'd end up with "correct" hands, but your logic would not correspond to the way real-world dealers behave.

Let's deal a couple hands in the demo program:

...
print("\nDealing two hands of seven cards:")
hands = deck.deal(num_hands=2, num_cards=7)
for hand in hands:
    hand.organize()
    hand.show()
demo_cards.py

We deal two hands of seven cards, then organize and show each hand:

$ python demo_cards.py
...
Dealing two hands of seven cards:
3♠ 4♣ 4♠ 5♥ 9♥ K♠ A♠
5♠ 6♣ 6♦ 8♣ 9♠ Q♣ K♣

This would be really useful in building working digital versions of many card games!

A small test suite

We now have working Card, Hand, and Deck models. Compared to most real-world projects there isn't a huge amount of code, but there's a lot of logic in here. I write code like this much faster if I write tests along with the code. I don't adhere strictly to test-driven development, and I've never aimed for 100% code coverage. But I do typically test the critical and subtle behavior in a project as soon as I have working code that I know I'll want to keep using.

You can see the test suite I've written so far here. I'll highlight one test for the deal() method, because it relates to the earlier discussion about how to nest loops:

...
def test_deal():
    """Test dealing two hands of three cards each."""
    deck = Deck()
    hand_1, hand_2 = deck.deal(num_hands=2, num_cards=3)

    # Check hand and deck lengths.
    assert len(hand_1.cards) == len(hand_2.cards) == 3
    assert len(deck.cards) == 46

    # Check hand_1 contents.
    assert hand_1.cards[0].rank == "A" and hand_1.cards[0].suit == "clubs"
    assert hand_1.cards[1].rank == "A" and hand_1.cards[1].suit == "hearts"
    assert hand_1.cards[2].rank == "K" and hand_1.cards[2].suit == "clubs"
test_cards.py

This test deals two hands of three cards each, from an unshuffled deck. Note the last three lines; we're examining the contents of the first hand. The last cards in an unshuffled deck are Q♦ Q♣ K♠ K♥ K♦ K♣ A♠ A♥ A♦ A♣. If we're dealing one card to each hand just as a dealer would, the first hand should get an ace of clubs, then an ace of hearts, and finally a king of clubs.

This test passes, showing that the deal() method is in fact acting like a real-world dealer. If you swap the inner and outer loops in the deal() method, the last two assertions will fail.

Conclusions

In the final posts of this series, we'll use these models in a variety of ways. I already know of at least two logical errors lurking in the code for Card, Deck, and Hand; we'll address those as they come up when we start to use these models. These are exactly the kinds of logical errors people end up needing to address when they start working on real-world projects.

Resources

Resources used in this post can be found in the Mostly Python GitHub repository.