PEP 636: Structural Pattern Matching: Tutorial

原文请参考PEP 636: Structural Pattern Matching: Tutorial

摘要

这个PEP是PEP 634引入的模式匹配的教程。

PEP 622 提出的模式匹配新语法经过了社区和理事会的详细讨论,讨论中一个常见的问题就是如何通俗易懂地解释(和学习)这个新功能。这个PEP就试图提供这样一份学习文档,使得开发者可以通过这个阅读此文档来学习模式匹配。

可以把这个PEP当做是PEP 634(模式匹配的技术说明)和PEP 635(支持模式匹配的动机,基本原理和设计思路)的一份补充材料。

对于更想快速了解模式匹配而不是教程的读者,请参考附录A。

教程

我们通过实现一个文字冒险游戏来作为这个教程的一个例子。文字冒险游戏是交互式小说的一种形式,用户通过输入文字指令来和虚拟世界交互,并且收到文字形式的反馈,以此来了解在虚拟世界里发生了什么。文字指令通常是自然语言的一种简单形式,例如: get sword, attack dragon, go north, enter shop 或者 buy cheese

匹配序列

程序的主循环需要接收用户的输入并且分割成一个一个的单词,比如一个这样的字符串的列表:

command = input("What are you doing next? ")
# 分析command.split()的结果

下一步就是处理这些单词。我们的大部分指令都包含两个单词:一个动作和一个对象。所以你可能会想这样做:

[action, obj] = command.split()
... # 处理 action, obj

问题在于这行代码缺少了一些东西:如果用户输入多于或者少于两个单词怎么办?为了避免这个问题,我们要么需要检查单词列表的长度,要么捕获上面这行指令产生的ValueError

或者,你也可以使用模式匹配指令:

match command.split():
    case [action, obj]:
        ... # 处理 action, obj

匹配指令评估了主对象match关键字后面的值),并且检查了它的模式(case后面的代码)。模式可以做两件不同的事情:

  • 验证主对象有特定的结构。在这个例子里,模式[action, obj]能匹配上任何刚好含有两个元素的序列。这个步骤叫做匹配
  • 它还会把模式里的变量名和主对象里的成员元素绑定起来。在这个例子里,如果这个列表含有两个元素,那么它会绑定action=subject[0]obj = subject[1]

如果这里匹配上了的话,分支块里的指令就会按照绑定好的变量执行。如果这里没有匹配上,什么都不会发生并且接下来会执行match后面的指令。

请注意,通过类似的方法去解包赋值的时候,你可以通过括号,大括号,或者逗号分隔,他们都是相同的意思。即你可以写case action, obj 或者 case (action, obj),都是一样的含义。所有的写法都会匹配任意的序列(比如列表或者元组)。

匹配多种模式

即使大多数命令都是动作/对象的形式,你可能也想支持不同长度的用户命令。比如,你可能想加入单个动词,没有对象的命令,例如look或者quit。一个匹配指令可以(并且更像是)支持一个以上的分支:

match command.split():
    case [action]:
        ... # 处理单个动词的动作
    case [action, obj]:
        ... # 处理 action, obj

匹配命令会从上到下检查模式。如果一个模式不能匹配上主对象,就会尝试下一个模式。但是,一但找到第一个匹配上的模式,分支主体代码就会被执行,并且后面所有的情况都会被忽略。它的工作方式非常像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)
    # 这里写剩下的命令

一个像["get", obj]的模式会匹配一个只含2个元素的序列,并且这个序列还满足,第一个元素的值是"get". 同时,它也会绑定obj = subject[1]

在go这个情况中,我们能看出可以在不同的模式里使用不同的变量名。

除了True, FalseNone 之外的字面值,都是用==来比较是否相等,前面三种使用is运算符。

匹配多个值

玩家有时候可能会扔下很多物品,这时候他会用命令drop key, drop sword, drop chesse。 这个接口可能会显得笨重,所以你会想要支持在一条命令里扔多个物品,比如drop key sword cheese。 在这个例子里,你不会提前知道命令里有多少个单词,但是你可以在模式中用和赋值同样的方式,通过扩展版的解包来实现。

match command.split():
    case ["drop", *objects]:
        for obj in objects:
            character.drop(obj, current_room)
    # 剩下的命令写在这里

这个会匹配所有第一个元素是"drop"的序列。所有剩下的元素都会被放到一个list对象中,这个列表同时也会被绑定到objects变量。

这个语法和序列解包有相似的限制: 在序列里,你不能有多个含有星号的变量名。

添加通配符

如果所有的模式都没有匹配上,这个时候,你可能想打印一条错误消息: 这个命令无法识别。你可以用我们刚刚学到的特性,把case [* ignored_words] 作为你最后一个模式。但是还有更简单的方法:

match command.split():
    case ["quit"]: ... # 为了简单省略代码
    case ["go", direction]: ...
    case ["drop", *objects]: ...
    ... # 其他情况
    case _:
        print(f"Sorry, I couldn't understand {command!r}")

这个特别的写作_(被称为通配符)的模式总能被匹配上,但是它不会绑定任何变量。

请注意,他会匹配任何对象,不仅仅是序列。所以,只有把它作为最后一个模式时才有意义(为了防止这个错误,Python会制止你在前面的模式中使用它)。

组合模式

现在是个好的机会让我们先放下这些例子,来弄明白之前你用的这些模式是怎样被构建的。模式可以和模式之间相互嵌套,而且我们在上面的例子里也隐式地做过这样的操作。

