Structural Pattern Matching(match 语句)

Structural Pattern Matching: 翻译过来应该是 结构化的模式匹配。从python 3.10开始提供了match statement。它远比简单的其它语言中的那种switch语句功能强大的多。
通过一个例子来了解一下这种语句的用法。
假设我们有一个函数,用来区分用户做的操作,并将其打印出来。 我们使用json结构的字符串来做为用户操作的内容并传到这个函数中。目前这个函数只区分用户敲击键盘,及单击鼠标。
下面的函数内部使用if else语句来判断输入的json字符串应该匹配到哪一种用户操作。判断的条件足够的严谨!

import json

def log(event):
    parsed_event = json.loads(event)
    if (
        "keyboard" in parsed_event and
        "key" in parsed_event["keyboard"] and
        "code" in parsed_event["keyboard"]["key"]
    ):
        code = parsed_event["keyboard"]["key"]["code"]
        print(f"Key pressed: {code}")
    elif (
        "mouse" in parsed_event and
        "cursor" in parsed_event["mouse"] and
        "screen" in parsed_event["mouse"]["cursor"]
    ):
        screen = parsed_event["mouse"]["cursor"]["screen"]
        if isinstance(screen, list) and len(screen) == 2:
            x, y = screen
            print(f"Mouse cursor: x={x}, y={y}")
        else:
            print("Unknown event type")
    else:
        print("Unknown event type")


event1 = '''{"keyboard": {"key": {"code": "Enter"}}}'''
event2 = '''{"mouse": {"cursor": {"screen": [600, 900]}}}'''
event3 = '''{"mouse": {"click": {"screen": [600, 900]}}}'''

log(event1)  # Key pressed: Enter
log(event2)  # Mouse cursor: x=600, y=900
log(event3)  # Unknown event type

让我们看一下如果我们使用match statement,如何来改写上面的函数:

import json

def log(event):
    match json.loads(event):
        case {"keyboard": {"key": {"code": code}}}:
            print(f"Key pressed: {code}")
        case {"mouse": {"cursor": {"screen": [x, y]}}}:
            print(f"Mouse cursor: {x=}, {y=}")
        case _:
            print("Unknown event type")


event1 = '''{"keyboard": {"key": {"code": "Enter"}}}'''
event2 = '''{"mouse": {"cursor": {"screen": [600, 900]}}}'''
event3 = '''{"mouse": {"click": {"screen": [600, 900]}}}'''

log(event1)  # Key pressed: Enter
log(event2)  # Mouse cursor: x=600, y=900
log(event3)  # Unknown event type

我们可以明显的看到,实现了同样的判断逻辑,我们使用match语句后,代码非常精简,并且更直观易读。
上面的示例体现了match语句用于匹配数据字典格式数据的能力,就像在声明一个字典的结构以及它要有的某些key和value,并且还可以把我们感兴趣的部分数据方便的提取出来。

现在,假设我们要用一个列表中包含三个值,来表示空间坐标系中的一个点,这个点必须在z轴上,也就是第三个数字为0。 前两个数字可以是float,也可以是int。
下面我们用传统的if...else语句来做一个逻辑严谨的匹配规则判断:

# subject = ['a']  # 匹配不上
# subject = [9.3, 10, 0, 88]  # 匹配不上
subject = [9.3, 10, 0]  # 匹配成功

if isinstance(subject, list) and len(subject) == 3:
    if (
        isinstance(subject[0], int | float) and
        isinstance(subject[1], int | float) and
        subject[2] == 0
    ):
        x, y, _ = subject
        print(f"Point({x=}, {y=})")

# 输出: Point(x=9.3, y=10)

接下来我们用match语句来实现上述匹配规则:

# subject = ['a']  # 匹配不上
# subject = [9.3, 10, 0, 88]  # 匹配不上
subject = [9.3, 10, 0]  # 匹配成功

match subject:
    case list([int() | float() as x, int() | float() as y, 0]):
        print(f"Point({x=}, {y=})")

# 输出: Point(x=9.3, y=10)

可以看到,我们使用了list()这种类定义的形式来做模式匹配。

注意,match后面接的是表达式,但case后面接的是模式(pattern),模式是不会运行的,比如list()不会真的生成一个空列表,而只代表其结构是一个列表。
我们用dataclass来说明这个问题:

