Writing/The Unbearable Lightness of Coding
Chapter 21 · 14 min read
The Match-Case Statement: So Much More than Switch
Another debt from before that I think we're ready to repay is the match-case statement: a conditional construct so powerful, that I really felt I should cover functional programming, objects and magic methods before we can delve into it seriously.
As you might recall, we saw that it can be used as a switch-statement:
>>> value = 2
>>> match value:
... case 1:
... print('one')
... case 2:
... print('two')
... case 3:
... print('three')
two
Which is nice — and we don't even need break statements at the end of each case, like in some other languages. Fallback behavior, when we combine several cases into one, can be achieved with the bitwise-or operator:
>>> match value:
... case 1 | 2 | 3:
... print('one, two or three')
one, two or three
And a default clause is conventionally written like so:
>>> value = 0
>>> match value:
... # actual cases
... case _:
... print('invalid value')
invalid value
Structural Pattern Matching
And that's about all the functionality we'd expect from a switch-statement. But actually, this was not the main inspiration for the match-case statement: it comes from what's known as structural pattern matching in functional languages like Haskell and ML. To give you a taste, a Haskell function can seem, in a sense, like a switch-statement:
printNumber :: Int -> String
printNumber 1 = "one"
printNumber 2 = "two"
printNumber 3 = "three"
printNumber _ = "invalid value"
But where it really shines is when matching sequences and other complex structures. For example, lists of different lengths and nesting levels can be handled like so:
printSequence :: (Int, Int) -> String
printSequence (a) = "one item"
printSequence ((a, b), (c, d)) = "two pairs"
printSequence (a:b:c) = "two items or more"
And a pair denoting a point can be matched this way:
printPoint :: (Int, Int) -> String
printPoint (0, 0) = "origin point"
printPoint (0, y) = "point on x-axis: " ++ show y
printPoint (x, 0) = "point on y-axis: " ++ show x
printPoint (x, y) = "point at (" ++ show x ++ ", " ++ show y ++ ")"
With Python's match-case statement, we can do the same:
>>> seq = [(1, 2), (3, 4)]
>>> match seq:
... case [a]:
... print('one item')
... case [[a, b], [c, d]]:
... print('two pairs')
... case [a, b, *c]:
... print('two items or more')
two pairs
Or:
>>> point = (0, 1)
... match point:
... case (0, 0):
... print('origin point')
... case (0, y):
... print(f'point on x-axis: {y}')
... case (x, 0):
... print(f'point on y-axis: {x}')
... case (x, y):
... print(f'point at ({x}, {y})')
point on x-axis: 1
That is to say, it understand all the syntax allowed in assignments and more — not only does it let us do conditional unpacking, taking effect and following through with the case only if the value can be unpacked as requested (whereas assignment would throw an error if it can't), but it also lets us match literal values (like (0, 0)), and even mix the two forms together (like (0, y) and (x, 0)). We can throw in the the bitwise-or operator, too:
>>> name = 'Ms. Alice Lidell'
>>> match name.split():
... case ('Mr.' | 'Ms.' | 'Mrs.'), first_name, last_name:
... print(f'Hello, {first_name} {last_name}!')
Hello, Alice Lidell!
And if we want to capture it as well (given that it can take one of several values):
>>> match name:
... case ("Mr." | "Ms." | "Mrs.") as title, first_name, last_name:
... print(fHello, {title} {first_name} {last_name!')
Hello, Ms. Alice Lidell!
Catch-All Cases
Notice, by the way, how parenthesis and square brackets are interchangeable, and how they can be omitted at the outermost level, just like in assignment. Also like in assignment, unpacking a single value does require them, otherwise Python will just match the whole sequence to that name:
>>> match value:
... case x:
... print('matches anything')
matches anything
This also explains the syntax of the default case, _: it's not that it's treated differently, we just assign "anything" to our conventional "don't care" name, which we could have used similarly as part of a more elaborate pattern:
>>> match seq:
... case [x, *_]:
... print(f'first item: {x}')
first item: (1, 2)
Likewise, it follows that if we do care about the default value, we can simply give it an explicit "do care" name:
>>> match value:
... # actual cases
... case other:
... print(f'invalid value: {other}')
invalid value: 0
Unfortunately, it also means we can't match values to variables: case x will capture a value into x even if such a name is defined, instead of comparing to the value associated with it:
>>> x = 1
>>> match 2: # definitely not 1
... case x:
... print(f'matched {x}')
matched 2
>>> # and actually overwrite it:
>>> x
2
So if we want to have such comparison, we need to make it unambiguous by using dotted names: either as class attribute (A.x), or dedicated enums (like Color.RED), or just scoped variables (like math.pi); this way, Python can tell we're comparing rather than assigning — and note that this also means we can't use the match-case statement to set an attribute:
>>> class A:
... x = 1
>>> match 2:
... case A.x:
... print(f'matched {x}')
# no match, and also:
>>> A.x
1
Dictionary Destructuring at Last
Remember our fantasizing about a way to destructure dictionaries as well? Well, here we can do that, as part of a broader pattern of dictionary matching:
>>> d = {'x': 1, 'y': 2}
>>> match d:
... case {'x': 1}: # exact match
... print('x is 1')
... case {'y': y}: # destructuring!
... print(f'y is {y}')
x is 1
Note that unlike sequences, which require an exact match (except when using the star operator), dictionaries only check the specified keys; extra keys are allowed and ignored, or can be captured with the double star operator:
>>> match d:
... case {'y': y, **rest}:
... print(f'y is {y}, and the rest is {rest}')
y is 2, and the rest is {'x': 1}
And just like in our reveries, it lets us combine arbitrarily complex nesting, and extract exactly the bits we need with unparalleled precision:
>>> d = {'x': [1]}
>>> match d:
... case {'x': [x]}
... print(x)
1
>>> # or even:
>>> d = {'x': [{'y': 2}]}
>>> match d:
... case {'x': [{'y': y}]}:
... print(y)
2
Matching Objects
In fact, the match-case statement lets us do even more: we can request that a certain value matches a certain type, and further request that some particular attributes of it match some subpattern:
>>> class B:
... def __init__(self, x):
... self.x = x
>>> value = B(2)
>>> match value:
... case A():
... print('a')
... case B(x=1):
... print('b1')
... case B(x=2):
... print('b2')
b2
The first case matches any object of class A (or a subclass of it; the same as what the builtin isinstance function would do); the second one matches any objects of class B, whose x attribute equals 1; and the third one matches our case, where it's 2.
Note, however, that in all cases — although, when dealing with classes in particular — the order of the case-clauses matters; Python will select the first one that matches the target, even if there's a more fitting case. If B was a subclass of A:
>>> class B(A):
... # same as before
>>> value = B(1)
>>> match value:
... case A():
... print('a')
... case B(x=1): # fits best
... print('b')
a
We might not get the result we expect, so we should always put the more general cases after the more specific ones.
And just like with dictionaries, not only can we match an attribute to a specific value, we can also capture its value (requesting, effectively, only that it exists):
>>> match value:
... case B(x=x):
... print(f'b.x = {x}')
b.x = 1
It's a bit jarring, though, this B(x=x); it would be nice if we could do B(x) and Python would know what we mean. Unfortunately, attributes don't have a well-defined order (with just one attribute, it's immaterial — but what if there were several? Is it the order they're passed to __init__, or the order in which they're assigned within it?); but if we intend to do this often, we can add the magic __match_args__ attribute to set such an order explicitly. For example:
>>> class Point:
... __match_args__ = 'x', 'y'
... def __init__(self, x, y):
... self.x = x
... self.y = y
>>> point = Point(0, 1)
>>> match point:
... case Point(x, y):
... print(f'point at ({x}, {y})')
point at (0, 1)
Matching Types
This type checking works for native types, like numbers and strings, just as well:
>>> value = 'hello world'
>>> match value:
... case int():
... print('number')
... case str():
... print('string')
string
They even come with sensible __match_args__, so we can do:
>>> value = 'hello world'
>>> match value:
... case int(n):
... print(f'number: {n}')
... case str(s):
... print(f'string: {s}')
string: hello world
It might seem a bit artificial in this case, since we could have used the original value just as easily — but consider what happens when we start composing all these rules:
>>> match point:
... case Point(x=int(), y=int()):
... print('integer point')
integer point
With this statement, we managed to assert that the value is of class Point, has the x and y attributes, and they're both integers — but in doing so we lost the ability to bind them to actual names for the case-clause. Thus, it's a way to recapture them:
>>> match point:
... case Point(x=int(x), y=int(y)):
... print(f'integer point: ({x}, {y})')
integer point: (0, 1)
Hell, if __match_args__ is defined, we can even do Point(int(x), int(y)). This works well for native types with a single value, but for custom classes there's a hiccup:
>>> match value:
... case Line(source=Point(), target=Point()):
... print('line from ? to ?')
Again, we lose the bindings between the objects and some names, to be used later — and in this case, we can't do Point(s) and Point(t), since according to our previous logic, this would bind to the first attribute in __match_args__ (or throw an error if it's missing). So instead, we use the same as-clause we've already seen for literals:
>>> match value:
... case Line(source=Point() as s, target=Point() as t):
... print(f'line from {s} to {t}')
Some Real Examples
Anyway, these are all toy examples; the syntax is just so rich that I wanted to show it to you with as little noise as possible. But seeing as we've covered most of it, let's consider a few more realistic scenarios — for example, parsing and responding to user input. A simple command-line calculator might look like this:
while True:
command = input('> ')
match command.split():
case 'add', x, y:
print(float(x) + float(y))
case 'mul', x, y:
print(float(x) * float(y))
case 'sqrt', x:
print(float(x) ** 0.5)
case 'quit' | 'q':
break
case _:
print(f'invalid command: {command}')
Consider how cumbersome writing the same logic with if-statements would be; even just the first clause:
if len(command) == 3 and command[0] == 'add':
x, y = command[1:]
print(float(x) + float(y))
Or, if we get the inputs via dictionaries (e.g. JSON messages or events), the same logic would get even clunkier:
if data.get('command') == 'add' and 'x' in data and 'y' in data:
print(float(data['x']) + float(data['y']))
Whereas a match-case statement can just do:
match data:
case {'command': 'add', 'x': x, 'y': y}:
print(float(x) + float(y))
Another interesting use case is validating a single value; the match-case statement is so versatile that it can come in handy even where you have just one (but complex) case. Suppose a function is expecting an argument that should be a string-number pair. Before:
if (
isinstance(arg, (tuple, list))
and len(arg) == 2
and isinstance(arg[0], str)
and isinstance(arg[1], (int, float))
):
s, n = arg
else:
raise ValueError(f'invalid argument: {arg}')
And after:
match arg:
case str(), int() | float():
s, n = arg
case _:
raise ValueError(f'invalid argument: {arg}')
The same is true for validating complex object structures; a good example comes from Python's standard library, when code is parsed into nodes, and those are then inspected: for example, to determine whether a node looks like a list. The resulting code can get complicated:
def is_list(node):
return (isinstance(node, Node)
and len(node.children) > 1
and isinstance(node.children[0], Leaf)
and isinstance(node.children[-1], Leaf)
and node.children[0].value == '['
and node.children[-1].value == ']')
The same could be done like this:
def is_list(node):
match node:
case Node(children=[Leaf(value='['), *_, Leaf(value=']')]):
return True
case _:
return False
It's still complicated — but that's because the check itself is complicated: we want to make sure the result is a node, whose first child is a leaf with the token [, and whose last child is a leaf with the token ]. In the former version, though, we are repeatedly burdened with implementation details, checking types manually and indexing children; in the latter, we get to just declare its structure, basically, and Python's success or failure at matching this structure provides us with the answer of whether this node is a list.
The Functionality of it All
We've discussed functional programming before, so I would like to highlight what a functional construct the match-case statement is, and elaborate a little on the implication of its design.
When we first addressed it, we discussed how a functional approach focuses on the transformation that a value undergoes, whereas a procedural approach is more of an attempt to coordinate between different values. In a conditional statement, this logic takes on a different meaning: neither if nor match-case produce a result, so much as check whether something is or isn't true.
But where if-statements check this by evaluating some expression, a match-case statement declares the structure it expects to work with, and lets the language decide whether this is possible or not.
These logics mirror what we've seen before: the evaluative lens aims to test whether some values are fit to be further coordinated (like if len(x) > 1: return x[0]) — while the declarative approach aims to check that the given values adhere to a certain structure, which can thus be transformed (like case [x, *_]: return x).
And like I've said before, neither approach is better than the other — they're just different, and it's tremendously cool that Python lets us have both. We've seen that in Haskell, a fundmantelly functional language, the very way functions are defined is like a match-case statement, which presents a challenge when procedural logic (like x > 1) needs to be evaluated. For that reason, they can have so called guards:
printGrade :: Int -> String
printGrade grade
| grade < 60 = "failed"
| grade < 80 = "satisfactory"
| otherwise = "excellent"
In Python, the same can be achieved with simple if-statements; but if for some reason we want to do it in a match statement (for example, because we do some destructuring first), we can add such guards to our case-clauses as well:
match point:
case (x, y) if x == y:
print(f'point on identity line: {x}')
case (x, y) if x > 0 and y > 0:
print(f'point in first quadrant: ({x}, {y})')