我们见过了一些"简单的"模式(简单在这里意思是他们不包含其他的模式):

  • 捕获模式 (独立的变量名,例如direction, action, objects)。我们没有单独讨论过这些模式,但是把他们当做了其他模式的一部分。
  • 字面值模式 (字符串字面值,数字字面值,True, FalseNone)。
  • 通配符模式_

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

或模式

回到我们的冒险游戏例子,你可能会发现你想要一些模式有相同的结果。比如,你可能想要命令 northgo north 有同样的效果。你可能还会想给任意X别名: get X, pick up X and pick X up.

模式中我们可以用|符号把他们绑定在一起作为可选项。例如,你可以这样写:

match command.split():
    ... # 其他情况
    case ["north"] | ["go", "north"]:
        current_room = current_room.neighbor("north")
    case ["get", obj] | ["pick", "up", obj] | ["pick", obj, "up"]:
        ... # 捡起指定物品的代码

这种被称作或模式并且会产出预期的结果。模式会从左到右挨个尝试。这会引出一个问题,如果有多个选项匹配的时候,绑定是怎么样的。在写或模式的时候,一个很重要的限制时,所有的选项都应该绑定同样的变量。所以一个模式[1, x] | [2, y]是不允许的。因为这会导致成功匹配之后,不清楚绑定上了哪个变量。 [1, x] | [2, x] 则很完美并且成功之后总是绑定x

捕获匹配的子模式

我们第一版"go"命令写的是["go", direction]模式。我们最新实现的一版改动里用的是模式["north"] | ["go", "north],这种方法既有优点也有缺点。后面的这个版本支持别名,但是方向是硬编码的。这让我们不得不独立实现每一个方向:north/south/east/west。这让我们的代码重复,但是我们能更好的验证用户的输入,如果用户输入的是""go figure!而不是方向的时候,我们不会匹配到这个分支的逻辑。

我们可以这样写,从而同时获得两种好处(为了简洁,我会忽略别名版本):

match command.split():
    case ["go", ("north" | "south" | "east" | "west")]:
        current_room = current_room.neighbor(...)
        # 我们怎么能知道去了哪个方向呢?

这个代码是一个单分支的并且它验证了”go“后面的单词真的是一个方向。但是让玩家在周围移动的代码需要知道哪个方向才是用户选择的方向,而这种写法并做不到。我们需要的是一个像或模式一样工作的模式但是同时还能捕获变量。我们可以通过as模式做到:

match command.split():
    case ["go", ("north" | "south" | "east" | "west") as direction]:
        current_room = current_room.neighbor(direction)

as模式会匹配它左边的任何模式,并且把匹配到的值和变量名绑定。

在模式中添加条件

目前我们看到的这些模式,能够做强大的数据过滤。但有时,你也许会想要布尔表达式的完整功能。比方说你只想要"go"命令只在一个有限的集合中生效,比如通往离开当前房间的出口方向。我们可以给我们的分支加上看守。看守由一个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")

看守不是模式的一部分,而是分支的一部分。它会在模式成功匹配,并且所有的模式变量都绑定之后(这也是为什么上面的代码能在条件里使用direction变量)检查。如果模式匹配成功并且条件为真,分支的主体才会照常执行。如果模式匹配了但是条件为假,匹配指令会继续检查下一个分支,就像这个模式没有被成功匹配(可能会有一些副作用:比如有一些变量已经被绑定)。

增加一个界面:匹配对象

你的冒险已经成功了,现在你被要求实现一个图形界面。你选择的界面库支持你写一个事件循环,在循环里你可以通过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 # 忽略其他的按键
    case other_event:
        raise ValueError(f"Unrecognized event: {other_event}")

Click(position=(x, y))这样的模式只有在事件是Click类别的子类时才会匹配。它也会要求事件有能匹配(x, y)模式的position属性。如果匹配上了,局部变量xy也会有预期的值。

KeyPress()这样没有参数的模式会匹配任何是KeyPress类的实例。只有你在模式里声明的属性会被匹配,其他的属性都会被忽略。

匹配位置属性

前面一个小节描述了如何在匹配对象的时候,匹配命名属性。 对于某些对象来说,通过位置来匹配参数会更加方便(尤其是如果只有少量的属性,并且有"标准"的顺序时)。 如果你用的是命名元组或者数据类时,你也可以使用构建对象时使用的顺序。例如,如果上面的界面框架是这样定义这些类的:

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类型,并且这个Button类型是通过enum.Enum来构建的。所以,你可以像这样来匹配枚举值:

match event.get():
    case Click((x, y), button=Button.LEFT):  # 点击了鼠标左键
        handle_click_at(x, y)
    case Click():
        pass  # 忽略其他点击

这个可以用于所有的用点访问的名字(比如math.pi)。不过,一个不符合要求的名字(即,单纯的没有点的变量名)会被看成是一个捕获模式。所以我们需要总是在模式中使用符合要求的常数来避免这种歧义。

上云:映射

你决定给你的游戏做一个在线版本。你所有的逻辑都在服务器上,同时客户端的图形界面会通过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"}会匹配上面的第一个模式。

匹配内置类

上面的代码可以加上一些校验。因为这些消息来源于外部,这些字段的类型可能会出错,从而导致一些bug或者安全隐患。

任何类都是一个可行的匹配目标,也包括一些内置的类比如bool, str 或者 int。 这让我们能够把上面的代码和类模式结合在一起。所以,相比于写{"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 - 简介

版权信息