from dataclasses import dataclass

@dataclass
class User:
    name: str

    def __post_init__(self):
        print(f"Created a new user named {self.name}")

match User("Alice"):
    case User("Bob"):
        print('This is Bob')

输出结果:
Created a new user named Alice
从输出结果可以看出,只执行了一次__post_init__方法 ,说明 case User("Bob"): 这句并未真正也创建一个dataclass的实例。

下面介绍一些基本的匹配模式:
Literal Pattern: 字面量模式, 字符串,整数,浮点数,布尔值等等。 但不包括复杂类型,比如sequence,dictionary,class等等。
除布尔值外,其他字面量都使用等值方式去匹配。 比如下面函数, 18可以匹配到18.0

def is_adult(age):
    match age:
        case 18:
            return True
        case _:
            return False

is_adult(18)  # True

is_adult(18.0)  # True

在python中,True和False是两个单例对象,None也一样。 所以一般判断一个值是否是它们三者中的一个,都用"is" 操作符来判断。并且,True和False是int类的子类。并且其值分别是1和0。
可以用下面代码进行验证:

print(1 == True) # True
print(2 == True) # False
print(0 == False) # True
print(-1 == False) # False

下面是一个简单的例子,case后面接的是True/False字面量,需要使用is操作符进行判断:

def is_true(value):
    match value:
        case False:
            print(False)
        case True:
            print(True)
        case _:
            print('未匹配')

is_true(1)  # False
is_true(0)  # False
is_true(False)  # False
is_true(True) # True

下面是另一个例子,case后面是数字,就使用==操作符来判断。注意,决定判断使用is还是==操作符是看case后面的字面量是不是True和False,而不是看match后面的表达式是什么类型。

def is_true(value):
    match value:
        case 0:
            print(0)
        case 1:
            print(1)
        case False:
            print(False)
        case True:
            print(True)
        case _:
            print('未匹配')

is_true(0)  # 0
is_true(1)  # 1
is_true(False)  # 0
is_true(True)   # 1

从上面的执行结果看,虽然当match后面放了True或False时,case块依次比对,前两个是整数字面量0和1,所以使用==操作符匹配,又因为True和False是int类的子类,其值等价于1和0,所以就能直接匹配上了。

Value Pattern: 值模式,用于使用一个对象属性值来进行匹配。
很多时候,我们想指定我们模式的值是一个变量,而不是字面量,这时就可以使用value pattern,而不能直接使用一个变量。因为变量会被当做capture pattern。
下面的例子可以看到,v_cmd变量的值被覆写成了match后面subject的值。 这就是因为case后面放变量会捕获所有情况,是一个capture pattern.

COMMAND = 'exit'
v_cmd = 'help'

match COMMAND:
    case 'hello':
        print('hello')
    case v_cmd:
        print(v_cmd)

输出结果:
exit

如果实际应用中,我们case后面跟的不是一个字面量,而是一个在程序运行时过程中才能决定的值,我们可以把它放在一个命名空间里,比如class, enum, module等等,总之可以使用点操作符访问得到,这种模式就是value pattern。
我们将上面的例子改写成value pattern。

v_cmd = 'help'
class CMD:
    prop = v_cmd

def match_cmd(p_command):
    match p_command:
        case 'hello':
            print('hello')
        case CMD.prop:
            print(v_cmd)
        case _:
            print('not matche')

match_cmd('exit')  # not matche
match_cmd('help')  # help

Capture Pattern: 捕获模式,顾名思义就是捕获所有情况,无论match后面的subject是什么,都能匹配到。case后面直接跟一个变量,捕获到的值就放在这个变量中,其实也就是match后面的表达式的值。
下面这个简单的例子可以看到,最后的case block相当于直接声明了一个变量,由于前面的case block都没有匹配上,所以最终值就放在这个var变量中。

match '123':
    case '456':
        print('456')
    case 'abc':
        print('abc')
    case var:
        print('capter pattern:', var)

print('var is', var)

输出结果:

capter pattern: 123
var is 123

那如果前面的case block先匹配上了,则捕获模式中的变量就不会被定义,match语句之后如果要引用那个变量,就会报错说变量未定义。

match 'abc':
    case '456':
        print('456')
    case 'abc':
        print('abc')
    case var:
        print('capter pattern:', var)

