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-Committers 消息
摘要
本 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 关键字后面的值),并将其与**模式**(case 旁边的代码)进行检查。模式能够做两件不同的事情:
- 验证主题是否具有特定结构。在您的情况下,
[action, obj]模式匹配任何恰好包含两个元素的序列。这称为**匹配**。 - 它将模式中的一些名称绑定到主题的组件元素。在这种情况下,如果列表有两个元素,它将绑定
action = subject[0]和obj = subject[1]。
如果存在匹配,则 case 块内的语句将与绑定变量一起执行。如果没有匹配,则不发生任何事情,并接着执行 match 之后的语句。
请注意,与解包赋值类似,您可以使用括号、方括号或仅使用逗号分隔符作为同义词。因此,您可以编写 case action, obj 或 case (action, obj),其含义相同。所有形式都将匹配任何序列(例如列表或元组)。
匹配多个模式
即使大多数命令都是动作/对象形式,您可能希望拥有不同长度的用户命令。例如,您可能希望添加没有对象的单个动词,例如 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" 的两个元素序列。它还将绑定 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"] 所做的更改有一些好处,但也有一些缺点:最新版本允许别名,但也将方向硬编码,这将迫使我们为南北东西分别设置模式。这会导致一些代码重复,但同时我们获得了更好的输入验证,如果用户输入的命令是 "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 模式匹配其左侧的任何模式,但也会将值绑定到一个名称。
向模式添加条件
我们上面探讨的模式可以进行一些强大的数据过滤,但有时您可能希望获得布尔表达式的全部功能。假设您实际上只想在基于 current_room 的可能出口的受限方向集合中允许“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 的主体正常执行。如果模式匹配但条件为假,则 match 语句将继续检查下一个 case,就像模式没有匹配一样(可能会产生已经绑定了一些变量的副作用)。
添加用户界面:匹配对象
您的冒险游戏获得了成功,您已被要求实现一个图形界面。您选择的 UI 工具包允许您编写一个事件循环,通过调用 event.get() 可以获取新的事件对象。根据用户操作,生成的对象可能具有不同的类型和属性,例如:
- 当用户按下键时,会生成一个
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 类的子类时才匹配。它还将要求事件具有与 (x, y) 模式匹配的 position 属性。如果匹配成功,局部变量 x 和 y 将获得预期值。
像 KeyPress() 这样的模式,没有参数,将匹配任何 KeyPress 类的实例对象。只有在模式中指定的属性才会被匹配,其他任何属性都将被忽略。
匹配位置属性
上一节描述了如何在进行对象匹配时匹配命名属性。对于某些对象,按位置描述匹配的参数可能很方便(特别是如果只有少数属性并且它们具有“标准”顺序)。如果您正在使用的类是命名元组或数据类,您可以通过遵循构建对象时使用的相同顺序来做到这一点。例如,如果上述 UI 框架定义其类如下:
from dataclasses import dataclass
@dataclass
class Click:
position: tuple
button: Button
那么您可以将上面的 match 语句改写为
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": "店主说‘啊!我们有卡门培尔奶酪,是的,先生’", "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 – 快速入门
match 语句接受一个表达式,并将其值与一个或多个 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")
您可以使用带有一些内置类的位置参数,这些内置类为其属性提供了顺序(例如数据类)。您还可以通过在类中设置 __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