Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

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 swordattack dragongo northenter shopbuy 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, objcase (action, obj),其含义相同。所有形式都将匹配任何序列(例如列表或元组)。

匹配多个模式

即使大多数命令都是动作/对象形式,您可能希望拥有不同长度的用户命令。例如,您可能希望添加没有对象的单个动词,例如 lookquit。一个 match 语句可以(并且很可能)有多个 case

match command.split():
    case [action]:
        ... # interpret single-verb action
    case [action, obj]:
        ... # interpret action, obj

match 语句将从上到下检查模式。如果模式与主题不匹配,则会尝试下一个模式。然而,一旦找到*第一个*匹配模式,该 case 的主体就会被执行,所有后续 case 都将被忽略。这类似于 if/elif/elif/... 语句的工作方式。

匹配特定值

您的代码仍然需要查看特定的动作,并根据特定的动作(例如 quitattackbuy)有条件地执行不同的逻辑。您可以使用一系列 if/elif/elif/... 来完成,或者使用函数字典,但在这里我们将利用模式匹配来解决此任务。除了变量,您可以在模式中使用文字值(例如 "quit"42None)。这允许您编写:

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 案例所示,我们也可以在不同的模式中使用不同的变量名。

字面值使用 == 运算符进行比较,但常量 TrueFalseNone 除外,它们使用 is 运算符进行比较。

匹配多个值

玩家可以通过一系列命令 drop keydrop sworddrop 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 会阻止您提前使用它)。

组合模式

现在是时候从示例中退后一步,了解您一直在使用的模式是如何构建的。模式可以相互嵌套,我们已经在上面的示例中隐式地这样做了。

我们已经看到了一些“简单”模式(“简单”在这里意味着它们不包含其他模式)

  • **捕获模式**(独立名称,如 directionactionobjects)。我们从未单独讨论过它们,但将它们作为其他模式的一部分使用。
  • **字面模式**(字符串字面量、数字字面量、TrueFalseNone
  • **通配符模式** _

到目前为止,我们唯一尝试过的非简单模式是序列模式。序列模式中的每个元素实际上可以是任何其他模式。这意味着您可以编写像 ["first", (left, right), _, *rest] 这样的模式。这将匹配至少包含三个元素的序列主题,其中第一个元素等于 "first",第二个元素又是一个包含两个元素的序列。它还将绑定 left=subject[1][0]right=subject[1][1]rest = subject[3:]

或模式

回到冒险游戏示例,您可能会发现您希望有几个模式产生相同的结果。例如,您可能希望命令 northgo north 等效。您可能还希望 get Xpick up Xpick 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 属性。如果匹配成功,局部变量 xy 将获得预期值。

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"} 将匹配上面示例中的第一个模式。

匹配内置类

上面的代码需要一些验证。鉴于消息来自外部源,字段的类型可能错误,导致错误或安全问题。

任何类都是有效的匹配目标,这包括 boolstrint 等内置类。这允许我们将上面的代码与类模式结合起来。因此,除了编写 {"text": message, "color": c},我们可以使用 {"text": str() as message, "color": str() as c} 来确保 messagec 都是字符串。对于许多内置类(完整列表请参阅 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): ...
    
  • 大多数字面量通过相等性进行比较,但单例 TrueFalseNone 通过身份进行比较。
  • 模式可以使用命名常量。这些必须是点分名称,以防止它们被解释为捕获变量。
    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 :(")
    

来源:https://github.com/python/peps/blob/main/peps/pep-0636.rst

上次修改:2025-02-01 08:59:27 GMT