PEP 636 – 结构化模式匹配:教程
- 作者:
- Daniel F Moisset <dfmoisset at gmail.com>
- 赞助人:
- Guido van Rossum <guido at python.org>
- BDFL 代表:
- 讨论邮件列表:
- Python-Dev 列表
- 状态:
- 最终
- 类型:
- 信息性
- 创建日期:
- 2020 年 9 月 12 日
- Python 版本:
- 3.10
- 历史记录:
- 2020 年 10 月 22 日,2021 年 2 月 8 日
- 决议:
- Python 提交者邮件
摘要
本 PEP 是 PEP 634 引入的模式匹配的教程。
PEP 622 提出了一种模式匹配语法,并获得了社区和指导委员会的详细讨论。一个常见的担忧是这个特性是否容易解释(和学习)。本 PEP 通过提供开发人员可以用来学习 Python 中模式匹配的文档来解决此问题。
这被认为是 PEP 634(模式匹配的技术规范)和 PEP 635(模式匹配的动机和基本原理以及设计考虑因素)的辅助材料。
对于那些希望快速回顾而不是学习教程的读者,请参阅 附录 A。
教程
为了激励本教程,您将编写一个文字冒险游戏。这是一种交互式小说的形式,用户输入文本命令与虚构世界交互,并收到对发生情况的文本描述。命令将是自然语言的简化形式,例如 get sword
、attack dragon
、go north
、enter shop
或 buy cheese
。
匹配序列
您的主循环需要从用户那里获取输入并将其拆分为单词,假设是一个像这样的字符串列表
command = input("What are you doing next? ")
# analyze the result of command.split()
下一步是解释这些单词。我们的大多数命令都将有两个单词:一个动作和一个对象。因此,您可能倾向于执行以下操作
[action, obj] = command.split()
... # interpret action, obj
该代码行的问题在于它缺少了一些东西:如果用户输入的单词多于或少于 2 个怎么办?为了防止此问题,您可以检查单词列表的长度,或捕获上面语句将引发的 ValueError
。
您可以改用匹配语句
match command.split():
case [action, obj]:
... # interpret action, obj
match 语句评估“主体”(match
关键字之后的 value),并根据模式(case
旁边的代码)进行检查。模式能够做两件事
- 验证主体是否具有特定的结构。在您的例子中,
[action, obj]
模式匹配任何正好包含两个元素的序列。这称为匹配 - 它将模式中的一些名称绑定到主题的组件元素。在这种情况下,如果列表有两个元素,它将绑定
action = subject[0]
和obj = subject[1]
。
如果匹配,则 case 块内的语句将使用绑定的变量执行。如果没有匹配,则不会发生任何事情,并且 match
之后的语句将被接下来执行。
请注意,与解包赋值类似,您可以使用括号、方括号或逗号分隔作为同义词。因此,您可以使用 case action, obj
或 case (action, obj)
,含义相同。所有形式都将匹配任何序列(例如列表或元组)。
匹配多个模式
即使大多数命令都具有 action/object 形式,您可能也希望用户命令具有不同的长度。例如,您可能希望添加没有对象的单一动词,例如 look
或 quit
。一个 match 语句可以(并且很可能)有多个 case
match command.split():
case [action]:
... # interpret single-verb action
case [action, obj]:
... # interpret action, obj
match 语句将从上到下检查模式。如果模式与主体不匹配,则将尝试下一个模式。但是,一旦找到第一个匹配模式,则将执行该 case 的主体,并且所有后续 case 都将被忽略。这类似于 if/elif/elif/...
语句的工作方式。
匹配特定值
您的代码仍然需要查看特定的动作,并根据特定的动作(例如 quit
、attack
或 buy
)有条件地执行不同的逻辑。您可以使用 if/elif/elif/...
链或函数字典来执行此操作,但在这里我们将利用模式匹配来解决此任务。您可以使用模式中的字面值(如 "quit"
、42
或 None
)而不是变量。这允许您编写
match command.split():
case ["quit"]:
print("Goodbye!")
quit_game()
case ["look"]:
current_room.describe()
case ["get", obj]:
character.get(obj, current_room)
case ["go", direction]:
current_room = current_room.neighbor(direction)
# The rest of your commands go here
像 ["get", obj]
这样的模式将仅匹配第一个元素等于 "get"
的 2 元素序列。它还将绑定 obj = subject[1]
。
如您在 go
案例中看到的,我们还可以在不同的模式中使用不同的变量名。
字面值与 ==
运算符进行比较,但常量 True
、False
和 None
除外,它们与 is
运算符进行比较。
匹配多个值
玩家可以通过使用一系列命令 drop key
、drop sword
、drop cheese
来丢弃多个物品。此界面可能很麻烦,您可能希望允许在单个命令中丢弃多个物品,例如 drop key sword cheese
。在这种情况下,您事先不知道命令中有多少个单词,但您可以像在赋值中允许的那样在模式中使用扩展解包
match command.split():
case ["drop", *objects]:
for obj in objects:
character.drop(obj, current_room)
# The rest of your commands go here
这将匹配任何以“drop”作为其第一个元素的序列。所有剩余的元素都将被捕获到一个 list
对象中,该对象将绑定到 objects
变量。
此语法具有与序列解包类似的限制:您在一个模式中不能有多个星号名称。
添加通配符
当所有模式都失败时,您可能希望打印一条错误消息,说明未识别命令。您可以使用我们刚刚学习的功能并编写 case [*ignored_words]
作为您的最后一个模式。但是,有一种更简单的方法
match command.split():
case ["quit"]: ... # Code omitted for brevity
case ["go", direction]: ...
case ["drop", *objects]: ...
... # Other cases
case _:
print(f"Sorry, I couldn't understand {command!r}")
此特殊模式写为 _
(称为通配符),始终匹配,但它不绑定任何变量。
请注意,这将匹配任何对象,而不仅仅是序列。因此,只有将其单独用作最后一个模式才有意义(为了防止错误,Python 将阻止您在之前使用它)。
组合模式
现在是时候从示例中退一步,了解您一直在使用的模式是如何构建的了。模式可以相互嵌套,我们在上面的示例中已经隐式地做到了这一点。
我们已经看到了一些“简单”模式(这里的“简单”是指它们不包含其他模式)
- 捕获模式(独立名称,如
direction
、action
、objects
)。我们从未单独讨论过这些,而是将它们用作其他模式的一部分。 - 字面量模式(字符串字面量、数字字面量、
True
、False
和None
) - 通配符模式
_
到目前为止,我们尝试的唯一非简单模式是序列模式。序列模式中的每个元素实际上可以是任何其他模式。这意味着你可以编写一个像 ["first", (left, right), _, *rest]
这样的模式。这将匹配至少包含三个元素的序列主题,其中第一个元素等于 "first"
,第二个元素依次是一个包含两个元素的序列。它还将绑定 left=subject[1][0]
、right=subject[1][1]
和 rest = subject[3:]
。
或模式
回到冒险游戏示例,你可能会发现你希望几个模式产生相同的结果。例如,你可能希望命令 north
和 go north
等价。你可能也希望为 get X
、pick up X
和 pick X up
(对于任何 X)设置别名。
模式中的 |
符号将它们组合成备选方案。例如,你可以编写
match command.split():
... # Other cases
case ["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
这称为**或模式**,并将产生预期结果。模式从左到右尝试;如果多个备选方案匹配,则了解绑定内容可能与之相关。编写或模式时的一个重要限制是所有备选方案都应绑定相同的变量。因此,模式 [1, x] | [2, y]
不允许,因为它会使成功匹配后绑定哪个变量不清楚。[1, x] | [2, x]
完美且始终会在成功时绑定 x
。
捕获匹配的子模式
我们“go”命令的第一个版本是用 ["go", direction]
模式编写的。我们在上一个版本中使用模式 ["north"] | ["go", "north"]
所做的更改有一些好处,但也有一些缺点:最新版本允许使用别名,但也将方向硬编码,这将迫使我们为 north/south/east/west 实际使用单独的模式。这会导致一些代码重复,但同时我们获得了更好的输入验证,并且如果用户输入的命令是 "go figure!"
而不是方向,我们将不会进入该分支。
我们可以尝试做到两全其美(为了简洁起见,我将省略没有“go”的别名版本)
match command.split():
case ["go", ("north" | "south" | "east" | "west")]:
current_room = current_room.neighbor(...)
# how do I know which direction to go?
此代码是一个分支,它验证“go”后面的单词是否真的是方向。但是,移动玩家的代码需要知道选择了哪一个,并且无法做到这一点。我们需要一个像或模式一样工作但同时进行捕获的模式。我们可以使用**as 模式**做到这一点
match command.split():
case ["go", ("north" | "south" | "east" | "west") as direction]:
current_room = current_room.neighbor(direction)
as 模式匹配其左侧的任何模式,但也将值绑定到一个名称。
为模式添加条件
我们上面探讨的模式可以进行一些强大的数据过滤,但有时你可能希望使用布尔表达式的全部功能。假设你实际上只想在基于当前房间可能出口的受限方向集中允许“go”命令。我们可以通过在我们的 case 中添加**守卫**来实现这一点。守卫由 if
关键字后跟任何表达式组成
match command.split():
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 的一部分。只有在模式匹配并且所有模式变量都已绑定后才会检查它(这就是为什么条件可以在上面的示例中使用 direction
变量的原因)。如果模式匹配且条件为真,则 case 的主体正常执行。如果模式匹配但条件为假,则匹配语句继续检查下一个 case,就好像模式没有匹配一样(可能存在已绑定某些变量的副作用)。
添加 UI:匹配对象
你的冒险正在取得成功,你被要求实现一个图形界面。你选择的 UI 工具包允许你编写一个事件循环,你可以在其中通过调用 event.get()
获取新的事件对象。根据用户操作,生成的 object 可以具有不同的类型和属性,例如
- 当用户按下键时,会生成一个
KeyPress
对象。它有一个key_name
属性,其中包含所按键的名称,以及一些关于修饰符的其他属性。 - 当用户单击鼠标时,会生成一个
Click
对象。它有一个position
属性,其中包含指针的坐标。 - 当用户单击游戏窗口的关闭按钮时,会生成一个
Quit
对象。
与其编写多个 isinstance()
检查,你可以使用模式来识别不同类型的对象,并将其应用于其属性
match event.get():
case Click(position=(x, y)):
handle_click_at(x, y)
case KeyPress(key_name="Q") | Quit():
game.quit()
case KeyPress(key_name="up arrow"):
game.go_north()
...
case KeyPress():
pass # Ignore other keystrokes
case other_event:
raise ValueError(f"Unrecognized event: {other_event}")
像 Click(position=(x, y))
这样的模式仅在事件类型是 Click
类的子类时才匹配。它还需要事件具有一个 position
属性,该属性与 (x, y)
模式匹配。如果匹配,则局部变量 x
和 y
将获得预期值。
像 KeyPress()
这样的模式,没有参数,将匹配任何是 KeyPress
类实例的对象。仅匹配你在模式中指定的属性,而忽略任何其他属性。
匹配位置属性
上一节描述了在进行对象匹配时如何匹配命名属性。对于某些对象,通过位置描述匹配的参数可能很方便(尤其是在只有几个属性并且它们具有“标准”顺序时)。如果正在使用的类是命名元组或数据类,则可以通过按照构建对象时使用的相同顺序来执行此操作。例如,如果上面的 UI 框架这样定义它们的类
from dataclasses import dataclass
@dataclass
class Click:
position: tuple
button: Button
那么你可以将上面的匹配语句重写为
match event.get():
case Click((x, y)):
handle_click_at(x, y)
(x, y)
模式将自动与 position
属性匹配,因为模式中的第一个参数对应于数据类定义中的第一个属性。
其他类没有其属性的自然顺序,因此你必须在模式中使用显式名称来与其属性匹配。但是,可以手动指定属性的顺序以允许位置匹配,就像在此备用定义中一样
class Click:
__match_args__ = ("position", "button")
def __init__(self, pos, btn):
self.position = pos
self.button = btn
...
__match_args__
特殊属性为你的属性定义了一个显式顺序,可以在像 case Click((x,y))
这样的模式中使用。
匹配常量和枚举
你上面的模式将所有鼠标按钮视为相同,并且你已决定要接受左键单击,并忽略其他按钮。在执行此操作时,你注意到 button
属性的类型为 Button
,它是一个使用 enum.Enum
构建的枚举。实际上,你可以像这样匹配枚举值
match event.get():
case Click((x, y), button=Button.LEFT): # This is a left click
handle_click_at(x, y)
case Click():
pass # ignore other clicks
这将适用于任何带点名称(如 math.pi
)。但是,未限定的名称(即没有点的裸名称)将始终被解释为捕获模式,因此请避免这种歧义,在模式中始终使用限定常量。
进入云端:映射
你已决定制作游戏的在线版本。你的所有逻辑都将在服务器中,而 UI 在客户端中,它们将使用 JSON 消息进行通信。通过 json
模块,这些将映射到 Python 字典、列表和其他内置对象。
我们的客户端将接收一个字典列表(从 JSON 解析),用于执行操作,每个元素例如如下所示
{"text": "The shop keeper says 'Ah! We have Camembert, yes sir'", "color": "blue"}
- 如果客户端应该暂停
{"sleep": 3}
- 播放声音
{"sound": "filename.ogg", "format": "ogg"}
到目前为止,我们的模式已处理序列,但有模式可以根据其存在的键匹配映射。在这种情况下,你可以使用
for action in actions:
match action:
case {"text": message, "color": c}:
ui.set_text_color(c)
ui.display(message)
case {"sleep": duration}:
ui.wait(duration)
case {"sound": url, "format": "ogg"}:
ui.play(url)
case {"sound": _, "format": _}:
warning("Unsupported audio format")
映射模式中的键需要是文字,但值可以是任何模式。与序列模式一样,所有子模式都必须匹配才能使通用模式匹配。
你可以在映射模式中使用 **rest
来捕获主题中的其他键。请注意,如果省略此项,则在匹配时将忽略主题中的额外键,即消息 {"text": "foo", "color": "red", "style": "bold"}
将匹配上面示例中的第一个模式。
匹配内置类
上面的代码可以使用一些验证。鉴于消息来自外部来源,字段的类型可能错误,从而导致错误或安全问题。
任何类都是有效的匹配目标,包括内置类,如bool
str
或 int
。这使我们能够将上述代码与类模式结合起来。因此,与其编写 {"text": message, "color": c}
,我们可以使用 {"text": str() as message, "color": str() as c}
来确保 message
和 c
都是字符串。对于许多内置类(有关完整列表,请参见PEP 634),您可以使用位置参数作为简写,编写 str(c)
而不是 str() as c
。完全重写的版本如下所示
for action in actions:
match action:
case {"text": str(message), "color": str(c)}:
ui.set_text_color(c)
ui.display(message)
case {"sleep": float(duration)}:
ui.wait(duration)
case {"sound": str(url), "format": "ogg"}:
ui.play(url)
case {"sound": _, "format": _}:
warning("Unsupported audio format")
附录 A – 快速入门
匹配语句获取一个表达式,并将其值与作为一个或多个 case 块给出的后续模式进行比较。这在表面上类似于 C、Java 或 JavaScript(以及许多其他语言)中的 switch 语句,但功能更强大。
最简单的形式将主题值与一个或多个字面量进行比较
def http_error(status):
match status:
case 400:
return "Bad request"
case 404:
return "Not found"
case 418:
return "I'm a teapot"
case _:
return "Something's wrong with the Internet"
请注意最后一个块:“变量名”_
充当通配符,并且永远不会匹配失败。
您可以使用 |
(“或”)将多个字面量组合到单个模式中
case 401 | 403 | 404:
return "Not allowed"
模式看起来像解包赋值,可用于绑定变量
# point is an (x, y) tuple
match point:
case (0, 0):
print("Origin")
case (0, y):
print(f"Y={y}")
case (x, 0):
print(f"X={x}")
case (x, y):
print(f"X={x}, Y={y}")
case _:
raise ValueError("Not a point")
仔细研究一下!第一个模式有两个字面量,可以认为是上面显示的字面量模式的扩展。但接下来的两个模式将字面量和变量组合在一起,变量绑定来自主题(point
)的值。第四个模式捕获两个值,这使得它在概念上类似于解包赋值 (x, y) = point
。
如果您使用类来构建数据,则可以使用类名称后跟类似于构造函数的参数列表,但能够将属性捕获到变量中
from dataclasses import dataclass
@dataclass
class Point:
x: int
y: int
def where_is(point):
match point:
case Point(x=0, y=0):
print("Origin")
case Point(x=0, y=y):
print(f"Y={y}")
case Point(x=x, y=0):
print(f"X={x}")
case Point():
print("Somewhere else")
case _:
print("Not a point")
您可以对某些提供其属性排序的内置类(例如 dataclasses)使用位置参数。您还可以通过在类中设置 __match_args__
特殊属性来定义属性的特定位置。如果将其设置为(“x”,“y”),则以下模式都等效(并且都将 y
属性绑定到 var
变量)
Point(1, var)
Point(1, y=var)
Point(x=1, y=var)
Point(y=var, x=1)
模式可以任意嵌套。例如,如果我们有一个点的短列表,我们可以这样匹配它
match points:
case []:
print("No points")
case [Point(0, 0)]:
print("The origin")
case [Point(x, y)]:
print(f"Single point {x}, {y}")
case [Point(0, y1), Point(0, y2)]:
print(f"Two on the Y axis at {y1}, {y2}")
case _:
print("Something else")
我们可以向模式添加一个 if
子句,称为“守卫”。如果守卫为假,match
将继续尝试下一个 case 块。请注意,值捕获发生在评估守卫之前
match point:
case Point(x, y) if x == y:
print(f"Y=X at {x}")
case Point(x, y):
print(f"Not on the diagonal")
其他几个关键特性
- 与解包赋值一样,元组和列表模式具有完全相同的含义,实际上匹配任意序列。一个重要的例外是它们不匹配迭代器或字符串。(从技术上讲,主题必须是
collections.abc.Sequence
的实例。) - 序列模式支持通配符:
[x, y, *rest]
和(x, y, *rest)
的工作原理类似于解包赋值中的通配符。*
后的名称也可能是_
,因此(x, y, *_)
匹配至少两个项目的序列,而不会绑定剩余的项目。 - 映射模式:
{"bandwidth": b, "latency": l}
从字典中捕获"bandwidth"
和"latency"
值。与序列模式不同,额外的键将被忽略。还支持通配符**rest
。(但**_
将是多余的,因此不允许。) - 可以使用
as
关键字捕获子模式case (Point(x1, y1), Point(x2, y2) as p2): ...
- 大多数字面量通过相等性进行比较,但是单例
True
、False
和None
通过同一性进行比较。 - 模式可以使用命名常量。这些必须是点分名称,以防止将其解释为捕获变量
from enum import Enum class Color(Enum): RED = 0 GREEN = 1 BLUE = 2 match color: case Color.RED: print("I see red!") case Color.GREEN: print("Grass is green") case Color.BLUE: print("I'm feeling the blues :(")
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以较宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0636.rst
上次修改时间:2023-09-09 17:39:29 GMT