Sometimes the shape of a value matters as much as its contents.
matchtests the shape, takes the value apart, and binds the parts in one statement.
The match statement compares a value against a
series of patterns and runs the first one that fits. No
C-style switch can express this case:
match event:
case {"type": "click", "x": x, "y": y}:
...That case matches only a dictionary whose "type"
is "click", and it binds x and
y from that dictionary as it matches.
match becomes valuable once the patterns do more
than test equality.
Pattern matching first appears in Control Flow.
match and case are soft
keywords: they act as keywords only inside this statement,
so existing code that uses match as a variable name
still runs. Avoid naming a new variable match. The
name shadows no keyword, but it reads like the statement.
The simplest patterns are literal values. A
case _ at the end is the wildcard. It matches
anything, like a default. Without one, a match that
fits no pattern does nothing and raises no error.
match tries the patterns top to bottom and the
first match wins. One case body runs, then the
statement ends. Unlike C’s switch, cases do not
fall through, and match has no break
to forget:
# http_status.py
def describe(status: int) -> str:
match status:
case 200:
return "OK"
case 404:
return "Not Found"
case 500:
return "Server Error"
case _:
return f"Status {status}"
print(describe(200))
#: OK
print(describe(404))
#: Not Found
print(describe(301))
#: Status 301A literal pattern compares with ==, not with
is, so case 200: also matches
200.0 and case 1: matches
True. None, True, and
False are the exception. Those three compare with
is, so case True: does not match
1.
For a value-to-value lookup like this, a dictionary is often shorter.
| joins several patterns in one
case, and the case matches when any one of them
fits.
A bare name is a capture pattern. Like
_, it matches any value unconditionally. Unlike
_, it also binds the matched value to that name.
Here, other is the capture pattern:
# step.py
def step(command: str) -> str:
match command:
case "up" | "u":
return "y -= 1"
case "down" | "d":
return "y += 1"
case other:
return f"unknown command: {other}"
print(step("up"))
#: y -= 1
print(step("d"))
#: y += 1
print(step("jump"))
#: unknown command: jumpThe alternatives in step() are literals, which
bind nothing. When alternatives do capture, each one must bind
the same set of names, so the body can use those names whichever
alternative matched.
A bare name always binds. It does not compare against a
variable of that name, so a named constant in a
case silently captures instead. A value
pattern is a dotted name, and it does compare.
Signal is an Enum:
# value_patterns.py
from enum import Enum
from typing import Final
class Signal(Enum):
STOP = "stop"
GO = "go"
DEFAULT: Final[Signal] = Signal.STOP
def act(s: Signal) -> str:
match s:
case Signal.GO:
return "accelerate"
case Signal.STOP:
return "brake"
def broken(s: Signal) -> str:
match s:
case DEFAULT:
return f"DEFAULT is now {DEFAULT}"
return "unreachable"
print(act(Signal.GO), act(Signal.STOP))
#: accelerate brake
print(broken(Signal.GO))
#: DEFAULT is now Signal.GO
print(DEFAULT)
#: Signal.STOPcase Signal.GO compares.
case DEFAULT binds. It matches
Signal.GO, rebinds DEFAULT as a local
name inside broken(), and leaves the module-level
constant untouched.
Python catches the mistake when a later case
follows a bare-name capture, refusing to compile with
SyntaxError: name capture 'DEFAULT' makes remaining patterns unreachable.
When the capture is the last case, as here, Python
compiles it without a warning, and neither ty nor
ruff reports it.
act() also shows why an enum is worth the
trouble. Signal is a closed set, so the type
checker sees that the cases cover both members and accepts the
function with no trailing return.
A sequence pattern matches the shape of a list or tuple and
binds the elements by position. A starred name, as in
*rest, captures the remaining elements:
# sequence_patterns.py
def summarize(items: list[int]) -> str:
match items:
case []:
return "Empty"
case [only]:
return f"One item: {only}"
case [first, second]:
return f"Two items: {first}, {second}"
case [first, *rest]:
return f"{first}, then {len(rest)} more"
case _: # [1]
return "Unreachable"
def last_of(items: list[int]) -> tuple[list[int], int]:
match items:
case [*init, last]:
return init, last
case _:
return [], 0
print(summarize([]))
#: Empty
print(summarize([5]))
#: One item: 5
print(summarize([3, 4]))
#: Two items: 3, 4
print(summarize([1, 2, 3, 4]))
#: 1, then 3 more
print(last_of([1, 2, 3, 4]))
#: ([1, 2, 3], 4)summarize() shows the structural part of
“structural pattern matching.” The pattern
[first, second] matches only a two-element sequence
and pulls both out at once.
The case _ at [1] is unreachable.
[first, *rest] catches every nonempty list and
[] the empty one. The type checker cannot prove
that, so the wildcard stays to satisfy the declared return
type.
A starred name can appear anywhere in a sequence pattern, not
only at the end, as long as the pattern has no more than one.
last_of() puts it first. [*init, last]
binds every element but the last to init.
[first, *middle, last] puts it in the middle
instead, binding the two ends by name and everything between
them to middle.
A sequence pattern deliberately excludes str,
bytes, and bytearray.
case [a, b, c] does not match "abc",
although a string is a sequence in every other context.
Iterating over a string a character at a time is rarely what a
pattern means, so the language rules it out. A tuple does match.
case [a, b, c] accepts (1, 2, 3) as
readily as [1, 2, 3], because the pattern describes
a shape, not a concrete type. The subject must be a sequence,
though, not merely iterable. case [a, b] matches a
range but not a generator and not a
set.
Parentheses group a pattern rather than build a tuple.
case (x) is case x, an unconditional
capture, and case (x,) is a one-element sequence
pattern. The brackets are optional in a sequence pattern, so
case 0, 0: and case [0, 0]: are the
same pattern. The subject is any expression, not only a
parameter, and a comma builds a tuple there too. Given a
sign() helper that returns -1,
0, or 1,
match sign(x), sign(y): matches on a pair computed
inline. Transforming the subject this way turns a set of
comparisons into literal patterns, and literal patterns usually
read better than the guards you would
write otherwise.
test_summarize_by_length() checks the empty,
one-item, two-item, and starred cases, and
test_last_of_binds_init_and_last() checks a starred
name in first position:
# test_sequence_patterns.py
import pytest
from sequence_patterns import last_of, summarize
@pytest.mark.parametrize("items, expected", [
([], "Empty"),
([5], "One item: 5"),
([3, 4], "Two items: 3, 4"),
([1, 2, 3], "1, then 2 more"),
])
def test_summarize_by_length(items: list[int],
expected: str) -> None:
assert summarize(items) == expected
def test_last_of_binds_init_and_last() -> None:
assert last_of([1, 2, 3, 4]) == ([1, 2, 3], 4)A class pattern matches by type and extracts attributes. With a data class you can match positionally or by keyword:
# point.py
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: int# class_patterns.py
from point import Point
def locate(p: Point) -> str:
match p:
case Point(0, 0):
return "The origin"
case Point(0, y):
return f"On the y-axis at y={y}"
case Point(x, 0):
return f"On the x-axis at x={x}"
case Point(x, y):
return f"At ({x}, {y})"
print(locate(Point(0, 0)))
#: The origin
print(locate(Point(0, 5)))
#: On the y-axis at y=5
print(locate(Point(3, 0)))
#: On the x-axis at x=3
print(locate(Point(3, 4)))
#: At (3, 4)Point(0, 0) matches a point whose fields are
both zero. Point(0, y) matches when x
is zero and captures y.
Despite the call syntax, a class pattern builds nothing. It tests the subject’s type and reads its attributes.
Positional matching depends on __match_args__, a
class attribute listing field names in order.
@dataclass generates it automatically from the
field order, so Point(0, y) means “position 0 is
x, position 1 is y.”
NamedTuple generates it too. An ordinary class must
assign it by hand. A positional pattern raises a
TypeError when __match_args__ is too
short to name every position you supply. For an ordinary class
R that lacks __match_args__,
case R(1) reports
TypeError: R() accepts 0 positional sub-patterns (1 given).
test_locate_matches_fields() checks the origin,
a point on each axis, and a point on neither:
# test_class_patterns.py
import pytest
from class_patterns import locate
from point import Point
@pytest.mark.parametrize("point, expected", [
(Point(0, 0), "The origin"),
(Point(0, 5), "On the y-axis at y=5"),
(Point(3, 0), "On the x-axis at x=3"),
(Point(3, 4), "At (3, 4)"),
])
def test_locate_matches_fields(point: Point,
expected: str) -> None:
assert locate(point) == expectedA keyword pattern such as Point(x=0, y=y)
matches by attribute name, through attribute access, not through
__match_args__. Keyword patterns also work on any
object with the named attributes, data class or not, and let you
match a subset of attributes while ignoring the rest:
# keyword_patterns.py
from point import Point
def describe(p: Point) -> str:
match p:
case Point(x=0):
return "Somewhere on the y-axis"
case Point(y=0):
return "Somewhere on the x-axis"
case Point():
return "Just some point"
print(describe(Point(0, 5)))
#: Somewhere on the y-axis
print(describe(Point(3, 0)))
#: Somewhere on the x-axis
print(describe(Point(3, 4)))
#: Just some pointPoint(x=0) matches any point whose
x attribute is zero, ignoring y. A
positional pattern can leave fields unchecked too.
Point(0) supplies fewer sub-patterns than
__match_args__ names, so it ignores y,
and Point(_, 0) uses the wildcard to skip
x. Naming the attribute is clearer, and it survives
a change to the field order. Reordering the fields rewrites
__match_args__, so every positional pattern
silently starts matching a different field.
Point() with no arguments, keyword or
positional, matches any Point instance. Use it as a
type-only check or a final catch-all.
test_describe_by_attribute() checks a point on
each axis and the Point() catch-all:
# test_keyword_patterns.py
import pytest
from keyword_patterns import describe
from point import Point
@pytest.mark.parametrize("point, expected", [
(Point(0, 5), "Somewhere on the y-axis"),
(Point(3, 0), "Somewhere on the x-axis"),
(Point(3, 4), "Just some point"),
])
def test_describe_by_attribute(point: Point,
expected: str) -> None:
assert describe(point) == expectedThe type test is isinstance(), so a subclass
matches its base’s pattern:
# type_patterns.py
def describe(value: object) -> str:
match value:
case bool(b):
return f"bool {b}"
case int(n):
return f"int {n}"
case str(s):
return f"str of length {len(s)}"
case _:
return "something else"
print(describe(True))
#: bool True
print(describe(7))
#: int 7
print(describe("hello"))
#: str of length 5
print(describe(3.5))
#: something elseBecause a subclass matches, the order of the cases decides
which one wins. bool is a subclass of
int, so moving case bool(b) below
case int(n) makes it unreachable.
describe(True) answers int True.
In int(n), the positional sub-pattern binds the
whole value rather than an attribute. Python special-cases a
handful of built-ins this way (bool,
int, float, str,
bytes, bytearray, list,
tuple, dict, frozendict,
set, frozenset), so
case str(s) reads as “a string, call it
s.”
Dropping the parentheses changes the meaning.
case str: is a bare-name capture, not a type test,
matching any value and binding it to a local named
str. case str: repeats the
DEFAULT mistake from value_patterns.py, and
Python catches it the same way, by refusing to compile any
case after it:
SyntaxError: name capture 'str' makes remaining patterns unreachable.
Matching on isinstance() is the opposite of the
exact-type dispatch that a dict keyed on
type(value) performs. Multiple
Dispatching relies on that dispatch, and there a
subclass finds no entry.
A guard is an if attached to a
case. The case matches only when the pattern fits
and the guard is true:
# guards.py
from point import Point
def quadrant(p: Point) -> str:
match p:
case Point(0, 0):
return "Origin"
case Point(x, y) if x > 0 and y > 0:
return "First quadrant"
case Point(x, y) if x < 0 and y > 0:
return "Second quadrant"
case _:
return "Somewhere else"
def leaky(p: Point) -> int:
match p:
case Point(x, _) if x > 100:
return 0
case _:
return x
print(quadrant(Point(0, 0)))
#: Origin
print(quadrant(Point(3, 4)))
#: First quadrant
print(quadrant(Point(-3, 4)))
#: Second quadrant
print(quadrant(Point(-1, -1)))
#: Somewhere else
print(leaky(Point(3, 4)))
#: 3The guard runs after the pattern matches, so it can use the
names the pattern bound. When a guard is false,
match moves on to the next case, but
the names stay bound. Once
case Point(x, y) if x > 0 and y > 0 has
failed, x and y still hold the values
it captured. leaky() shows why that matters.
case _: binds nothing, yet x still
holds 3, left over from the failed guard in the
case above it. A case that does not rebind a name inherits
whatever an earlier, failed case left behind. That holds for a
failed guard, where the pattern matched and made its bindings. A
pattern that fails partway is different. The language reference
leaves it to the implementation whether the captures made before
the failure stay bound. Use a captured name only in the
case that captured it.
A pattern tests shape and equality, so everything beyond that
belongs in the guard: an ordering test like
x > 0, a relation between two captures like
x == y, or any call like
len(items) > 3. Repeating a name does not
express equality. case [x, x]: fails with
SyntaxError: multiple assignments to name 'x' in pattern,
so an equal-elements test is also a guard,
case [x, y] if x == y:.
A guard that merely compares one capture to a constant is a
literal pattern written the long way. The exception is a
constant with a bare name, such as DEFAULT in value_patterns.py. A
pattern cannot compare against that name, so
case s if s == DEFAULT: is how a case
tests for it.
A mapping pattern matches keys in a dictionary and binds
their values. It ignores keys you do not mention, so it
dispatches cleanly on JSON-shaped data. Ignoring unmentioned
keys also makes case {} a catch-all for any mapping
rather than a test for an empty one, the opposite of
case [], which matches only an empty sequence. Test
for an empty dictionary with a guard,
case {} if not event:. A **rest at the
end binds whatever keys the pattern did not mention, the mapping
counterpart of *rest in a sequence pattern.
# mapping_patterns.py
def handle(event: dict[str, object]) -> str:
match event:
case {"type": "click", "x": x, "y": y}:
return f"Click at ({x}, {y})"
case {"type": "key", "key": key}:
return f"Key {key}"
case {"type": kind}:
return f"Other event: {kind}"
case unknown:
return f"Unrecognized event: {unknown}"
print(handle({"type": "click", "x": 10, "y": 20}))
#: Click at (10, 20)
print(handle({"type": "key", "key": "Enter"}))
#: Key Enter
print(handle({"type": "scroll", "delta": 3}))
#: Other event: scroll
print(handle({"button": 1}))
#: Unrecognized event: {'button': 1}Binding through a mapping pattern loses type information a
class pattern keeps. handle()’s parameter is
event: dict[str, object], so x and
y come out typed object, the same as
every other value the dictionary could hold. class_patterns.py’s
Point(x, y) binds x and y
as int, because Point declares its
fields that way. The shape test is precise, but each binding
takes the dictionary’s one declared value type. When the data
has a known shape, parse it into a dataclass first, then match
on the dataclass. You keep the shape test and gain the field
types.
test_handle_matches_keys() checks a key event
and the catch-all for a mapping with no "type":
# test_mapping_patterns.py
from mapping_patterns import handle
def test_handle_matches_keys() -> None:
assert handle(
{"type": "key", "key": "Esc"}) == "Key Esc"
assert handle(
{"nope": 1}) == "Unrecognized event: {'nope': 1}"A sub-pattern is a pattern, so any kind of pattern can sit inside any other:
# nested_patterns.py
from point import Point
def survey(points: list[Point]) -> str:
match points:
case [Point(0, 0) as start, *rest]: # [1]
return f"{start} then {len(rest)} more"
case [Point(0, n) | Point(n, 0)]: # [2]
return f"one axis point, offset {n}"
case [Point(), Point()]:
return "two points"
case _:
return "nothing to say"
print(survey([Point(0, 0), Point(1, 1), Point(2, 2)]))
#: Point(x=0, y=0) then 2 more
print(survey([Point(0, 5)]))
#: one axis point, offset 5
print(survey([Point(4, 0)]))
#: one axis point, offset 4
print(survey([Point(1, 2), Point(3, 4)]))
#: two pointsThe case at [1] is a sequence pattern holding a
class pattern holding two literals, with a starred capture
beside the class pattern. as binds whatever its
sub-pattern matched, so start is the whole
Point while 0, 0 checks its fields.
Without as you must choose between testing the
shape and keeping the object.
The case at [2] alternates two class patterns
and binds n from either, inside a one-element
sequence pattern. survey([Point(0, 5)]) matches,
but a list of two points does not. The compiler enforces the same-names rule. Adding a
third alternative | Point(1, 1), which binds
nothing, fails with
SyntaxError: alternative patterns bind different names.
Nesting has a set depth, the depth you write into the
pattern. A self-referential type such as a tree needs recursion
instead: a function whose match takes one level
apart and calls the function again on the parts. Composite
and Interpreter walks an expression tree this way.
Each case matches one node type, and its body
recurses into that node’s children.
When a value is one of a known set of types, define that set
as a union using the type
statement. Then match on that union. When you
end with case _: assert_never(value), the type
checker ensures the match is exhaustive. If you add a
type to the union without its case, the type
checker reports an error at assert_never() instead
of letting the value fall through at runtime.
# exhaustive.py
from dataclasses import dataclass
from math import pi
from typing import assert_never
@dataclass(frozen=True)
class Circle:
radius: float
@dataclass(frozen=True)
class Square:
side: float
type Shape = Circle | Square
def area(shape: Shape) -> float:
match shape:
case Circle(radius):
return pi * radius ** 2
case Square(side):
return side ** 2
case _:
assert_never(shape)
print(round(area(Circle(1.0)), 4))
#: 3.1416
print(area(Square(2.0)))
#: 4.0assert_never() acts at runtime as well as at
check time. If a caller ignores the annotation and passes the
string "x", that value reaches
assert_never(), which raises
AssertionError: Expected code to be unreachable, but got: 'x',
naming the value it received.
act() in value_patterns.py has no
assert_never(), and the type checker still confirms
that its cases cover Signal. That check comes from
the return type. A missing case would let the
function run off its end and return None, which is
not a str. A match whose cases return
nothing gets no such check, so there a missing case
passes unnoticed. assert_never() covers both kinds
of match, and it reports the error at the
match instead of at the function’s signature.
A switch in C, JavaScript, or traditional Java
has no such check. Nothing forces you to add a case, and an
unhandled value falls through silently. Scala’s
match, Kotlin’s when, and Java’s newer
switch expressions check exhaustiveness, as an error in Java and
Kotlin and a warning in Scala. The check applies only when the
matched type is a closed set the compiler can see in full: a
sealed hierarchy or an enum.
The Scala, Kotlin, and Java versions are also expressions,
producing a value you can assign. Python’s match is
a statement, not an expression, so a match that
must produce a value goes inside a function that returns from
each case.
Python has no sealed keyword.
assert_never() plus a type checker fills that role
instead. An if/isinstance() chain can
reach the same guarantee, but only if you remember to end it
with assert_never(). A match makes the
form of the dispatch explicit.
Shape turns the classic OOP “shapes” example
into a closed type union instead of a class hierarchy.
test_area_of_each_shape() checks the area of one
Circle and one Square, so every member
of the Shape union reaches its own
case. The string "x" is no
Shape, so the area("x") call in
test_assert_never_rejects_a_non_shape() carries a
# type: ignore. At runtime
assert_never() raises an
AssertionError for the string:
# test_exhaustive.py
import pytest
from exhaustive import Circle, Square, area
def test_area_of_each_shape() -> None:
assert round(area(Circle(1.0)), 4) == 3.1416
assert area(Square(2.0)) == 4.0
def test_assert_never_rejects_a_non_shape() -> None:
with pytest.raises(AssertionError):
area("x") # type: ignoreFor a value-to-value lookup, a dictionary is shorter:
# value_to_value_lookup.py
from typing import Final
STATUS: Final[dict[int, str]] = {
200: "OK", 404: "Not Found", 500: "Server Error"}
def describe(status: int) -> str:
try:
return STATUS[status]
except KeyError:
return f"Status {status}"
print(describe(200))
#: OK
print(describe(301))
#: Status 301The lookup is try/except rather
than STATUS.get(status, f"Status {status}") because
Python evaluates arguments before the call. Every lookup builds
the default string, including the hits that discard it.
A literal match compiles to a chain of
comparisons, one per case, so its cost grows with
the number of cases. A dictionary lookup costs the same at any
size. At three entries the difference is invisible. The
dictionary wins as the table grows, and you can build or change
a dictionary at runtime, but not a match.
When the set of types is open (anyone can add a new
one), inheritance and dynamic binding work better than
match. Each type carries its own behavior, so
adding a type needs no change to a central match.
Use match for a closed set of cases you want to
handle in one place, especially when the cases need to look
inside the value. When that closed set is a set of constants
rather than a set of shapes, make it an Enum and
match on its members, as value_patterns.py does.
The enum hands the type checker the closed set, so
assert_never() works without a type
union.
An alerting system sends a notification through one of three channels: email, SMS, or push. Every channel renders the notification into a message string for a recipient. Every channel also has a rough cost to send a message.
The inheritance answer declares both operations as abstract methods on a base class. Each channel is a subclass that implements them, and dynamic binding picks the correct implementation at each call:
# notifications_oo.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import override
class Notification(ABC):
@abstractmethod
def render(self, recipient: str) -> str: ...
@abstractmethod
def cost(self) -> float: ...
@dataclass(frozen=True)
class Email(Notification):
subject: str
@override
def render(self, recipient: str) -> str:
return f"Email to {recipient}: {self.subject}"
@override
def cost(self) -> float:
return 0.001
@dataclass(frozen=True)
class Sms(Notification):
body: str
@override
def render(self, recipient: str) -> str:
return f"SMS to {recipient}: {self.body}"
@override
def cost(self) -> float:
return 0.02
@dataclass(frozen=True)
class Push(Notification):
title: str
@override
def render(self, recipient: str) -> str:
return f"Push to {recipient}: {self.title}"
@override
def cost(self) -> float:
return 0.0005
email = Email("Invoice ready")
sms = Sms("Code: 5821")
push = Push("New message")
print(email.render("Dana"))
#: Email to Dana: Invoice ready
print(sms.render("Dana"))
#: SMS to Dana: Code: 5821
print(push.render("Dana"))
#: Push to Dana: New message
print(round(email.cost() + sms.cost() + push.cost(), 4))
#: 0.0215Notification names the shape every channel must
have. @abstractmethod forces Email,
Sms, and Push to define both
render() and cost(). If you leave one
out, the class stays abstract. Instantiating that class raises a
TypeError.
match VersionA type union with match takes the opposite shape
from the class hierarchy. The channels become plain data, and
each operation is a free function that inspects the type:
# notifications_match.py
from dataclasses import dataclass
from typing import assert_never
@dataclass(frozen=True)
class Email:
subject: str
@dataclass(frozen=True)
class Sms:
body: str
@dataclass(frozen=True)
class Push:
title: str
type Notification = Email | Sms | Push
def render(note: Notification, recipient: str) -> str:
match note:
case Email(subject):
return f"Email to {recipient}: {subject}"
case Sms(body):
return f"SMS to {recipient}: {body}"
case Push(title):
return f"Push to {recipient}: {title}"
case _:
assert_never(note)
def cost(note: Notification) -> float:
match note:
case Email():
return 0.001
case Sms():
return 0.02
case Push():
return 0.0005
case _:
assert_never(note)
email = Email("Invoice ready")
sms = Sms("Code: 5821")
push = Push("New message")
print(render(email, "Dana"))
#: Email to Dana: Invoice ready
print(render(sms, "Dana"))
#: SMS to Dana: Code: 5821
print(render(push, "Dana"))
#: Push to Dana: New message
print(round(cost(email) + cost(sms) + cost(push), 4))
#: 0.0215render() and cost() each
match over Notification and end with
assert_never(), so the type checker confirms each
match handles every case.
test_oo_and_match_agree() confirms that the two
versions render and price each channel alike:
# test_notifications.py
import notifications_match as nm
import notifications_oo as no
import pytest
@pytest.mark.parametrize("oo, data", [
(no.Email("Hi"), nm.Email("Hi")),
(no.Sms("Hi"), nm.Sms("Hi")),
(no.Push("Hi"), nm.Push("Hi")),
])
def test_oo_and_match_agree(
oo: no.Notification, data: nm.Notification
) -> None:
assert oo.render("Dana") == nm.render(data, "Dana")
assert oo.cost() == nm.cost(data)Try growing the system in each direction. First, add a new
type: a Webhook channel. In the object version, you
write one new subclass with its own render() and
cost(), and nothing else changes. In the match
version, you add a Webhook data class to the
Notification union, and the type checker flags
assert_never() in both render() and
cost() until you add a
case Webhook(...) to each.
If you add a new operation, priority(), that
ranks channels by urgency, every existing subclass in the object
version needs a new method. In the match version, you write one
new function with its own match, and the existing
classes and functions stay untouched.
Adding a type is cheaper with inheritance. Adding an operation is cheaper with pattern matching. That is the open-set-versus-closed-set tradeoff from When Not to Match, worked out concretely. It also has a name: the expression problem. Rethinking Objects works through the same split with shapes, and Multiple Dispatching and Visitor explore it further.
This chapter’s solutions give a hint, usually the shape of the code, and a full answer for each exercise.
classify(value) that uses
match to return "empty list",
"singleton", or "longer list" for
lists, "point" for a Point, and
"other" for anything else.Rectangle type to exhaustive.py’s
Shape union without adding its case.
Run ty and read the error it reports at
assert_never().mapping_patterns.handle() to also
accept a nested shape, such as
{"type": "click", "at": {"x": x, "y": y}}, binding
x and y from the inner
dictionary.Webhook channel to notifications_match.py: a
data class with a url field, added to the
Notification union. Run ty before
adding its case to render() and
cost(), and read the errors. Then add both cases
and confirm ty passes.guards.py’s
quadrant() so it handles the third and fourth
quadrants too. Then write it a second time with one
case per sign combination, using |
alternations and no guards, and say which version reads
better.value_patterns.py’s
Signal a third member, and write act()
so that it compares against a module-level
FALLBACK: Final[Signal]. Run it and confirm that
the constant captures instead of comparing. Then fix it two
ways, with a dotted name and with a guard.