print('var is', var)

输出结果报错了:

abc
    print('var is', var)
                    ^^^
NameError: name 'var' is not defined. Did you mean: 'vars'?

这里顺便要提一下通配符模式:
Wildcard pattern: , 与capture pattern一样,它也会捕获所有subject,但是""在这里只是一种特殊关键字(soft keyword),只在当前范围有这个特殊意义。并不会像capter pattern一样自动创建一个名为"_"的变量。

match '789':
    case '456':
        print('456')
    case 'abc':
        print('abc')
    case _:
        print('capture pattern')

print(_)

输出结果报错:

capture pattern
NameError: name '_' is not defined

由于capture pattern和wildcard pattern的这种特殊性,所以在一个match语句中,不能出现多个capture pattern,或多个wildcard pattern,或者两者混合。 但是在capture pattern后面可以加上条件语句,称之为guard,这样就可以允许出现多个capture pattern。
Guard: 用于pattern之后的判断语句,只有在pattern成功匹配后才执行,如果返回True,则最终执行这个pattern对应的case block。 否则,继续匹配下一个pattern。
先看一个guard简单示例:

match 'abc':
    case '456':
        print('456')
    case 'abc' if 1 > 2:
        print('abc')
    case 'abc' if 1 < 2:
        print('abc and 1 < 2')
    case _:
        print('capture pattern')

输出结果:
abc and 1 < 2

下面语句直接提示有语法错误,因为出现了两个capter pattern的case block。

match 'abc':
    case '456':
        print('456')
    case var1:
        print('var1')
    case var2:
        print('var2')

而像下面这样写就不会报错,并且运行结果会输出"var2"。:

match 'abc':
    case '456':
        print('456')
    case var1 if 1 > 2:
        print('var1')
    case var2 if 1 < 2:
        print('var2')

其实我们还可以在不同的case block中使用相同变量名的capture pattern。

compound pattern 和 subpattern: 顾名思义,就是指一个case语句后面的pattern不是一个简单的pattern,还是有多个pattern复合构造成。其中的各个子pattern也就被称为subpattern了。
下面例子中的case block里包含了literal pattern, wildcard pattern, class pattern, capture pattern。

from dataclasses import dataclass
from typing import Self

@dataclass
class Command:
    name: str
    options: tuple[str, ...]
    subcommand: None | Self = None

command = Command(
    name="git",
    options=(),
    subcommand=Command(
        name="commit",
        options=("-a", "-m", "'Initial commit'"),
    )
)

match command:
    case Command("git", _, Command(subcommand, options)):
        print(f"{subcommand=}\n{options=}")

Class Pattern: 类模式。用于精确匹配subject的数据类型。 就像是给对象进行解构一样,也是使用类名加括号的形式,同时也可以使用capture pattern来捕获对象属性值。需要注意的是,pattern里类名后面的括号里放的是类的属性,而不是类的__init__方法里的参数。
下面的示例展示了,当我只关心subject的数据类型的时候的用法。 用于表示日期的值可能是一个数值,一个日期对象,或者一个字符串。

from datetime import UTC, datetime

def parse(value):
    match value:
        case datetime():
            return value.astimezone(UTC)
        case int() | float():
            return datetime.fromtimestamp(value, UTC)
        case str():
            return datetime.fromisoformat(value).astimezone(UTC)
        case _:
            raise TypeError(f"Unsupported type: {type(value)}")


parse(-14182967.876544)  # 1970-06-14 03:42:47.876544+00:00
parse("1969-07-20T20:17:12.123456+00:00")  # 1969-07-20 20:17:12.123456+00:00

下面的例子通过使用命名参数来更精确的匹配subject,同时可使用了capture pattern捕获到了subject对象的属性值。

import datetime

def aoc_status(date):
    match date:
        case datetime.date(year=_, month=12, day=1):
            print("Advent of Code starts today!")
        case datetime.date(year=_, month=12, day=day) if day <= 25:
            print(f"{25 - day} days until the end of AoC.")
        case _:
            print("Sorry, no Advent of Code today.")


aoc_status(datetime.date.today())  # Sorry, no Advent of Code today.

aoc_status(datetime.date(2024, 12, 1))  # Advent of Code starts today!

aoc_status(datetime.date(2024, 12, 2)) # 23 days until the end of AoC.

