Python’s structural pattern matching is a game-changer. It’s like getting a shiny new tool in your programming toolbox. I’ve been playing with it since its introduction in Python 3.10, and I’m excited to share what I’ve learned.
Let’s start with the basics. Structural pattern matching is a way to check the structure of data and perform actions based on what you find. It’s more powerful than traditional if-else statements or switch-case constructs you might be familiar with from other languages.
Here’s a simple example to get us started:
def greet(name):
match name:
case "Alice":
return "Hi Alice, how's your day?"
case "Bob":
return "Hey Bob, what's up?"
case _:
return f"Hello, {name}!"
print(greet("Alice")) # Hi Alice, how's your day?
print(greet("Charlie")) # Hello, Charlie!
In this example, we’re matching against simple string values. But the real power of structural pattern matching comes when we start working with more complex data structures.
Let’s say we’re building a simple command-line calculator. We could use pattern matching to handle different types of operations:
def calculate(operation):
match operation:
case ("+", a, b):
return a + b
case ("-", a, b):
return a - b
case ("*", a, b):
return a * b
case ("/", a, b):
return a / b if b != 0 else "Error: Division by zero"
case _:
return "Invalid operation"
print(calculate(("+", 5, 3))) # 8
print(calculate(("*", 4, 2))) # 8
print(calculate(("/", 10, 0))) # Error: Division by zero
print(calculate(("%", 10, 3))) # Invalid operation
Here, we’re matching against tuples. The first element of the tuple is the operation, and the next two are the operands. This approach is much cleaner and more readable than a series of if-else statements.
One of the coolest features of structural pattern matching is its ability to work with nested structures. Let’s look at an example where we’re parsing a simple abstract syntax tree:
def evaluate(expr):
match expr:
case ("literal", value):
return value
case ("add", left, right):
return evaluate(left) + evaluate(right)
case ("multiply", left, right):
return evaluate(left) * evaluate(right)
case _:
raise ValueError(f"Unknown expression: {expr}")
print(evaluate(("literal", 5))) # 5
print(evaluate(("add", ("literal", 3), ("literal", 4)))) # 7
print(evaluate(("multiply", ("add", ("literal", 2), ("literal", 3)), ("literal", 4)))) # 20
This example shows how we can recursively evaluate nested expressions. It’s a powerful way to work with tree-like structures.
Another neat feature is the ability to use guards in our patterns. Guards are additional conditions that must be true for a pattern to match. Here’s an example:
def describe_number(n):
match n:
case n if n < 0:
return "negative"
case 0:
return "zero"
case n if n % 2 == 0:
return "positive even"
case _:
return "positive odd"
print(describe_number(-5)) # negative
print(describe_number(0)) # zero
print(describe_number(4)) # positive even
print(describe_number(7)) # positive odd
In this example, we’re using guards to further refine our patterns. This allows for more complex conditions than simple value matching.
One area where structural pattern matching really shines is in handling different types of objects. Let’s say we’re building a simple shape calculator:
class Circle:
def __init__(self, radius):
self.radius = radius
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def calculate_area(shape):
match shape:
case Circle(radius=r):
return 3.14 * r ** 2
case Rectangle(width=w, height=h):
return w * h
case _:
return "Unknown shape"
print(calculate_area(Circle(5))) # 78.5
print(calculate_area(Rectangle(4, 5))) # 20
print(calculate_area("triangle")) # Unknown shape
Here, we’re matching against different types of objects and extracting their attributes in one go. This is much more concise than the equivalent if-else structure with isinstance() checks.
Structural pattern matching also works great with dictionaries. This can be super useful when working with JSON-like data:
def process_user(user):
match user:
case {"name": str(name), "age": int(age)} if age >= 18:
return f"{name} is an adult."
case {"name": str(name), "age": int(age)}:
return f"{name} is a minor."
case {"name": str(name)}:
return f"{name}'s age is unknown."
case _:
return "Invalid user data."
print(process_user({"name": "Alice", "age": 30})) # Alice is an adult.
print(process_user({"name": "Bob", "age": 15})) # Bob is a minor.
print(process_user({"name": "Charlie"})) # Charlie's age is unknown.
print(process_user({"age": 25})) # Invalid user data.
In this example, we’re not only matching the structure of the dictionary but also the types of its values. We’re even using a guard to check if the user is an adult.
One of the most powerful features of structural pattern matching is the ability to capture variables. This allows us to extract values from our patterns and use them in our code:
def analyze_list(lst):
match lst:
case []:
return "Empty list"
case [x]:
return f"Single element list with {x}"
case [x, y]:
return f"Two element list with {x} and {y}"
case [x, *rest]:
return f"List with {x} as first element and {len(rest)} more elements"
print(analyze_list([])) # Empty list
print(analyze_list([1])) # Single element list with 1
print(analyze_list([1, 2])) # Two element list with 1 and 2
print(analyze_list([1, 2, 3, 4])) # List with 1 as first element and 3 more elements
Here, we’re using patterns to match different list structures and capture variables from them. The *rest
syntax is particularly powerful, allowing us to match any number of remaining elements.
Structural pattern matching can also be used to implement simple state machines. This can be really useful for parsing or processing sequential data:
def process_events(events):
state = "START"
for event in events:
match state, event:
case "START", "INIT":
state = "READY"
case "READY", "BEGIN":
state = "PROCESSING"
case "PROCESSING", "DATA":
pass # continue processing
case "PROCESSING", "END":
state = "FINISHED"
case _:
raise ValueError(f"Invalid state transition: {state} -> {event}")
return state
print(process_events(["INIT", "BEGIN", "DATA", "DATA", "END"])) # FINISHED
print(process_events(["INIT", "BEGIN", "DATA"])) # PROCESSING
This example shows how we can use pattern matching to implement a simple state machine. We’re matching against tuples of the current state and the incoming event.
One area where structural pattern matching really excels is in handling errors and exceptions. It provides a much cleaner way to handle different types of exceptions:
def safe_divide(a, b):
try:
result = a / b
except Exception as e:
match e:
case ZeroDivisionError():
return "Error: Division by zero"
case TypeError():
return "Error: Invalid operand types"
case _:
return f"Unexpected error: {str(e)}"
else:
return f"Result: {result}"
print(safe_divide(10, 2)) # Result: 5.0
print(safe_divide(10, 0)) # Error: Division by zero
print(safe_divide("10", 2)) # Error: Invalid operand types
This approach is much more readable than a series of except clauses, especially when you need to handle many different types of exceptions.
Structural pattern matching can also be used to implement simple parsers. This can be really useful for processing domain-specific languages or custom data formats:
def parse_command(command):
match command.split():
case ["move", ("up" | "down" | "left" | "right") as direction, distance] if distance.isdigit():
return f"Moving {direction} by {distance} units"
case ["rotate", ("clockwise" | "counterclockwise") as direction, angle] if angle.isdigit():
return f"Rotating {direction} by {angle} degrees"
case ["quit" | "exit"]:
return "Exiting program"
case _:
return "Invalid command"
print(parse_command("move up 5")) # Moving up by 5 units
print(parse_command("rotate clockwise 90")) # Rotating clockwise by 90 degrees
print(parse_command("quit")) # Exiting program
print(parse_command("jump left")) # Invalid command
In this example, we’re using pattern matching to parse simple commands. We’re using the |
operator to match multiple possibilities, and we’re using guards to ensure that numeric arguments are actually digits.
Structural pattern matching is not just for simple data types. It can also be used with custom classes, making it a powerful tool for working with complex domain models:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
@dataclass
class Circle:
center: Point
radius: float
@dataclass
class Rectangle:
top_left: Point
bottom_right: Point
def describe_shape(shape):
match shape:
case Circle(center=Point(x, y), radius=r):
return f"Circle at ({x}, {y}) with radius {r}"
case Rectangle(top_left=Point(x1, y1), bottom_right=Point(x2, y2)):
width = x2 - x1
height = y1 - y2
return f"Rectangle with width {width} and height {height}"
case _:
return "Unknown shape"
print(describe_shape(Circle(Point(0, 0), 5)))
# Circle at (0, 0) with radius 5
print(describe_shape(Rectangle(Point(0, 10), Point(5, 0))))
# Rectangle with width 5 and height 10
This example shows how we can use pattern matching with custom classes. We’re able to extract nested attributes in a single pattern, making our code much more concise and readable.
Structural pattern matching can also be used to implement simple interpreters. This can be really useful for evaluating domain-specific languages or mathematical expressions:
def evaluate(expr):
match expr:
case int(n) | float(n):
return n
case (op, left, right) if op in {'+', '-', '*', '/'}:
l, r = evaluate(left), evaluate(right)
match op:
case '+': return l + r
case '-': return l - r
case '*': return l * r
case '/': return l / r if r != 0 else float('inf')
case _:
raise ValueError(f"Invalid expression: {expr}")
print(evaluate(5)) # 5
print(evaluate(('+', 3, 4))) # 7
print(evaluate(('*', ('+', 2, 3), 4))) # 20
print(evaluate(('/', 10, ('+', 1, 1)))) # 5.0
In this example, we’re using pattern matching to evaluate simple mathematical expressions. We’re able to handle both literal values and nested expressions in a very concise way.
Structural pattern matching is not just for data processing. It can also be used to implement simple AI decision-making systems:
def ai_decision(game_state):
match game_state:
case {"player_health": ph, "enemy_health": eh} if ph < 20 and eh > 50:
return "Retreat and heal"
case {"player_health": ph, "enemy_health": eh} if ph > eh:
return "Attack aggressively"
case {"player_ammo": ammo} if ammo < 10:
return "Conserve ammo and use melee attacks"
case {"enemy_type": "boss", "boss_weak_point": wp}:
return f"Target the boss's weak point: {wp}"
case _:
return "Continue with default strategy"
print(ai_decision({"player_health": 15, "enemy_health": 60}))
# Retreat and heal
print(ai_decision({"player_health": 80, "enemy_health": 40}))
# Attack aggressively
print(ai_decision({"player_ammo": 5, "enemy_health": 100}))
# Conserve ammo and use melee attacks
print(ai_decision({"enemy_type": "boss", "boss_weak_point": "glowing orb"}))
# Target the boss's weak point: glowing orb
This example shows how we can use pattern matching to implement a simple AI decision-making system. We’re able to handle different game states and make decisions based on complex conditions in a very readable way.
Structural pattern matching is a powerful feature that can make your Python code more expressive and easier to read. It’s particularly useful when working with complex data structures or implementing algorithms that need to handle many different cases.
However, it’s important to use it judiciously. While it can greatly simplify certain types of code, overusing it can make your code harder to understand for developers who aren’t familiar with the syntax. As with any programming feature, the key is to use it where it makes your code clearer and more maintainable.
In conclusion, structural pattern matching is a exciting addition to Python. It provides a powerful new tool for handling complex data structures and control flow. Whether you’re working on data processing, parsing, or AI, it’s definitely worth adding to your Python toolkit. Happy coding!