Introduction
Structural Pattern Matching
is a new feature introduced in python
3.10, and perhaps the
most intriguing one. It takes as input an object to inspect (following
match
), and multiple patterns to match against (following one or
more case
). If there is a match, a pattern-specific code block
runs. The basic syntax is
error_code = 403match error_code:case 400:print("Bad request")case 403:print("Unauthorized")case 500:print("Internal server error")case _:the default case when none of the above matchesprint("Something's wrong with the Internet")#> Unauthorized
Here, we check case by case if error_code
is equal to a value, if it’s
not one of 400
, 403
, 500
, _
will be a catch-all wildcard. Also,
pattern matching will break when it hits a match, so each case
should
be independent.
To run examples in this article, be sure you are using python higher than 3.10.
import syssys.version#> '3.10.14 (main, Mar 19 2024, 21:46:16) [Clang 15.0.0 (clang-1500.3.9.4)]'
Pattern Matching as a Replacement for switch
Statements
The most apparent usage of pattern matching is implementing
switch ... case
statements in many other programming languages, which
is a replacement for multiple if else statements. Imagine we are
building a game, and we have a move
function for controlling figure
movement.
def move(self, direction):match direction:case "up":self.move_up()case "down":self.move_down()case "left":self.move_left()case "right":self.move_right()
Match Against Dictionaries and Lists
The real power of pattern matching resides in destructuring data structures like lists and dictionaries.
match {"first_name": "Jane", "last_name": "Doe", "middle_name": ""}:case {"first_name": first_name , "last_name": last_name}:print(first_name, last_name)#> Jane Doe
In the example above, we supplied a pattern that says the object should
contains keys “first_name” and “last_name”, if the pattern matches,
their values will be captured into the variables first_name
and
last_name
. Note that for an dictionary, structural pattern matching
matches the structure instead of the exact content, the pattern is valid
as long as “first_name” and “last_name” are one of the object’s keys,
any unmentioned keys, like “middle_name”, will be ignored.
For a more advanced example, suppose we are working with the SpaceX API to retrieve launch data, an individual record of launch looks like
{"flight_number": 1,"mission_name": "FalconSat","upcoming": false,"launch_year": "2006","launch_date_unix": 1143239400,"rocket": {"rocket_id": "falcon1","rocket_name": "Falcon 1",},"details": "Engine failure at 33 seconds and loss of vehicle",//... other fields}
We only want to get the fields mission_name
, rocket_name
and
details
. Note that
import requestsimport jsonimport pprintres = requests.get("https://api.spacexdata.com/v3/launches?limit=3")data = res.json()out = []for launch in data:match launch:case {"mission_name": mname, "rocket": {"rocket_name": rname}, "details": details}:out.append({"mission_name": mname, "rocket_name": rname, "details": details})print(json.dumps(out, indent = 2))
[{"mission_name": "FalconSat","rocket_name": "Falcon 1","details": "Engine failure at 33 seconds and loss of vehicle"},{"mission_name": "DemoSat","rocket_name": "Falcon 1","details": "Successful first stage burn and transition to second stage, maximum altitude 289 km, Premature engine shutdown at T+7 min 30 s, Failed to reach orbit, Failed to recover first stage"},{"mission_name": "Trailblazer","rocket_name": "Falcon 1","details": "Residual stage 1 thrust led to collision between stage 1 and stage 2"}]
Here, not only are we matching top level key mission_name
and
details
, we also use a nested pattern to extract rocket name.
You can also match on a list.
match [1, 2, 3]:case [1, 2]:print("match 1")case [2, *rest]:match the the rest of elementsprint("match 2")print(rest)case[*other, last]:match elements except the last oneprint("match 3")print(last)print(other)#> match 3#> 3#> [1, 2]
I mentioned that pattern matching for dict does not require use to
explicitly declare all fields. For lists this is different, we have to
declare all elements, this is why match 1 fails. Match 2 attempts to use
the *
syntax to capture all values after 2 into a list called rest
.
This is helpful in case the list is really large and it’s impossible to
exhaust all elements, or we simply don’t care about some elements.
However, the second pattern does not specify the correct order, as 2 is
not the first element in the matching object. Match 3 captures all
elements but the last into other
, and assign the last element to
last
.
It’s also possible to express or
logic with |
in case statement. For
example, we may add a case 401 | 403 | 404: "Not allowed"
statement to
our first example. Here is another example from pep:
match command.split():... # Other casescase ["north"] | ["go", "north"]:current_room = current_room.neighbor("north")case ["get", obj] | ["pick", "up", obj] | ["pick", obj, "up"]:... # Code for picking up the given object
Literal patterns can also be captured using the as
keyword
match command.split():case ["go", ("north" | "south" | "east" | "west") as direction]:current_room = current_room.neighbor(direction)
Add Conditions
Pattern matching allows if conditions the same way as list
comprehension, we can append an if ... else
clause like so
all_directions = ["up", "down"]match commad.split():case ["go", direction] if direction in all_directions:move(direction)case ["go", _]:print("direction not supported")
For conditions that checks if an object is of a data type, we can directly use the class creator function:
def sum_list(numbers):match numbers:case []:return 0case [int(first) | float(first), *rest]:return first + sum_list(rest)case _:raise ValueError(f"Can only sum lists of numbers")sum_list([1, 2, 3])#> 6sum_list([1, "2", 3])#> Can only sum lists of numbers
Here int(first) | float(first)
checks if the first element is an
integer or float. The next example uses a dataclass
based pattern as
well as its attributes
from dataclasses import dataclass@dataclassclass Point:x: floaty: floatp = Point(x = 1, y = 1)# data class for validationmatch p:case Point(x = x):print(x)#> 1
In general, any class can be used for validation. This is also called a class pattern.
Matching Against Constants
Care should be taken when matching constants, since it’s common to store
the constant in an variable and then use it in case
. The following
code will not work:
direction = "up"match "down":case direction:capture "up" into the variable `direction`print("match")case _:print("no match")
We are trying to match the value “up”, but python interprets it as a
capture pattern that says store whatever the match object is into the
variable direction
, so we are effectively overriding direction
(recall the list matching example) and the first case always matches.
Python will not allow this with an error message
SyntaxError: name capture 'direction' makes remaining patterns unreachable
.
As an alternative to matching literal values, we can use an enum type for constant matching instead
import enumclass Direction(str, enum.Enum):up = "up"down = "down"match "down":case Direction.up:print("match 1")case Direction.down:print("match 2")case _:print("match 3")#> match 2