除非类实现了__match_args__方法,否则class pattern里如果带参数,都使用关键字参数。 像是我们自定义的类,即便自定义的__init__方法只有一个参数,在class pattern中也要使用key-word方式表示。但是对于那些built-in type的构造函数,比如int(),str()等等,它们有单个参数的构造方法,这时则可以在class pattern中使用位置参数的方式。

def length(value):
    match value:
        case str(text):
            return len(text)
        case int(number):
            return number.bit_length()

length("255")  # 3
length(255)    # 8

Sequence Pattern: 序列模式用于匹配常见的list, tuple, range, collections.namedtuple等序列,但并不是支持所有iterable类型。可以使用星号来代表序列中多个元素,用法类似于解包赋值。中括号和小括号都可用于标记序列模式,但如果要匹配的序列只有一个元素,这时如果使用小括号,则后面要跟一个逗号。例如:(item,)

command = ['git', 'commit', '-a', '-m', "'Initial commit'"]

match command:
    case ["git"]:
        print("Showing the list of available Git subcommands.")
    case ["git", "--version"]:
        print("Showing the version of your Git client.")
    case [_, subcommand, *options]:
        print(f"{subcommand=}, {options=}")
        
# 输出结果: ubcommand='commit', options=['-a', '-m', "'Initial commit'"]

上面的示例中使用中括号标识出是sequence pattern,并且内部使用了wildcard pattern,literal pattern, 解包写法,和capture pattern。

下面我们再展示一下使用小括号,及不使用括号,但使用多个pattern中间用逗号分隔的方式来匹配。

lst = [1,2,3]

match lst:
    case (1,2,v):
        print(v)  # 3

match lst:
    case 1,v,3:
        print(v)  # 2

其实在python中,小括号有时用于标记元组,有时又只是一种分组符。下面的示例可以看出,当我们想用字面量定义一个只有一个元素的元组时,元素后要加个逗号。 否则它就不是元组。

v1 = ('a')
v2 = ('a',)
print(type(v1), v1)  # <class 'str'> a
print(type(v2), v2)  # <class 'tuple'> ('a',)

我们看下如何匹配只有一个元素的sequence,使用sequence pattern。

tup = ('abc',)

match tup:
    case v,:
        print(type(v), v)

match tup:
    case (v,):
        print(type(v), v)

match tup:
    case v:
        print(type(v), v)

match tup:
    case (v):
        print(type(v), v)

match tup:
    case [v]:
        print(type(v), v)

输出结果:

<class 'str'> abc
<class 'str'> abc
<class 'tuple'> ('abc',)
<class 'tuple'> ('abc',)
<class 'str'> abc

从输出结果来看,使用中括号来标识sequence pattern是最稳妥不容易出错的方式。 使用不带逗号的单个变量实际上是capture pattern,会捕获所有数据类型的。
python中,空元组的字面量是(),这时倒不能加个逗号。 下面的例子中,两个case的pattern其实是等效的。因为中括号的在上面,所以就先执行了case []。

subject = ()
print(type(subject), subject)
print('-' * 25, '分隔线', '-' * 25)

match subject:
    case []:
        print("This will match the subject if it's an empty sequence.")
    case ():
        print("This is equivalent to the above.")

输出结果:

<class 'tuple'> ()
------------------------- 分隔线 -------------------------
This will match the subject if it's an empty sequence.

再看一些*号在squence pattern中的灵活用法:

subject = list('abcdefg')

match subject:
    case [v1,v2, *_]:
        print(f"v1={v1}, v2={v2}")

print('-' * 25, '分隔线', '-' * 25)
match subject:
    case [*all_items]:
        print(all_items)

print('-' * 25, '分隔线', '-' * 25)
match subject:
    case first, *middle, last:
        print(f"{first=}")
        print(f"{middle=}")
        print(f"{last=}")

print('-' * 25, '分隔线', '-' * 25)

输出结果:

v1=a, v2=b
------------------------- 分隔线 -------------------------
['a', 'b', 'c', 'd', 'e', 'f', 'g']
------------------------- 分隔线 -------------------------
first='a'
middle=['b', 'c', 'd', 'e', 'f']
last='g'
------------------------- 分隔线 -------------------------

如果不使用*号,则sequence pattern的元素个数要与subject一致,否则就匹配不上。

