Python case|match - structural pattern matching

Python case|match - structural pattern matching

After many proposals to add a switch/case like syntax to Python failed, a recent proposal by Python language creator Guido van Rossum and a number of other contributors has been accepted for Python 3.10: structural pattern matching . Structural pattern matching not only makes it possible to perform simple switch/case style matches, but also supports a broader range of use cases.

Python structural pattern matching

Structural pattern matching introduces the match/case statement and the pattern syntax to Python. It takes an object, tests the object against one or more match patterns, and takes an action if it finds a match. To apply structural pattern matching, you will need to use two new keywords: match and case.

day = 7
match day:
    case 6:
        print("Saturday")
    case 7:
        print("Sunday")
    case _:
        print("Almost Weekend")

# output
Sunday

match command:
    case "quit":
        quit()
    case "reset":
        reset()
    case unknown_command:
        print (f"Unknown command '{unknown_command}')

Each case statement is followed by a pattern to match against. In the above example we’re using simple strings as our match targets, but more complex matches are possible.

Let’s explore some more challenging examples. For example, you can specify multiple conditions for a single case block like this:

month = 'jan'
match month:
    case 'jan' | 'mar' | 'may' | 'jul' | 'aug' | 'oct' | 'dec':
        print(31)
    case 'apr' | 'jun' | 'sep' | 'nov':
        print(30)
    case 'feb':
        print(28)
    case _:
        print('Bad month!')

# output
31

This snippet will print out the number of days in a month, given by the month variable. To use multiple values, we separate them with the | (or) operator. We could take it even further and check for leap year, using the guard expression:

month = 'jan'
match month:
    case 'jan' | 'mar' | 'may' | 'jul' | 'aug' | 'oct' | 'dec':
        print(31)
    case 'apr' | 'jun' | 'sep' | 'nov':
        print(30)
    case 'feb' if is_leap_year():
        print(29)
    case 'feb':
        print(28)
    case _:
        print('Bad month!')

Suppose that is_leap_year is a function that returns true if the current year is a leap year. In this case, Python will firstly evaluate the condition, and, if met, print 29. If not, it will go to the next condition and print 28.

Packing and unpacking

Structural pattern matching provides much more features than basic switch-case statements. You could use it to evaluate complex data structures and extract data from them. For example, suppose you store date in a tuple of form (day, month, year), all integers. This code will print out what season is it, along with day and year:

date = (29, 6, 2021)
match date:
    case (day, 12, year) | (day, 1, year) | (day, 2, year):
        print(f'Winter of {year}, day {day}')
    case (day, 3, year) | (day, 4, year) | (day, 5, year):
        print(f'Spring of {year}, day {day}')
    case (day, 6, year) | (day, 7, year) | (day, 8, year):
        print(f'Summer of {year}, day {day}')
    case (day, _, year):
        print(f'Fall of {year}, day {day}')
    case _:
        print('Invalid data')

# output
Summer of 2021, day 29

You can see we are using the tuple syntax to capture the values. By placing day and year names in place of values, we are telling Python we want those values extracted and made available as variables. By placing actual number value in place of month, we are checking only for that value. When we check for fall, you can notice we are using the _ symbol again. It is a wildcard and will match all other month values that were missed by the previous checks. Lastly, we have one more wildcard case to account for badly formatted data.

Matching against multiple elements with Python structural pattern matching

The key to working most effectively with pattern matching is not just to use it as a substitute for a dictionary lookup. It’s to describe the structure of what you want to match. This way, you can perform matches based on the number of elements you’re matching against, or their combination.

Here’s a slightly more complex example. Here, the user types in a command, optionally followed by a filename.

command = input()
match command.split():
    case ["quit"]:
        quit()
    case ["load", filename]:
        load_from(filename)
    case ["save", filename]:
        save_to(filename)
    case _:
        print(f"Command '{command}' not understood")

Let’s examine these cases in order:

  • case ["quit"]: tests if what we’re matching against is a list with just the item "quit", derived from splitting the input.
  • case ["load", filename]: tests if the first split element is the string "load", and if there's a second string that follows. If so, we store the second string in the variable filename and use it for further work. Same for case ["save", filename]:.
  • case _: is a wildcard match. It matches if no other match has been made up to this point. Note that the underscore variable _ doesn’t actually bind to anything; the name _ is used as a signal to the match command that the case in question is a wildcard. (That's why we refer to the variable command in the body of the case block; nothing has been captured)

Patterns in Python structural pattern matching

Small example first:

for item in [[1, 2], [9, 10], [1, 2, 3], [1], [0, 0, 0, 0, 0]]:
    match item:
        case [x]:
            print(f"single value: {x}")
        case [x, y]:
            print(f"two values: {x} and {y}")
        case [x, y, z]:
            print(f"three values: {x}, {y} and {z}")       
        case _:
            print("too many values")

# output
two values: 1 and 2
two values: 9 and 10
three values: 1, 2 and 3
single value: 1
too many values

And example with check first or second item in collection:

for item in [[1,2], [9,10], [3,4], [1,2,3], [1], [0,0,0,0,0]]:
    match item:
        case [x]:
            print(f"single value: {x}")
        case [1, y]:
            print(f"two values: 1 and {y}")
        case [x, 10]:
            print(f"two values: {x} and 10")
        case [x, y]:
            print(f"two values: {x} and {y}")
        case [x, y, z]:
            print(f"three values: {x}, {y} and {z}")       
        case _:
            print("too many values")

# output
two values: 1 and 2
two values: 9 and 10
two values: 3 and 4
three values: 1, 2 and 3
single value: 1
too many values

Patterns can be simple values, or they can contain more complex matching logic. Some examples:

  • case "a": Match against the single value "a".

  • case ["a","b"]: Match against the collection ["a","b"].

  • case ["a", value1]: Match against a collection with two values, and place the second value in the capture variable value1.

  • case ["a", *values]: Match against a collection with at least one value. The other values, if any, are stored in values. Note that you can include only one starred item per collection (as it would be with star arguments in a Python function).

  • case ("a"|"b"|"c"): The or operator (|) can be used to allow multiple cases to be handled in a single case block. Here, we match against either "a", "b", or "c".

  • case ("a"|"b"|"c") as letter: Same as above, except we now place the matched item into the variable letter.

  • case ["a", value] if <expression>: Matches the capture only if expression is true. Capture variables can be used in the expression. For instance, if we used if value in valid_values, the case would only be valid if the captured value value was in fact in the collection valid_values.

  • case ["z", _]: Any collection of items that begins with "z" will match.

  • Match sequences using list or tuple syntax (like Python’s existing iterable unpacking feature)

  • Match mappings using dict syntax

  • Use * to match the rest of a list

  • Use ** to match other keys in a dict

  • Match objects and their attributes using class syntax

  • Include or patterns with |

  • Capture sub-patterns with as

  • Include an if guard clause

Here is a program that will match a list with any number of elements.

for thing in [[1,2,3,4], ['a','b','c'], "this won't be matched"]:
    match thing:
        case [*y]:
            for i in y:
                print(i)
        case _:
            print("unknown")

# output
1
2
3
4
a
b
c
unknown

example:

items = [1, 2, 3, 4, 5, 6, 7, 8, 9]

match items:
    case [1, *x]:
        print(x)
# output
[2, 3, 4, 5, 6, 7, 8, 9]

match items:
    case [*x, 9]:
        print(x)
# output
[1, 2, 3, 4, 5, 6, 7, 8]

match items:
    case [2|1, *x]:
        print(x)
# output
[2, 3, 4, 5, 6, 7, 8, 9]

match items:
    case [1|2, *x, 9]:
        print(x)
# output
[2, 3, 4, 5, 6, 7, 8]

example:

dialog = [
             ["The Dead Parrot Sketch"],
             ["Cast", "Customer", "Shop Owner"],
             ["Customer", "He's gone to meet his maker."],
             ["Owner", "Nah, he's resting."],
             ["Customer", "He should be pushing up the daisies."],
             ["Owner", "He's pining for the fjords"],  
             ["Customer", "He's shuffled off his mortal coil."],
             ["Customer", "This is a dead parrot!"]
]

for line in dialog:
    match line:
        case [title]:
            print(title)
        case ["Cast", *actors]:
            print("Cast:")
            for a in actors: print(a)
            print("---")
        case [("Customer"|"Owner") as person, line] :
            print(f"{person}: {line}")

# output
The Dead Parrot Sketch
Cast:
Customer
Shop Owner
---
Customer: He's gone to meet his maker.
Owner: Nah, he's resting.
Customer: He should be pushing up the daisies.
Owner: He's pining for the fjords
Customer: He's shuffled off his mortal coil.
Customer: This is a dead parrot!

Dictionary structural pattern matching

Similarly, we can use ** to match the remainder of a dictionary. But first, let us see what is the behaviour when matching dictionaries:

d = {0: "zero", 1: "one", 2: "two", 3: "three"}
match d:
    case {2: "two"}:
        print("yes")

# output
yes

While d has a key 2 with a value "two", there is a match and we enter the statement.

Double asterisk **

However, if you want to know what the original dictionary had that was not specified in the match, you can use a ** wildcard:

d = {0: "zero", 1: "one", 2: "two", 3: "three"}
match d:
    case {2: "two", **remainder}:
        print(remainder)

# output
{0: 'zero', 1: 'one', 3: 'three'}

Finally, you can use this to your advantage if you want to match a dictionary that contains only what you specified:

d = {0: "zero", 1: "one", 2: "two", 3: "three"}
match d:
    case {2: "two", **remainder} if not remainder:
        print("Single key in the dictionary")
    case {2: "two"}:
        print("Has key 2 and extra stuff.")

# output
Has key 2 and extra stuff.

You can also use variables to match the values of given keys:

d = {0: "zero", 1: "one", 2: "two", 3: "three"}
match d:
    case {0: zero_val, 1: one_val}:
        print(f"0 mapped to {zero_val} and 1 to {one_val}")

# output
0 mapped to zero and 1 to one

Class matchers

The most advanced feature of Python’s structural pattern matching system is the ability to match against objects with specific properties. Consider an application where we're working with an object named media_object, which we want to convert into a .jpg file and return from the function.

example 1

match media_object:
    case Image(type="jpg"):
        # Return as-is
        return media_object
    case Image(type="png") | Image(type="gif"):
        return render_as(media_object, "jpg")
    case Video():
        raise ValueError("Can't extract frames from video yet")
    case other_type:
        raise Exception(f"Media type {media_object} can't be handled yet")

In each case above, we're looking for a specific kind of object, sometimes with specific attributes. The first case matches against an Image object with the type attribute set to "jpg". The second case matches if type is "png" or "gif". The third case matches any object of type Video, no matter its attributes. And the final case is our catch-all if everything else fails.

You can also perform captures with object matches:

match media_object:
    case Image(type=media_type):
        print (f"Image of type {media_type}")

example 2

Suppose you have a class like this:

class Vector:
    speed: int
    acceleration: int

You can use its attributes to match Vector objects:

vec = Vector()
vec.speed = 4
vec.acceleration = 1

match vec:
    case Vector(speed=0, acceleration=0):
        print('Object is standing still')
    case Vector(speed=speed, acceleration=0):
        print(f'Object is travelling at {speed} m/s')
    case Vector(speed=0, acceleration=acceleration):
        print(f'Object is accelerating from standing still at {acceleration} m/s2')
    case Vector(speed=speed, acceleration=acceleration):
        print(f'Object is at {speed} m/s, accelerating at {acceleration} m/s2')
    case _:
        print('Not a vector')

# output
Object is at 4 m/s, accelerating at 1 m/s2

define a default order for class arguments

Now, I don't know if you noticed, but didn't all the speed= and acceleration= in the code snippet above annoy you? Every time I wrote a new pattern for a Vector instance, I had to specify what argument was speed and what was acceleration. For classes where this order is not arbitrary, we can use __match_args__ to tell Python how we would like match to match the attributes of our object.

Here is a shorter version of the example above, making use of __match_args__ to let Python know the order in which arguments to Vector should match:

class Vector2:
    __match_args__ = ("speed", "acceleration")
    def __init__(self, speed:int, acceleration:int):
        self.speed = speed
        self.acceleration = acceleration

def describe_vector(vec):
    match vec:
        case Vector2(0, 0):
            print('Object is standing still')
        case Vector2(speed, 0):
            print(f'Object is travelling at {speed} m/s')
        case Vector2(0, acceleration):
            print(f'Object is accelerating from standing still at {acceleration} m/s2')
        case Vector2(speed, acceleration):
            print(f'Object is at {speed} m/s, accelerating at {acceleration} m/s2')
        case _:
            print('Not a vector')

describe_vector(Vector2(0, 0))
# Object is standing still
describe_vector(Vector2(4, 0))
# Object is travelling at 4 m/s
describe_vector(Vector2(0, 6))
# Object is accelerating from standing still at 6 m/s2
describe_vector(Vector2(8, 12))
# Object is at 8 m/s, accelerating at 12 m/s2

More Python structural pattern matching examples

example 1

Another place match might be useful is when validating the structure of JSON from an HTTP request:

try:
    obj = json.loads(request.body)
except ValueError:
    raise HTTPBadRequest(f'invalid JSON: {request.body!r}')

match obj:
    case {
        'action': 'sign-in',
        'username': str(username),
        'password': str(password),
        'details': {'email': email, **other_details},
    } if username and password:
        sign_in(username, password, email=email, **other_details)
    case {'action': 'sign-out'}:
        sign_out()
    case _:
        raise HTTPBadRequest(f'invalid JSON structure: {obj}')

example 2

command = input("What are you doing next? ")
match command.split():
    case ["quit"]:
        print("Goodbye!")
        quit_game()
    case ["look"]:
        current_room.describe()
    case ["get", obj]:
        character.get(obj, current_room)
    case ["drop", *objects]:
        for obj in objects:
            character.drop(obj, current_room)
    case ["go", direction] if direction in current_room.exits:
        current_room = current_room.neighbor(direction)
    case ["go", _]:
        print("Sorry, you can't go that way")
    case _:
        print(f"Sorry, I couldn't understand {command!r}")

example 3

command = input("What are you doing next? ")
fields = text.split()
n = len(fields)

if fields == ["quit"]:
    print("Goodbye!")
    quit_game()
elif fields == ["look"]:
    current_room.describe()
elif n == 2 and fields[0] == "get":
    obj = fields[1]
    character.get(obj, current_room)
elif n >= 1 and fields[0] == "drop":
    objects = fields[1:]
    for obj in objects:
        character.drop(obj, current_room)
elif n == 2 and fields[0] == "go":
    direction = fields[1]
    if direction in current_room.exits:
        current_room = current_room.neighbor(direction)
    else:
        print("Sorry, you can't go that way")
else:
    print(f"Sorry, I couldn't understand {command!r}")

example 4

def factorial(n):
    match n:
        case 0 | 1:
            return 1
        case _:
            return n * factorial(n - 1)

factorial(5)

# output
120

SUBSCRIBE FOR NEW ARTICLES

@
comments powered by Disqus