Python Lists: A closer look, part 10

MP #20: Two final list gotchas.

Note: The previous post in this series discussed what happens when you try to modify a list inside a simple for loop. The next post explains different ways you can copy the information in a list.

There are two final list behaviors that we’ll examine. First, it can sometimes be tempting to use an empty list as a default argument in a function or method. This usually doesn’t work out, for a reason that surprises many people. Also, there’s one thing to watch out for when removing items from a list.

Fortunately, both of these situations have straightforward solutions if you know to watch out for them.

Modeling a player

Let’s consider a game that centers around playing cards. Here’s the challenge:

  • Define a class called Player.
  • The __init__() method must have one required argument and one optional argument. The required argument is name, which refers to a string. The optional argument is hand, which refers to a list.
  • When someone creates a Player object, they must provide a name. They have the option of providing a hand of cards as well, but they don’t have to.
  • Define one more method called show_cards(). This method should print the player’s name, and show all the cards in their hand. If their hand is empty, it should print the message [Player] has no cards.

If you’re looking for a coding challenge, try to write this class before reading further. If you try it, see if your version avoids the problem that this post is about to demonstrate.

A seemingly correct implementation

Here’s a reasonable attempt at defining this class:

# player.py

"""Represent a player in a card game."""

class Player:

    def __init__(self, name, hand=[]):
        """Each player has a name, and a hand."""
        self.name = name
        self.hand = hand

    def show_cards(self):
        """Show all the cards in the current hand."""
        if self.hand:
            print(f"{self.name} has the following cards:")
            print(f"\t{self.hand}")
        else:
            print(f"{self.name} has no cards.")

This seems to meet all the requirements listed above. When you make a new Player, you have to provide a value for name. You can provide a list representing a hand, but if you don’t an empty hand will be created for you.

The method show_cards() shows the player’s hand if self.hand is not empty, otherwise it prints a message that the player has no cards.

A dealer

Next we need a dealer. The dealer we’ll work with is just a module containing one function, get_card(). The function should return a single random card from a deck. If you want to write your own module, go ahead and do so. You don’t need to worry about removing cards from the deck as they’re dealt, for the purposes of this exercise.

Here’s an implementation of get_card(), using Unicode characters for the suits:

# dealer.py

from random import choice

def get_card():
    """Return a random card."""
    suits = ('\u2660', '\u2663', '\u2665', '\u2666')
    values = (
        '2', '3', '4', '5', '6', '7', '8', '9', '10',
        'J', 'Q', 'K', 'A'
    )
    return choice(values) + choice(suits)

We make a list of suits; these are the Unicode characters for spades, clubs, hearts, and diamonds: ('♠', '♣', '♥', '♦')

We then make a list of values. Whenever the function is called, we return a string containing a random value combined with a random suit.

A single player

Let’s see if all this works, starting with a single player. In a new file we’ll make a player with only a name, show their hand, add some cards, and then show their hand again. (Feel free to try this yourself as well.)

Here’s a player, starting out with just a name:

# card_game_one_player.py

from player import Player
from dealer import get_card

eric = Player('Eric')
eric.show_cards()

Let’s check the output:

Eric has no cards.

So far so good; we can make a player with just a name. Now let’s add some cards to the hand:

from player import Player
from dealer import get_card

eric = Player('Eric')
eric.show_cards()

for _ in range(5):
    new_card = get_card()
    eric.hand.append(new_card)

eric.show_cards()

We get 5 new cards, and append them to the player’s hand. Then we call show_cards() again, and we should see a hand:

Eric has no cards.
Eric has the following cards:
    ['8♠', '5♣', 'J♦', 'Q♠', '9♣']

Great! Everything seems to work, at least for one player.

Two players

Now let’s add a second player, and start each player off with an empty hand:

card_game_two_players.py

from player import Player
from dealer import get_card

eric = Player('Eric')
kyle = Player('Kyle')

eric.show_cards()
kyle.show_cards()

We make two players, again using just names so they each start with an empty hand. We show each player’s hands:

Eric has no cards.
Kyle has no cards.

Again, so far so good. Now let’s deal both players a 5-card hand:

from player import Player
from dealer import get_card

eric = Player('Eric')
eric.show_cards()

kyle = Player('Kyle')
kyle.show_cards()

for _ in range(5):
    new_card = get_card()
    eric.hand.append(new_card)

    new_card = get_card()
    kyle.hand.append(new_card)

eric.show_cards()
kyle.show_cards()

Here’s the output:

Eric has no cards.
Kyle has no cards.

Eric has the following cards:
    ['Q♠', '7♥', '3♣', '8♣', '7♣', '10♦', '2♠', '6♦', '3♥', '6♥']
Kyle has the following cards:
    ['Q♠', '7♥', '3♣', '8♣', '7♣', '10♦', '2♠', '6♦', '3♥', '6♥']

Oh no! That doesn’t look right at all. Both players have hands with 10 cards, and they have the exact same hand!

This output surprises a lot of people when they first see it.

Understanding the problem

The problem is in the player.py module, in the __init__() method:

"""Represent a player in a card game."""

class Player:

    def __init__(self, name, hand=[]):
        """Each player has a name, and a hand."""
        self.name = name
        self.hand = hand

    def show_cards(self):
        ...

In the card game program, we asked Python to import the Player class. When Python executes that import statement, it reads through all the code in the Player class. If it finds any default arguments, it evaluates those arguments during the import process. Most importantly, default arguments are not re-evaluated each time a new Player object is made.

In general, if any function has a default argument, that argument is evaluated only once, when the function is first read into memory. For typical default arguments such as age=0, this behavior works as expected. But if the default argument is mutable (if it can be modified), that creates a problem.

So what happened in our code? When the Player object eric was defined, the attribute eric.hand was assigned the empty list that Python created when Player was first loaded into memory. When the Player object kyle was defined, the attribute kyle.hand was associated with that same list object! Both players’ hand attributes point to the same list object in memory.

This behavior is perfectly appropriate. It’s much more efficient to assume that all default arguments will be static, and only evaluate them once when the program is loaded.

A simple fix

The fix is not necessarily obvious, but it is simple. Instead of using an empty list as the default argument, we’ll use None. Then, inside the __init__() method, we’ll create an empty list unique to each player if they don’t start out with a hand:

class Player:

    def __init__(self, name, hand=None):
        """Each player has a name, and a hand."""
        self.name = name
        if not hand:
            self.hand = []
        else:
            self.hand = hand

    def show_cards(self):
        ...

The value None never changes, so it’s well-suited for use as a default value. Inside the __init__() method, we make a quick check: if no value was passed for hand, we assign a new empty list to the attribute self.hand. If a hand was provided, we assign that list to self.hand.

The __init__() method is run every time a new Player object is created. This code works, and if you go back and run card_game_two_players.py again you’ll see a unique 5-card hand for each player:

Eric has no cards.
Kyle has no cards.

Eric has the following cards:
	['2♠', '9♦', '4♦', '7♦', '5♥']
Kyle has the following cards:
	['8♦', 'J♠', '6♦', '5♥', '10♣']

Removing items from a list

This last gotcha is much quicker to demonstrate. Imagine you have a hand with some regular cards and a few wild cards, indicated by 'w':

wild_cards.py

hand = ['5♦', '4♣', 'w', '5♦', 'A♣', 'w', '3♥', 'w']

Imagine the game has changed, and we want to remove all the wild cards:

hand = ['5♦', '4♣', 'w', '5♦', 'A♣', 'w', '3♥', 'w']
print(hand)

hand.remove('w')
print(hand)

You might expect to see no more wild cards, but that’s not what happens:

['5♦', '4♣', 'w', '5♦', 'A♣', 'w', '3♥', 'w']
['5♦', '4♣', '5♦', 'A♣', 'w', '3♥', 'w']

The method remove() only gets rid of the first occurrence of the item you specify.

If you want to get rid of all the wild cards, use a while loop, and keep it running as long as there’s a wild card in the hand:

hand = ['5♦', '4♣', 'w', '5♦', 'A♣', 'w', '3♥', 'w']
print(hand)

while 'w' in hand:
    hand.remove('w')
    
print(hand)

This calls remove() repeatedly, until there are no wild cards left in the hand:

['5♦', '4♣', 'w', '5♦', 'A♣', 'w', '3♥', 'w']
['5♦', '4♣', '5♦', 'A♣', '3♥']

Conclusions

When you need to use an empty list as a default value for an argument, make sure you use None instead. Inside the function, define an empty list if no value was passed for that argument. In general, avoid using mutable values as default arguments.

If you need to remove an item from a list, make sure you’re clear about whether you need to remove just the first occurrence of that item, or all occurrences of that item. If you need to remove all of them, use a while loop to keep removing the item from the list until it no longer appears anywhere in the list.

Resources

You can find the code files from this post in the mostly_python GitHub repository.