cursor_position = (765, 432, 0)
match cursor_position:
    case [x, y]:
        print(f"{x=}, {y=}")
    case _:
        print("Cursor position is not a two-tuple.")
# 输出结果: Cursor position is not a two-tuple.

sequence pattern匹配任何支持的sequence类型,但如果我们要精确匹配具体的sequence类型,这时就需要借助class pattern。

def flip(pair):
    match pair:
        case list([x, y]):
            return [y, x]
        case tuple([x, y]):
            return y, x
        case _:
            raise TypeError("Unsupported type")

flip([1, 2])  # [2, 1]
flip((1, 2))  # (2, 1)

Mapping Pattern: 用于匹配映射关系(key-value)的数据,比如dictionary, collections.OrderedDict, collections.defaultdict等等。 匹配时可以不提供所有的key以及所有嵌套层级。但key不能是captural pattern,可以是literal pattern或value pattern。
下面的示例之前使用class pattern进行过演示,现在也可以使用mapping pattern来处理。

from dataclasses import asdict, dataclass
from typing import Self

@dataclass
class Command:
    name: str
    options: tuple[str, ...]
    subcommand: None | Self = None

command = Command(
    name="git",
    options=(),
    subcommand=Command(
        name="commit",
        options=("-a", "-m", "'Initial commit'"),
    )
)

match asdict(command):
    case {"subcommand": {"options": options, "name": "commit"}}:
        print(options)  # ('-a', '-m', "'Initial commit'")

与sequence patterns不同的是,case []可以用于匹配空的sequence,但是case {}可以匹配所有支持的mapping数据,即便数据不是一个空的mapping对象。比如空字典。

match asdict(command):
    case {}:
        print('can match any supported mapping object, include empty object')

我们可以用**var这种语法获取除个别key-value以外的数据。比如:

match asdict(command):
    case {"subcommand": _, **rest}:
        print(rest)   # {'name': 'git', 'options': ()}

OR Pattern: 就是合并几个basic pattern。它们之间的或的关系。 如果子pattern中有capture pattern,则每个子pattern都要有相同的capture pattern,即变量名及个数都要一致。
下面是一个简单的例子,subpatterns是literal pattern。

command = 'exit'

match command:
    case 'exit' | 'quit':
        print('exit app')
    case _:
        print('others command')

下面例子中subpatterns是class pattern,里面又含有capture pattern,并且capture pattern的名称和个数都得一致,否则会报错,但位置可以不一致。

from dataclasses import dataclass

@dataclass
class User():
    name: str
    age: int

user1 = User("Harry", 10)
user2 = User("Roland", 40)

match user1:
    case User(name='Harry', age=var) | User(name=var, age=40):
        print('Harry or Roland')
    case _:
        print('other user')

# 输出结果: Harry or Roland

AS Pattern: 用于将匹配到的内容绑定给一个变量。 比如,当我们用OR Pattern来组合多个literal pattern时,我们不知道最终是哪个literal pattern匹配成功了,我们可以用as来将其值绑定到一个变量,以便后续使用。

match command:
    case 'exit' | 'quit' as cmd:
        print(f'cmd is {cmd}')
    case _:
        print('others command')

# 输出结果:cmd is exit 

下面例子将class pattern匹配到的具体对象绑定到了变量。

@dataclass
class User():
    name: str
    age: int

user1 = User("Harry", 10)
user2 = User("Roland", 40)

match user1:
    case User(name='Harry', age=var) | User(name=var, age=40) as user:
        print(user)
    case _:
        print('other user')

# 输出结果: User(name='Harry', age=10)

Group Pattern: 用小括号包裹pattern,纯粹是为了让复杂的pattern的结构更清晰。 注意,小括号还用在sequence pattern中,括号内为空,代表是空的sequence pattern,当代表只有一个元素的sequence时,后面要有一个逗号。
下面是一个表示经纬坐标的pattern,里面的小括号是为了区分各子组。像第一个小括其实是可以拿掉的,意义等价。

match subject:
    case [(latitude), (("N" | "S") | ("W" | "E")) as hemisphere]:
        ...
posted @   RolandHe  阅读(17)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 别再用vector<bool>了!Google高级工程师:这可能是STL最大的设计失误
· 单元测试从入门到精通
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示