In the previous post, we talked about generators. We described them as special kinds of iterators that allow two-way data transfer between the calling program and the generator itself. This property can be leveraged to implement coroutines.
Coroutines are code structures that can cooperatively pass control to one another to complete a shared task. The term coroutine (short for cooperative routine) comes from this collaborative behavior.
To make this kind of cooperation possible, coroutines have several unique features. They can suspend execution multiple times while preserving their internal state, transfer control to another block of code, and later resume execution exactly where they left off. In addition, they can receive data each time they resume, and send data back whenever they suspend.
Let’s illustrate how coroutines work using a simple example.
We’ll model a card game played by three players using a standard French deck of playing cards. Each player starts with five cards. The remaining deck is placed face up on the table.
Players take turns in a fixed order. The rule is that a player must play a card that either matches the suit of the top card on the table, or, if no such card is available, one with an equal or higher rank. If the player cannot play any card, they must draw the top card from the deck. The winner is the player who first runs out of cards.
Here’s one possible implementation of the game:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
from collections import namedtuple from random import shuffle Card = namedtuple('Card', 'suit value') card_suits = (chr(u) for u in range(0x2660, 0x2664)) deck = [Card(suit, value) for suit in card_suits for value in range(2, 15)] def player(hand: list[Card]): """Generator function implementing a coroutine for a player""" discarded_card = None while True: top_card: Card = yield discarded_card same_suit = {c for c in hand if top_card.suit == c.suit} higher_value = {c for c in hand if c.value >= top_card.value} if same_suit: discarded_card = same_suit.pop() elif higher_value: discarded_card = higher_value.pop() else: discarded_card = None hand.append(top_card) # If no cards remain after discarding, raise an exception # to signal this event to the calling program. if discarded_card is not None: hand.remove(discarded_card) if not hand: raise GeneratorExit # Shuffle the deck thoroughly. for _ in range(10): shuffle(deck) # Create the players; each gets 5 cards. players = [*zip(('Eve', 'John', 'Sarah'), [player(deck[:5]), player(deck[5:10]), player(deck[10:15])])] # The remaining cards stay in the deck after dealing. deck = deck[15:] # Initialize coroutines (prime the generators). for _, p in players: p.send(None) # Game loop while True: # Get the next player in turn current_player = players.pop(0) name, p = current_player print('\nCurrent player:', name) # The top card of the table deck. top_card: Card = deck[-1] print(f'Top card on the table: {top_card.suit}{top_card.value}') # The player either plays a matching card or draws the top card # if they cannot play. If their hand becomes empty, they win. try: played_card: Card = p.send(top_card) except GeneratorExit: print(f'{name} has won by playing their last card!') break if played_card is None: deck.remove(top_card) print(f'{name} could not play and drew the top card.') else: deck.append(played_card) print(f'{name} played: {played_card.suit}{played_card.value}') # Put the player at the end of the list for the next round. players.append(current_player) |
The first thing we do in the program is to create the 52 cards as namedtuple objects. Then we represent each player by a generator-based coroutine. To achieve this, we define a generator function that takes the player’s initial five cards as a list.
Inside the function body, the variable on the left-hand side of yield expression receives the top card from the deck on the table, while the right-hand side of yield holds the card the player decides to play (or None if no card can be played).
We then check whether the player has a playable card. If so, one is selected—preferably a card matching the suit of the table card. If none match by suit, we choose one based on value. If the player runs out of cards after discarding, this is signaled by raising a dedicated exception.
After shuffling the deck, we create the player coroutines and initialize them. Then the game loop begins:
- We iterate over the list of players and send the top card on the table to the current player using the send() method.
- If the player cannot play, they must keep the top card, which is then removed from the top of the deck.
- If the player could play a card, it becomes the new top card on the table.
- Once a player’s turn ends, they are placed at the end of the list to await their next turn.
- When a player wins, we handle the exception and print the winner’s name. Then the game ends (exiting the loop).
Below you can see the output of a run.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 |
Current player: Eve Top card on the table: ♢5 Eve played: ♢4 Current player: John Top card on the table: ♢4 John played: ♢8 Current player: Sarah Top card on the table: ♢8 Sarah played: ♢7 Current player: Eve Top card on the table: ♢7 Eve played: ♡12 Current player: John Top card on the table: ♡12 John played: ♡13 Current player: Sarah Top card on the table: ♡13 Sarah played: ♡8 Current player: Eve Top card on the table: ♡8 Eve played: ♡11 Current player: John Top card on the table: ♡11 John played: ♡9 Current player: Sarah Top card on the table: ♡9 Sarah played: ♢11 Current player: Eve Top card on the table: ♢11 Eve played: ♣14 Current player: John Top card on the table: ♣14 John could not play and drew the top card. Current player: Sarah Top card on the table: ♢11 Sarah could not play and drew the top card. Current player: Eve Top card on the table: ♡9 Eve has won by playing their last card! |
Note that the game doesn’t always produce a winner; in such cases, execution does not stop. Of course, this could also be managed, and the program could be refined in many places, but such additional code would take the focus away from the primary goal, which was to demonstrate how coroutines work.
You can learn more about coroutines and other language constructs used in this example in the e-book Python Knowledge Building Step by Step.