In this lesson, you’ll practice playing with Python types and learn about adding annotations and type hints for sequence objects.
Up until now, you’ve only used basic types like str
, float
, and bool
in your type hints. The Python type system is quite powerful and supports many kinds of more complex types. This is necessary as it needs to be able to reasonably model Python’s dynamic duck typing nature.
In this lesson, you’ll learn more about this type system by making a card game. You’ll see how to specify:
- The type of sequences and mappings like tuples, lists, and dictionaries
- Type aliases that make code easier to read
The following example shows an implementation of a regular (French) deck of cards:
# game.py
import random
SUITS = "♠ ♡ ♢ ♣".split()
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
def create_deck(shuffle=False):
"""Create a new deck of 52 cards"""
deck = [(s, r) for r in RANKS for s in SUITS]
if shuffle:
random.shuffle(deck)
return deck
def deal_hands(deck):
"""Deal the cards in the deck into four hands"""
return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
def play():
"""Play a 4-player card game"""
deck = create_deck(shuffle=True)
names = "P1 P2 P3 P4".split()
hands = {n: h for n, h in zip(names, deal_hands(deck))}
for name, cards in hands.items():
card_str = " ".join(f"{s}{r}" for (s, r) in cards)
print(f"{name}: {card_str}")
if __name__ == "__main__":
play()
Each card is represented as a tuple of strings denoting the suit and rank. The deck is represented as a list of cards. create_deck()
creates a regular deck of 52 playing cards and optionally shuffles the cards. deal_hands()
deals the deck of cards to four players.
Finally, play()
plays the game. As of now, it only prepares for a card game by constructing a shuffled deck and dealing cards to each player. The following is a typical output:
$ python3 game.py
P4: ♣9 ♢9 ♡2 ♢7 ♡7 ♣A ♠6 ♡K ♡5 ♢6 ♢3 ♣3 ♣Q
P1: ♡A ♠2 ♠10 ♢J ♣10 ♣4 ♠5 ♡Q ♢5 ♣6 ♠A ♣5 ♢4
P2: ♢2 ♠7 ♡8 ♢K ♠3 ♡3 ♣K ♠J ♢A ♣7 ♡6 ♡10 ♠K
P3: ♣2 ♣8 ♠8 ♣J ♢Q ♡9 ♡J ♠4 ♢8 ♢10 ♠9 ♡4 ♠Q
Let’s add type hints to our card game. In other words, let’s annotate the functions create_deck()
, deal_hands()
, and play()
. The first challenge is that you need to annotate composite types like the list used to represent the deck of cards and the tuples used to represent the cards themselves.
With basic types like str
, float
, and bool
, adding type hints is as straightforward as using the type itself:
>>> name: str = "Guido"
>>> pi: float = 3.142
>>> centered: bool = False
With composite types, you are allowed to do the same:
>>> names: list = ["Guido", "Thomas", "Bobby"]
>>> version: tuple = (3, 7, 1)
>>> options: dict = {"centered": False, "capitalize": True}
>>> type(names[2])
<class 'str'>
>>> __annotations__
{'name': <class 'str'>, 'pi': <class 'float'>, 'centered': <class 'bool'>, 'names': <class 'list'>, 'version': <class 'tuple'>, 'options': <class 'dict'>}
However, this does not really tell the full story. What will be the types of names[2]
, version[0]
, and options["centered"]
? In this concrete case, you can see that they are str
, int
, and bool
, respectively. However, the type hints themselves give no information about this.
Instead, you should use the special types defined in the typing
module. These types add syntax for specifying the types of elements of composite types. You can write the following:
>>> from typing import Dict, List, Tuple
>>> names: List[str] = ["Guido", "Thomas", "Bobby"]
>>> version: Tuple[int, int, int] = (3, 7, 1)
>>> options: Dict[str, bool] = {"centered": False, "capitalize": True}
>>> __annotations__
{'name': <class 'str'>, 'pi': <class 'float'>, 'centered': <class 'bool'>, 'names': typing.List[str], 'version': typing.Tuple[int, int, int], 'options': typing.Dict[str, bool]}
Note that each of these types starts with a capital letter and that they all use square brackets to define item types:
names
is a list of strings.version
is a 3-tuple consisting of three integers.options
is a dictionary mapping strings to Boolean values.
The typing
module contains many more composite types, including Counter
, Deque
, FrozenSet
, NamedTuple
, and Set
. In addition, the module includes other kinds of types that you’ll see in later sections.
Let’s return to the card game. A card is represented by a tuple of two strings. You can write this as Tuple[str, str]
, so the type of the deck of cards becomes List[Tuple[str, str]]
. Therefore, you can annotate create_deck()
as follows:
def create_deck(shuffle: bool = False) -> List[Tuple[str, str]]:
"""Create a new deck of 52 cards"""
deck = [(s, r) for r in RANKS for s in SUITS]
if shuffle:
random.shuffle(deck)
return deck
The type hints might become quite oblique when you’re working with nested types, like the deck of cards. You may need to stare at List[Tuple[str, str]]
a bit before figuring out that it matches our representation of a deck of cards.
Now consider how you would annotate deal_hands()
:
def deal_hands(
deck: List[Tuple[str, str]]
) -> Tuple[
List[Tuple[str, str]],
List[Tuple[str, str]],
List[Tuple[str, str]],
List[Tuple[str, str]],
]:
"""Deal the cards in the deck into four hands"""
return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
That’s just terrible!
Remember that type annotations are regular Python expressions. That means that you can define your own type aliases by assigning them to new variables. You can, for instance, create Card
and Deck
type aliases:
from typing import List, Tuple
Card = Tuple[str, str]
Deck = List[Card]
Card
can now be used in type hints or in the definition of new type aliases, like Deck
in the example above. When you use these aliases, the annotations of deal_hands()
become much more readable:
def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
"""Deal the cards in the deck into four hands"""
return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
Type aliases are great for making your code and its intent clearer.