Skip to Content
Course content

61: Structural Pattern Matching with Classes and Sequences

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, you've probably relied on if/elif/else chains to handle different types of data. They work, but once you start dealing with complex nested structures—like a mix of custom objects and lists—your code starts looking like a pyramid of indentation. That's where structural pattern matching comes in. It's not just a "switch" statement; it's a way to describe the shape of the data you're looking for.

Defining our Game Events

To see this in action, let's build a simple event processor for a 2D game. We have different types of actions: some are structured as classes (for complex state), and others are simple sequences (for quick commands). Let's start by defining a couple of classes to represent movement and combat.

class Move:
    __match_args__ = ("x", "y")
    def __init__(self, x, y):
        self.x = x
        self.y = y

class Attack:
    __match_args__ = ("target", "damage")
    def __init__(self, target, damage):
        self.target = target
        self.damage = damage

Notice those __match_args__ tuples. I'm adding those because they tell Python, "When someone matches this class positionally, here is the order of the attributes." Without them, you'd have to use keyword arguments in your patterns, which is fine, but positional matching is much cleaner.

Handling the Simple Stuff with Classes

Now, let's write a function to process these events. Instead of checking isinstance() over and over, we use match. This allows us to "unpack" the attributes of the class directly into variables we can use inside the case block.

def handle_event(event):
    match event:
        case Move(x, y):
            print(f"Character moving to coordinates: {x}, {y}")
        case Attack(target, damage):
            print(f"Dealing {damage} damage to {target}!")
        case _:
            print("Unknown event received.")

I love this because the pattern Move(x, y) does two things at once: it checks if event is an instance of Move AND it assigns the values of event.x and event.y to local variables. It's incredibly concise.

Tackling Sequence-based Commands

In many games, you might have a console where you type commands. These usually come in as lists or tuples. Let's say we want to support commands like ["teleport", 10, 20] or ["wait"]. We can mix sequence matching right into the same match block.

def handle_event(event):
    match event:
        case Move(x, y):
            print(f"Character moving to coordinates: {x}, {y}")
        case Attack(target, damage):
            print(f"Dealing {damage} damage to {target}!")
        case ["teleport", x, y]:
            print(f"Magic teleport to {x}, {y}!")
        case ["wait"]:
            print("Character is idling...")
        case _:
            print("Unknown event received.")

Python is now looking at the structure. If it sees a list with exactly three elements and the first one is the string "teleport", it triggers that block. If it's a list with one element "wait", it takes that path.

The Order Problem (and how I messed it up)

Here is where I usually trip up when I'm coding this in a hurry. Let's say I want to add a "catch-all" for any list command that I haven't specifically defined yet, maybe just to log it. I might do this:

# WARNING: This version has a bug
def handle_event(event):
    match event:
        case Move(x, y):
            print(f"Moving to {x}, {y}")
        case [command, *args]: # Catch-all for any list
            print(f"Executing generic command {command} with args {args}")
        case ["wait"]: 
            print("Character is idling...")
        case _:
            print("Unknown event.")

If I pass ["wait"] into this, it will never hit the ["wait"] case. Why? Because ["wait"] matches the pattern [command, *args] perfectly (command becomes "wait" and args becomes an empty list). Pattern matching is sequential. I effectively put a giant net in front of a small hole.

The fix is simple: always put your most specific patterns at the top and your most general ones at the bottom. I'll move the generic list handler below the specific "wait" and "teleport" commands.

Adding a bit of logic with Guards

Sometimes, matching the structure isn't enough. You might need to match the structure and check a condition. We do this with "guards" using the if keyword inside the case.

def handle_event(event):
    match event:
        case Move(x, y) if x < 0 or y < 0:
            print("Can't move into negative coordinates!")
        case Move(x, y):
            print(f"Moving to {x}, {y}")
        case ["teleport", x, y] if x > 100 or y > 100:
            print("Teleport destination is out of bounds!")
        case ["teleport", x, y]:
            print(f"Teleporting to {x}, {y}")
        case ["wait"]:
            print("Idling...")
        case [command, *args]:
            print(f"Generic command {command} with {args}")
        case _:
            print("Unknown event.")

Now the logic is read like a story: "If it's a Move and the coordinates are negative, do this. Otherwise, if it's just a Move, do that." It keeps the validation logic tightly coupled with the data structure it's validating.




📋 Practical Task

Build a Combat Log Parser

You are building a system that parses a combat log from a RPG. The log is a list containing different types of events. Some events are Damage objects, and some are lists representing "Status Effect" changes.

Your task:

  • Create a Damage class with __match_args__ for amount and type (e.g., "Fire", "Physical").
  • Write a function parse_log_entry(entry) that uses structural pattern matching to:
    • Handle Damage objects: If the type is "Critical", print "CRITICAL HIT for [amount]!". Otherwise, print "Took [amount] [type] damage."
    • Handle lists with the format ["buff", "strength", duration]: Print "Strength increased for [duration] seconds."
    • Handle lists with the format ["debuff", "poison", duration]: Print "Poisoned for [duration] seconds."
    • Handle any other list starting with "buff" or "debuff" as a "Generic status change."
    • Handle everything else as "Unrecognized log entry."

Test your function with a list of entries like: Damage(50, "Physical"), Damage(100, "Critical"), ["buff", "strength", 10], ["debuff", "slow", 5], and "Random noise".

Rating
0 0

There are no comments for now.

to be the first to leave a comment.