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

Python 增强提案

PEP 635 – 结构化模式匹配:动机和原理

作者:
Tobias Kohn <kohnt at tobiaskohn.ch>, Guido van Rossum <guido at python.org>
BDFL 代表:

讨论列表:
Python-Dev 列表
状态:
最终
类型:
信息性
创建:
2020-09-12
Python 版本:
3.10
历史记录:
2020-10-22, 2021-02-08
决议:
Python 提交者信息

目录

摘要

本 PEP 提供了对 PEP 634(“结构化模式匹配:规范”)的动机和原理说明。建议初次阅读者先阅读 PEP 636,该 PEP 提供了对模式的概念、语法和语义的更易理解的介绍。

动机

(结构化)模式匹配语法在许多语言中都有,从 Haskell、Erlang 和 Scala 到 Elixir 和 Ruby。(JavaScript 也正在考虑一项提案。)

Python 已经通过序列解包赋值支持了这种模式的有限形式,新提案利用了这一点。

其他一些常见的 Python 习惯用法也相关

  • if ... elif ... elif ... else 习惯用法通常用于以一种临时的方式找出对象的类型或形状,使用一个或多个检查,例如 isinstance(x, cls)hasattr(x, "attr")len(x) == n"key" in x 作为守卫来选择一个适用的代码块。然后,代码块可以假设 x 支持由守卫检查的接口。例如
    if isinstance(x, tuple) and len(x) == 2:
        host, port = x
        mode = "http"
    elif isinstance(x, tuple) and len(x) == 3:
        host, port, mode = x
    # Etc.
    

    可以使用 match 更优雅地呈现类似这样的代码

    match x:
        case host, port:
            mode = "http"
        case host, port, mode:
            pass
        # Etc.
    
  • AST 遍历代码通常查找与给定模式匹配的节点,例如检测形状为“A + B * C”的节点的代码可能如下所示
    if (isinstance(node, BinOp) and node.op == "+"
            and isinstance(node.right, BinOp) and node.right.op == "*"):
        a, b, c = node.left, node.right.left, node.right.right
        # Handle a + b*c
    

    使用 match,这变得更易读

    match node:
        case BinOp("+", a, BinOp("*", b, c)):
            # Handle a + b*c
    

我们相信,向 Python 添加模式匹配将使 Python 用户能够为上述示例和许多其他示例编写更简洁、更易读的代码。

有关此提案的更学术性讨论,请参阅 [1]

模式匹配与面向对象

模式匹配是对面向对象范式的补充。使用面向对象和继承,我们可以轻松地在基类上定义一个方法,该方法定义特定操作在该类上的默认行为,并且可以在子类中覆盖此默认行为。我们还可以使用访问者模式将操作与数据分离。

但这对于所有情况都不够。例如,代码生成器可能会使用 AST,并且在许多操作中,生成的代码需要根据节点的类以及某些类属性的值(如上面的 BinOp 示例)而变化。访问者模式对此不够灵活:它只能根据类进行选择。

请参阅 完整示例

与访问者模式一样,模式匹配允许严格分离关注点:特定的操作或数据处理独立于类层次结构或被操纵的对象。特别是当处理预定义甚至内置类时,通常不可能向各个类添加更多方法。模式匹配不仅减轻了程序员或类设计者为访问者模式所需的样板代码带来的负担,而且足够灵活,可以直接与内置类型一起使用。它自然地区分不同长度的序列,尽管这些序列可能共享相同的类,但结构明显不同。此外,模式匹配会自动考虑继承:默认情况下,继承自 C 的类 D 将由针对 C 的模式处理。

面向对象编程面向单分派:是单个实例(或其类型)决定调用哪个方法。在二元运算符的情况下,这会导致一种人为的情况,其中两个对象都可能在决定使用哪个实现方面发挥同等作用(Python 通过使用反向二元方法解决了这个问题)。模式匹配在结构上更适合处理这种多分派情况,其中要执行的操作在一定程度上取决于多个对象的类型。

模式和函数式风格

许多 Python 应用程序和库不是以一致的面向对象风格编写的——与 Java 不同,Python 鼓励在模块的顶层定义函数,对于简单的数据结构,元组(或命名元组或列表)和字典通常被单独使用或与类或数据类混合使用。

模式匹配特别适合于分解此类数据结构。作为一个极端的例子,使用 match 很容易编写选择 JSON 数据结构的代码

match json_pet:
    case {"type": "cat", "name": name, "pattern": pattern}:
        return Cat(name, pattern)
    case {"type": "dog", "name": name, "breed": breed}:
        return Dog(name, breed)
    case _:
        raise ValueError("Not a suitable pet")

函数式编程通常更喜欢声明式风格,重点关注数据中的关系。尽可能避免副作用。因此,模式匹配自然适合并高度支持函数式编程风格。

原理

本节提供了对各个设计决策的原理说明。它取代了标准 PEP 格式中的“被拒绝的方案”。它按与规范(PEP 634)相对应的部分进行组织。

概述和术语

模式匹配的大部分功能来自子模式的嵌套。因此,模式匹配的成功直接取决于子模式的成功是设计的基石。但是,尽管像 P(Q(), R()) 这样的模式只有在两个子模式 Q()R() 都成功时才会成功(即模式 P 的成功取决于 QR),但首先检查模式 P。如果 P 失败,则不会尝试 Q()R()(这是 P 失败时,首先没有主题可以与 Q()R() 匹配的直接结果)。

另请注意,模式将名称绑定到值,而不是执行赋值。这反映了模式的目标是避免副作用,这也意味着捕获或 AS 模式不能将值赋予属性或下标。因此,我们始终使用术语“绑定”而不是“赋值”来强调传统赋值和模式中名称绑定之间的细微差别。

match 语句

match 语句计算表达式以生成主题,找到第一个与主题匹配的模式,并执行关联的代码块。从语法上讲,match 语句因此采用一个表达式和一个 case 子句序列,其中每个 case 子句包含一个模式和一个代码块。

由于 case 子句包含一个代码块,因此它们遵循现有的缩进方案,其语法结构为 <keyword> ...: <(indented) block>,这类似于复合语句。关键字 case 反映了它在模式匹配语言中的广泛使用,忽略了那些使用其他语法方式(如 | 等符号)的语言,因为它不适合已建立的 Python 结构。下面将讨论关键字后面模式的语法。

鉴于 case 子句遵循复合语句的结构,match 语句本身也自然成为一个复合语句,遵循相同的语法结构。这自然会导致 match <expr>: <case_clause>+。请注意,match 语句确定一个准作用域,其中计算出的主题保持活动状态(尽管不在局部变量中),类似于 with 语句如何在执行其代码块期间保持资源活动状态。此外,控制流从 match 语句流向 case 子句,然后离开 match 语句的代码块。因此,match 语句的代码块既具有语法意义也具有语义意义。

各种建议试图消除或避免 case 子句代码块自然产生的“双重缩进”。不幸的是,所有这些关于扁平缩进方案的提案都是以违反 Python 已建立的结构化范式为代价的,导致了额外的语法规则

  • 未缩进的 case 子句。这个想法是将 case 子句与 match 对齐,即
    match expression:
    case pattern_1:
        ...
    case pattern_2:
        ...
    

    这在 Python 程序员看来可能很笨拙,因为在其他任何地方,冒号后面都跟着缩进。match 既不遵循简单语句也不遵循复合语句的语法方案,而是建立了自己的类别。

  • 将表达式放在“match”后面的单独一行。这个想法是使用生成主题的表达式作为语句来避免 match 尽管有冒号但实际上没有代码块的奇异性

    match:
        expression
    case pattern_1:
        ...
    case pattern_2:
        ...
    

    最终放弃了这种方案,因为第一个代码块将是 Python 语法中的另一个新奇事物:一个唯一的內容是单个表达式而不是语句序列的代码块。尝试通过添加或重新利用另一个类似于match: return expression的关键字来解决此问题,但没有得到任何令人满意的解决方案。

虽然扁平缩进可以节省一些水平空间,但增加复杂性或不寻常规则的成本太高。它还会使简单的代码编辑器变得复杂。最后,可以通过允许匹配语句使用“半缩进”(即两个空格而不是四个空格)来缓解水平空间问题(尽管我们不推荐这样做)。

在作为此 PEP 开发一部分编写的使用match的示例程序中,观察到代码简洁性有了明显的改进,这弥补了额外的缩进级别。

语句与表达式。一些建议围绕着将match设为表达式而不是语句的想法。但是,这与 Python 面向语句的特性不太相符,会导致异常冗长和复杂的表达式,以及需要发明新的语法结构或破坏既定的语法规则。将match作为表达式的明显后果是,case 子句将不再能够附加任意代码块,而只能附加单个表达式。总的来说,严格的限制在任何情况下都无法抵消某些特殊用例中的轻微简化。

硬关键字与软关键字。可以选择将 match 设为硬关键字,或选择不同的关键字。虽然使用硬关键字会简化简单语法高亮器的使用,但出于以下几个原因,我们决定不使用硬关键字

  • 最重要的是,新的解析器不需要我们这样做。与async在几个版本中作为软关键字导致的困难不同,这里我们可以将match永久设为软关键字。
  • match在现有代码中非常常用,因此它会破坏几乎所有现有的程序,并给许多可能不会从新语法中受益的人带来修复代码的负担。
  • 很难找到一个在现有程序中不常用作标识符的备用关键字,并且仍然能够清晰地反映语句的含义。

使用“as”或“|”代替“case”作为 case 子句。此处提出的模式匹配是多分支控制流(与 Algol 派生语言中的switch或 Lisp 中的cond一致)和函数式语言中发现的对象解构的组合。虽然建议的关键字case突出了多分支方面,但诸如as之类的备用关键字同样可行,突出了解构方面。aswith例如,还具有已经是 Python 中关键字的优点。但是,由于case作为关键字只能作为match语句内部的引导关键字出现,因此解析器很容易区分它作为关键字还是变量的使用。

其他变体将使用|=>之类的符号,或者完全不使用特殊标记。

由于 Python 是一种遵循 Algol 传统的面向语句的语言,并且每个复合语句都以一个识别关键字开头,因此case似乎最符合 Python 的风格和传统。

match 语义

不同 case 子句的模式可能会重叠,因为多个 case 子句会匹配给定的主题。先匹配规则确保对给定主题的 case 子句选择是明确的。此外,case 子句可以具有越来越通用的模式,以匹配更广泛的主题集。然后,先匹配规则确保可以选择最精确的模式(尽管程序员有责任正确排序 case 子句)。

在静态类型语言中,match 语句将被编译成决策树以快速有效地选择匹配模式。但是,这将要求所有模式都纯粹是声明性的和静态的,这与 Python 已建立的动态语义相冲突。因此,建议的语义代表了一条融合两方面优点的路径:模式按严格的顺序尝试,以便每个 case 子句都构成一个实际语句。同时,我们允许解释器缓存关于主题的任何信息或更改尝试子模式的顺序。换句话说:如果解释器发现主题不是类C的实例,它可以直接跳过再次测试此类的 case 子句,而无需执行重复的实例检查。如果一个守卫规定变量x必须为正(例如if x > 0),则解释器可能会在绑定x之后以及考虑任何其他子模式之前直接检查这一点。

绑定和作用域。在许多模式匹配实现中,每个 case 子句都会建立一个单独的作用域。然后,由模式绑定的变量仅在相应的 case 代码块内可见。但是,在 Python 中,这没有意义。建立单独的作用域基本上意味着每个 case 子句都是一个单独的函数,无法直接访问周围作用域中的变量(无需诉诸nonlocal)。此外,case 子句将无法再通过标准语句(如returnbreak)影响任何周围的控制流。因此,这种严格的作用域会导致不直观和令人惊讶的行为。

这直接导致任何变量绑定都比相应的 case 或 match 语句存活时间长。即使仅部分匹配主题的模式也可能绑定局部变量(实际上,这对于守卫正常工作是必要的)。但是,这些变量绑定的语义与现有的 Python 结构(如 for 循环和 with 语句)一致。

守卫

某些约束无法仅通过模式充分表达。例如,“小于”或“大于”关系违反了模式通常的“等于”语义。此外,不同的子模式是独立的,不能相互引用。守卫的添加解决了这些限制:守卫是附加到模式的任意表达式,并且必须计算为“真值”才能使模式成功。

例如,case [x, y] if x < y:使用守卫(if x < y)来表达两个否则不相交的捕获模式xy之间的“小于”关系。

从概念上讲,模式以声明式风格描述主题的结构约束,理想情况下没有任何副作用。特别是,请记住,模式与表达式明显不同,遵循不同的目标和语义。然后,守卫以高度受控的方式使用任意表达式(可能具有副作用)增强 case 代码块。将整体功能拆分为静态结构部分和动态评估部分,不仅有助于可读性,还可以为编译器优化带来巨大的潜力。为了保持这种清晰的分离,仅在 case 子句级别支持守卫,而不支持单个模式。

示例使用守卫

def sort(seq):
    match seq:
        case [] | [_]:
            return seq
        case [x, y] if x <= y:
            return seq
        case [x, y]:
            return [y, x]
        case [x, y, z] if x <= y <= z:
            return seq
        case [x, y, z] if x >= y >= z:
            return [z, y, x]
        case [p, *rest]:
            a = sort([x for x in rest if x <= p])
            b = sort([x for x in rest if p < x])
            return a + [p] + b

模式

模式有两个用途:它们对主题施加(结构)约束,并指定应从主题中提取哪些数据值并绑定到变量。在可迭代解包中,这可以被视为 Python 中模式匹配的原型,只有一个结构模式来表达序列,而有一组丰富的绑定模式来为特定变量或字段分配值。完整的模式匹配与之不同之处在于,结构模式种类更多,但绑定模式最少。

模式与赋值目标(如可迭代解包)有两个区别:它们对主题的结构施加了额外的约束,并且主题可以在任何时候安全地无法匹配特定模式(在可迭代解包中,这构成一个错误)。后者意味着模式应尽可能避免副作用。

避免副作用的愿望是捕获模式不允许将值绑定到属性或下标的原因之一:如果包含的模式在后面的步骤中失败,则很难撤消此类绑定。

模式匹配的基石是任意嵌套模式的可能性。嵌套允许表达深层树结构(有关嵌套类模式的示例,请参见上面的动机部分)以及备选方案。

虽然模式表面上看起来像表达式,但重要的是要记住它们之间存在明显的区别。实际上,没有模式是或包含表达式。将模式视为类似于函数定义中形式参数的声明性元素更有成效。

AS 模式

模式分为两类:大多数模式施加主题需要满足的(结构)约束,而捕获模式将主题绑定到名称,而不管主题的结构或实际值如何。因此,模式可以表达约束或绑定值,但不能两者兼而有之。AS 模式填补了这一空白,因为它允许用户指定通用模式以及在变量中捕获主题。

AS 模式的一些典型用例包括 OR 和 Class 模式以及绑定名称,例如case BinOp('+'|'-' as op, ...):case [int() as first, int() as second]:。后者可以理解为主题必须满足两个不同的模式:[first, second]以及[int(), int()]。因此,AS 模式可以被视为“and”模式的特例(有关“and”模式的其他讨论,请参见下面的 OR 模式)。

在早期版本中,AS 模式被设计为“海豹模式”,写成case [first:=int(), second:=int()]。但是,使用as:=提供了一些优势

  • 海象运算符 := 用于捕获右侧表达式的结果,而 as 通常表示某种形式的“处理”,例如 import foo as barexcept E as err:。实际上,模式 P as x 不会将模式 P 赋值给 x,而是将成功匹配 P 的主体赋值给 x
  • as 允许数据从左到右更一致地流动(类模式中的属性也遵循从左到右的数据流)。
  • 海象运算符看起来非常类似于类模式中匹配属性的语法,可能会导致一些混淆。

示例 使用 AS 模式

def simplify_expr(tokens):
    match tokens:
        case [('('|'[') as l, *expr, (')'|']') as r] if (l+r) in ('()', '[]'):
            return simplify_expr(expr)
        case [0, ('+'|'-') as op, right]:
            return UnaryOp(op, right)
        case [(int() | float() as left) | Num(left), '+', (int() | float() as right) | Num(right)]:
            return Num(left + right)
        case [(int() | float()) as value]:
            return Num(value)

OR 模式

OR 模式允许你将“结构上等价”的备选方案组合成一个新的模式,即多个模式可以共享一个共同的处理程序。如果 OR 模式的任何子模式与主体匹配,则整个 OR 模式都成功。

静态类型语言禁止在 OR 模式内绑定名称(捕获模式),因为变量类型的潜在冲突。作为动态类型语言,Python 在这里可以不那么严格,允许在 OR 模式内使用捕获模式。但是,每个子模式必须绑定相同的变量集,以免留下可能未定义的名称。对于两个备选方案 P | Q,这意味着如果 P 绑定变量 uv,则 Q 必须精确绑定相同的变量 uv

关于是否使用竖线符号 |or 关键字来分隔备选方案,曾有一些讨论。OR 模式并不完全符合这两个符号现有的语义和用法。但是,| 是所有支持 OR 模式的编程语言中选择的符号,并且在 Python 中也用于正则表达式中的该功能。它也是形式语法(包括 Python 的语法)中备选方案的传统分隔符。此外,| 不仅用于按位 OR,还用于集合联合和字典合并(PEP 584)。

也考虑了其他备选方案,但这些方案都不允许 OR 模式嵌套在其他模式中。

  • 使用逗号:
    case 401, 403, 404:
        print("Some HTTP error")
    

    这看起来太像元组了——我们将不得不找到一种不同的方式来拼写元组,并且该结构必须在类模式的参数列表内使用括号括起来。一般来说,逗号在 Python 中已经有很多不同的含义,我们不应该再添加更多含义。

  • 使用堆叠的 case:
    case 401:
    case 403:
    case 404:
        print("Some HTTP error")
    

    这将是 C 中使用其 case 语句的贯穿语义的方式。但是,我们不想误导人们认为 match/case 使用贯穿语义(这是 C 中常见的错误来源)。此外,这将是一种新颖的缩进模式,这可能会使 IDE 等工具更难以支持(它将破坏简单的规则“在以冒号结尾的行后添加一个缩进级别”)。最后,这也将不支持嵌套在其他模式内的 OR 模式。

  • 使用“case in”后跟逗号分隔的列表:
    case in 401, 403, 404:
        print("Some HTTP error")
    

    这将不适用于嵌套在其他模式内的 OR 模式,例如

    case Point(0|1, 0|1):
        print("A corner of the unit square")
    

AND 和 NOT 模式

由于此提案定义了 OR 模式(|)来匹配多个备选方案中的一个,为什么不也定义 AND 模式(&)甚至 NOT 模式(!)呢?特别是考虑到其他一些语言(例如 F#)支持 AND 模式。

但是,目前尚不清楚这将有多大用处。匹配字典、对象和序列的语义已经包含了一个隐式的“and”:所有提到的属性和元素都必须存在才能使匹配成功。保护条件也可以支持假设的“and”运算符将用于的许多用例。

使用运算符 ! 作为前缀对匹配模式进行否定,将完全匹配如果模式本身不匹配。例如,!(3 | 4) 将匹配除 34 之外的任何内容。但是,有来自其他语言的证据表明这很少有用,主要用作双重否定 !! 来控制变量范围并防止变量绑定(这并不适用于 Python)。其他用例最好使用保护条件来表达。

最终,我们决定这将使语法变得更复杂,而不会带来明显的益处。它可以随时稍后添加。

示例 使用 OR 模式

def simplify(expr):
    match expr:
        case ('/', 0, 0):
            return expr
        case ('*'|'/', 0, _):
            return 0
        case ('+'|'-', x, 0) | ('+', 0, x) | ('*', 1, x) | ('*'|'/', x, 1):
            return x
    return expr

字面量模式

字面量模式是一种方便的方式,用于对主体的值施加约束,而不是其类型或结构。它们还允许你使用模式匹配模拟 switch 语句。

通常,主体通过标准相等性与字面量模式进行比较(在 Python 语法中为 x == y)。因此,字面量模式 1.01 精确匹配同一组对象,即 case 1.0:case 1: 是完全可互换的。原则上,True 也将匹配同一组对象,因为 True == 1 成立。但是,我们认为许多用户会惊讶地发现 case True: 匹配主体 1.0,从而导致一些细微的错误和复杂的解决方法。因此,我们采用了以下规则:三个单例模式 NoneFalseTrue 通过身份(在 Python 语法中为 x is y)而不是相等性进行匹配。因此,case True: 将仅匹配 True 以及其他任何内容。请注意,case 1: 仍然会匹配 True,因为字面量模式 1 通过相等性而不是身份工作。

早期的想法是在数字上引入层次结构,以便 case 1.0 将匹配整数 1 和浮点数 1.0,而 case 1: 将仅匹配整数 1,最终被放弃,转而采用基于相等性的更简单、更一致的规则。此外,任何额外的检查主体是否为 numbers.Integral 的实例,都会带来很高的运行时成本,从而引入本质上是 Python 中一个新概念的东西。如果需要,可以使用显式语法 case int(1):

回想一下,字面量模式**不是**表达式,而是直接表示特定值。从实用角度来看,我们希望允许使用负数甚至复数值作为字面量模式,但它们不是原子字面量(只有无符号实数和虚数才是)。例如,-3+4j 在语法上是表单 BinOp(UnaryOp('-', 3), '+', 4j) 的表达式。由于表达式不是模式的一部分,因此我们必须为这些值添加显式的语法支持,而无需诉诸完整的表达式。

另一方面,内插的 f-字符串不是字面量值,尽管它们的外观如此,因此不能用作字面量模式(但是,支持字符串连接)。

字面量模式不仅本身作为模式出现,而且作为映射模式中的键出现。

范围匹配模式。这将允许诸如 1...6 之类的模式。但是,存在许多歧义

  • 范围是开、半开还是闭?(即,在上面的例子中 6 是否包含在内?)
  • 范围是否匹配单个数字或范围对象?
  • 范围匹配通常用于字符范围('a'…’z'),但这在 Python 中不起作用,因为没有字符数据类型,只有字符串。
  • 如果可以预先构建跳转表,则范围匹配通常可以成为一项重要的性能优化,但这在 Python 中通常是不可能的,因为名称可以动态重新绑定。

与其为范围创建特殊情况的语法,不如决定允许自定义模式对象(InRange(0, 6))会更灵活且更少歧义;但是这些想法暂时被搁置了。

示例 使用字面量模式

def simplify(expr):
    match expr:
        case ('+', 0, x):
            return x
        case ('+' | '-', x, 0):
            return x
        case ('and', True, x):
            return x
        case ('and', False, x):
            return False
        case ('or', False, x):
            return x
        case ('or', True, x):
            return True
        case ('not', ('not', x)):
            return x
    return expr

捕获模式

捕获模式采用名称的形式,该名称接受任何值并将其绑定到一个(局部)变量(除非该名称被声明为 nonlocalglobal)。从这个意义上说,捕获模式类似于函数定义中的参数(当调用函数时,每个参数都会将相应的参数绑定到函数作用域中的局部变量)。

用于捕获模式的名称不得与同一模式中的其他捕获模式重名。这再次类似于参数,参数也要求每个参数名称在参数列表中唯一。但是,它与可迭代解包赋值不同,在可迭代解包赋值中,允许重复使用变量名作为目标(例如,x, x = 1, 2)。不支持模式中使用(x, x) 的原因在于其解读存在歧义:它可能被视为可迭代解包,其中只有第二个绑定到x 的绑定会被保留。但它也可能被视为表示一个有两个相等元素的元组(这本身也存在一些问题)。如果需要,将来仍然可以引入对重复使用名称的支持。

曾有人提议显式标记捕获模式,从而将其识别为绑定目标。根据这个想法,捕获模式可以写成例如?x$x=x。这种显式捕获标记的目的是让未标记的名称成为值模式(见下文)。然而,这是基于这样一个误解,即模式匹配是switch 语句的扩展,强调基于(序数)值的快速切换。Python 之前确实有人提议过这样的switch 语句(参见PEP 275PEP 3103)。另一方面,模式匹配构建了一个可迭代解包的广义概念。从数据结构中提取并绑定值是该概念的核心,也是最常见的用例。因此,显式捕获模式标记会背离所提出的模式匹配语法的目标,并以牺牲核心用例的额外语法杂乱为代价,简化了一个次要的用例。

有人提议根本不需要捕获模式,因为可以通过将 AS 模式与通配符模式结合来获得相同的效果(例如,case _ as x 等价于 case x)。但是,这会显得冗长乏味,特别是考虑到我们预计捕获模式会非常常见。

示例:使用捕获模式

def average(*args):
    match args:
        case [x, y]:           # captures the two elements of a sequence
            return (x + y) / 2
        case [x]:              # captures the only element of a sequence
            return x
        case []:
            return 0
        case a:                # captures the entire sequence
            return sum(a) / len(a)

通配符模式

通配符模式是“捕获”模式的一种特殊情况:它接受任何值,但不将其绑定到变量。此规则背后的理念是支持在模式中重复使用通配符。虽然(x, x) 是错误的,但(_, _) 是合法的。

特别是在较大的(序列)模式中,允许模式专注于具有实际意义的值,同时忽略其他任何内容非常重要。如果没有通配符,则需要“发明”许多局部变量,这些变量会被绑定但从未使用。即使坚持命名约定并使用例如_1, _2, _3 来命名无关的值,这仍然会引入视觉杂乱并可能影响性能(将序列模式(x, y, *z)(_, y, *_) 进行比较,其中*z 强制解释器复制一个可能非常长的序列,而第二个版本则简单地编译成类似于y = seq[1] 的代码)。

关于选择下划线_ 作为通配符模式,即使这个名称不进行绑定,已经有很多讨论。但是,下划线已经被大量用作可迭代解包中的“忽略值”标记。由于通配符模式_ 从不绑定,因此下划线的这种用法不会干扰其他用法,例如在 REPL 或gettext 模块内部的用法。

有人提议使用...(即省略号标记)或*(星号)作为通配符。但是,这两个看起来都像是省略了任意数量的项。

case [a, ..., z]: ...
case [a, *, z]: ...

这两个示例看起来都像是匹配两个或更多项的序列,捕获第一个和最后一个值。虽然这可能是最终的“通配符”,但它并没有传达所需的语义。

一个不暗示任意数量项的替代方案是?。这甚至在PEP 640 中独立于模式匹配而被提出。但是,我们认为将? 用作特殊的“赋值”目标可能会比使用_ 对 Python 用户造成更多困惑。它违反了 Python(诚然模糊)的原则,即仅以与它们在常用英语用法或高中数学中使用方式类似的方式使用标点符号,除非这种用法在其他编程语言中非常 完善(例如,使用点进行成员访问)。

问号在这两方面都失败了:它在其他编程语言中的使用是一个“问题”概念的各种用法,这些用法只是隐约地暗示了这个概念。例如,在 shell 通配符中表示“任何字符”,在正则表达式中表示“可能”,在 C 和许多 C 派生语言中表示“条件表达式”,在 Scheme 中表示“谓词函数”,在 Rust 中表示“修改错误处理”,在 TypeScript 中表示“可选参数”和“可选链”(后者含义也已由PEP 505 提议用于 Python)。一个尚未命名的 PEP 提议用它来标记可选类型,例如int?

编程系统中? 的另一个常见用法是“帮助”,例如在 IPython 和 Jupyter Notebook 以及许多交互式命令行实用程序中。

此外,这会使 Python 处于一个相当独特的位置:下划线作为通配符模式出现在我们能找到的所有 具有模式匹配的编程语言中(包括C#ElixirErlangF#GraceHaskellMathematicaOCamlRubyRustScalaSwiftThorn)。考虑到许多 Python 用户也使用其他编程语言,在学习 Python 时拥有先前的经验,并且可能在学习 Python 后转向其他语言,我们发现这种完善的标准对于可读性和可学习性非常重要和相关。在我们看来,担心此通配符意味着常规名称受到特殊处理的顾虑不足以引入使 Python 特殊的语法。

Else 块。其模式为单个通配符(即case _:)且没有保护的 case 块接受任何主题,而无需将其绑定到变量或执行任何其他操作。因此,如果支持的话,它在语义上等同于else:。但是,将这样的 else 块添加到 match 语句语法中并不会消除在其他上下文中对通配符模式的需求。另一个反对意见是,else 块将有两个可能的缩进级别:与case 对齐或与match 对齐。作者发现选择哪个缩进级别颇有争议。

示例:使用通配符模式

def is_closed(sequence):
    match sequence:
        case [_]:               # any sequence with a single element
            return True
        case [start, *_, end]:  # a sequence with at least two elements
            return start == end
        case _:                 # anything
            return False

值模式

对于参数值或阐明特定值的含义,使用命名常量是一种良好的编程风格。显然,最好编写case (HttpStatus.OK, body): 而不是case (200, body):,例如。这里出现的主要问题是如何区分捕获模式(变量绑定)和值模式。围绕此问题的总体讨论提出了大量选项,我们无法在这里全部列出。

严格来说,值模式并不是真正必要的,但可以使用保护来实现,即case (status, body) if status == HttpStatus.OK:。尽管如此,值模式的便利性是毋庸置疑且显而易见的。

观察到常量倾向于用大写字母编写或收集在类似枚举的命名空间中,这表明可以根据语法来识别常量。但是,使用大小写作为标记的想法遭到了质疑,因为在核心 Python 中没有类似的先例(尽管在其他语言中很常见)。因此,我们只采用了以下规则:任何带点的名称(即属性访问)都应解释为值模式,例如上面的HttpStatus.OK。这尤其排除了局部变量和当前模块中定义的全局变量充当常量。

有人提议为此目的使用前导点(例如.CONSTANT),但遭到批评,因为人们认为点不是为此目的而设计的足够明显的标记。部分受到其他编程语言中形式的启发,提出了许多不同的标记/符号(例如^CONSTANT$CONSTANT==CONSTANTCONSTANT? 或用反引号括起来单词),尽管没有明显或自然的选项。因此,当前提案将此类“常量”标记的讨论和可能的引入留给未来的 PEP。

基于名称是全局变量(即编译器将全局变量视为常量而不是捕获模式)来区分名称的语义会导致各种问题。模块中全局变量的添加或更改可能会对模式产生意外的副作用。此外,模式匹配无法直接在模块的作用域内使用,因为所有变量都将是全局的,这使得捕获模式变得不可能。

示例:使用值模式

def handle_reply(reply):
    match reply:
        case (HttpStatus.OK, MimeType.TEXT, body):
            process_text(body)
        case (HttpStatus.OK, MimeType.APPL_ZIP, body):
            text = deflate(body)
            process_text(text)
        case (HttpStatus.MOVED_PERMANENTLY, new_URI):
            resend_request(new_URI)
        case (HttpStatus.NOT_FOUND):
            raise ResourceNotFound()

组模式

允许用户显式指定分组在 OR 模式的情况下特别有用。

序列模式

序列模式尽可能遵循已建立的可迭代解包的语法和语义。当然,子模式取代了赋值目标(变量、属性和下标)。此外,序列模式只匹配一组精心挑选的可能的主题,而可迭代解包可以应用于任何可迭代对象。

  • 与可迭代解包一样,我们不区分“元组”和“列表”表示法。[a, b, c](a, b, c)a, b, c 都是等价的。虽然这意味着我们有一种冗余的表示法,并且专门检查列表或元组需要更多努力(例如 case list([a, b, c])),但我们尽可能地模仿可迭代解包。
  • 星号模式将捕获任意长度的子序列,再次反映可迭代解包。任何序列模式中只能存在一个星号项。理论上,像 (*_, 3, *_) 这样的模式可以理解为表示包含值 3 的任何序列。然而,在实践中,这只会适用于非常狭窄的一组用例,否则会导致低效的回溯甚至歧义。
  • 序列模式**不会**遍历可迭代主题。所有元素都是通过下标和切片访问的,并且主题必须是 collections.abc.Sequence 的实例。当然,这包括列表和元组,但不包括例如集合和字典。虽然它将包括字符串和字节,但我们对这些做了例外(见下文)。

序列模式不能仅仅遍历任何可迭代对象。如果整体模式失败,则必须撤消从迭代中消耗的元素,这是不可行的。

为了识别序列,我们不能仅仅依靠 len() 以及下标和切片,因为序列在这方面与映射(例如 dict)共享这些协议。如果序列模式也匹配字典或其他实现映射协议(即 __getitem__)的对象,那将令人惊讶。因此,解释器执行实例检查以确保所讨论的主题确实是序列(已知类型)。(作为最常见情况的优化,如果主题恰好是列表或元组,则可以跳过实例检查。)

字符串和字节对象具有双重性质:它们本身都是“原子”对象,也是序列(具有强递归性质,因为字符串是字符串的序列)。字符串和字节的典型行为和用例与元组和列表的不同之处足以保证明确的区别。事实上,字符串充当序列通常是不直观的和非预期的,这一点从经常出现的问题和投诉中可以看出。因此,字符串和字节不匹配序列模式,将序列模式限制为对“序列”的非常具体的理解。内置的 bytearray 类型,作为 bytes 的可变版本,也应该是一个例外;但我们不打算枚举所有可能用于表示字节的其他类型(例如,一些,但不是全部,memoryviewarray.array 的实例)。

映射模式

字典或一般的映射是 Python 中最重要和使用最广泛的数据结构之一。与序列相比,映射是为了快速直接访问由键标识的任意元素而构建的。在大多数情况下,元素是从字典中通过已知键检索的,而不考虑存储在同一字典中的任何顺序或其他键值对。字符串键尤其常见。

映射模式反映了字典查找的常用用法:它允许用户通过常量/已知键从映射中提取一些值,并使这些值与给定的子模式匹配。即使没有 **rest,主题中的额外键也会被忽略。这与序列模式不同,在序列模式中,额外项会导致匹配失败。但映射实际上与序列不同:它们具有自然的结构子类型化行为,即,在某处传递一个带有额外键的字典很可能会正常工作。如果需要对映射施加上限并确保不存在其他键,则可以使用通常的双星号模式 **rest。但是,带有通配符的特殊情况 **_ 不受支持,因为它不会有任何效果,但可能会导致对映射模式语义的错误理解。

为了避免过度昂贵的匹配算法,键必须是字面量或值模式。

使用 get(key, default) 而不是 __getitem__(key) 后跟 AttributeError 检查有一个微妙的原因:如果主题碰巧是 defaultdict,则对不存在的键调用 __getitem__ 会添加该键。使用 get() 可以避免这种意外的副作用。

示例 使用映射模式

def change_red_to_blue(json_obj):
    match json_obj:
        case { 'color': ('red' | '#FF0000') }:
            json_obj['color'] = 'blue'
        case { 'children': children }:
            for child in children:
                change_red_to_blue(child)

类模式

类模式满足两个目的:检查给定主题是否确实是特定类的实例,以及从主题的特定属性中提取数据。轶事证据表明,就程序中的静态出现次数而言,isinstance() 是 Python 中最常用的函数之一。此类实例检查通常在随后访问存储在对象中的信息或对其进行可能的操纵之前进行。典型的模式可能类似于

def traverse_tree(node):
    if isinstance(node, Node):
        traverse_tree(node.left)
        traverse_tree(node.right)
    elif isinstance(node, Leaf):
        print(node.value)

在许多情况下,类模式会嵌套出现,如动机中给出的示例所示

if (isinstance(node, BinOp) and node.op == "+"
        and isinstance(node.right, BinOp) and node.right.op == "*"):
    a, b, c = node.left, node.right.left, node.right.right
    # Handle a + b*c

类模式允许您简洁地指定实例检查和相关属性(以及可能的进一步约束)。因此,非常容易编写例如在第一个例子中编写 case Node(left, right): 以及在第二个例子中编写 case Leaf(value):。虽然这确实适用于具有严格代数数据类型的语言,但在 Python 对象的结构中存在问题。

在处理通用 Python 对象时,我们面临着潜在的大量无序属性:Node 的实例包含大量属性(其中大多数是“特殊方法”,例如 __repr__)。此外,解释器无法可靠地推断属性的顺序。对于表示圆的对象,例如,属性 xyradius 没有内在的明显顺序。

我们设想了两种处理此问题的方法:要么显式命名感兴趣的属性,要么提供一个额外的映射来告诉解释器要提取哪些属性以及提取顺序。这两种方法都受支持。此外,显式命名感兴趣的属性允许您进一步指定对象的所需结构;如果对象缺少模式指定的属性,则匹配失败。

  • 显式命名的属性采用命名参数的语法。如果 Node 类的对象具有如上所述的两个属性 leftright,则模式 Node(left=x, right=y) 将提取这两个属性的值并分别将其分配给 xy。数据从左到右流动看起来不寻常,但与映射模式一致,并且有先例,例如通过 withimport 语句中的 as 进行赋值(实际上还有 AS 模式)。

    显式命名相关属性主要用于位置形式(如下)不足的更复杂的情况。

  • 类字段 __match_args__ 指定了一些属性及其顺序,允许类模式依赖于位置子模式,而无需显式命名相关的属性。这对于较小的对象或数据类的实例特别方便,其中感兴趣的属性相当明显并且通常具有明确定义的顺序。在某种程度上,__match_args__ 类似于形式参数的声明,它允许使用位置参数调用函数,而不是命名所有参数。

    这是一个类属性,因为它需要在类模式中命名的类上查找,而不是在主题实例上查找。

类模式的语法基于解构镜像构造语法的理念。这在几乎所有 Python 结构中都是如此,无论是赋值目标、函数定义还是可迭代解包。在所有这些情况下,我们发现“数据”发送和接收的语法几乎相同。

  • 赋值目标,如变量、属性和下标:foo.bar[2] = foo.bar[3]
  • 函数定义:用 def foo(x, y, z=6) 定义的函数调用方式例如 foo(123, y=45),其中调用站点提供的实际参数与定义站点处的形式参数匹配;
  • 可迭代解包:a, b = b, a[a, b] = [b, a](a, b) = (b, a),仅举几个等价的可能性。

使用相同的语法进行读写、l 值和 r 值,或构造和解构,因其在思考数据、数据流和数据操作方面的益处而被广泛接受。这同样扩展到实例的显式构造,其中类模式 C(p, q) 故意镜像创建实例的语法。

对于内置类boolbytearray等(例如,str(x)捕获x中的主题值)的特殊情况,可以通过以下方式由用户定义的类模拟。

class MyClass:
    __match_args__ = ["__myself__"]
    __myself__ = property(lambda self: self)

模式变量的类型注解。该提案是将模式与类型注解结合起来。

match x:
    case [a: int, b: str]: print(f"An int {a} and a string {b}:")
    case [a: int, b: int, c: int]: print("Three ints", a, b, c)
    ...

这个想法有很多问题。首先,冒号只能用在方括号或圆括号内,否则语法会变得模棱两可。并且由于 Python 不允许对泛型类型进行isinstance()检查,因此包含泛型的类型注解将无法按预期工作。

历史和背景

模式匹配起源于 20 世纪 70 年代后期,以元组解包的形式出现,并作为处理递归数据结构(如链表或树)(面向对象语言通常使用访问者模式来处理递归数据结构)的一种手段。模式匹配的早期支持者将结构化数据组织成“带标签的元组”,而不是像 C 中的struct或后来引入的对象。例如,二叉树中的一个节点将是一个元组,分别有两个元素用于左右分支,以及一个Node标签,写成Node(left, right)。在 Python 中,我们可能会将标签放在元组内部,如('Node', left, right),或者定义一个数据类Node来达到同样的效果。

使用现代语法,深度优先树遍历将按如下方式编写。

def traverse(node):
    match node:
        case Node(left, right):
            traverse(left)
            traverse(right)
        case Leaf(value):
            handle(value)

使用模式匹配处理递归数据结构的概念立即产生了使用模式匹配处理更一般的递归“模式”(即超越递归数据结构的递归)的想法。因此,模式匹配也将用于定义递归函数,例如

def fib(arg):
    match arg:
        case 0:
            return 1
        case 1:
            return 1
        case n:
            return fib(n-1) + fib(n-2)

随着模式匹配被反复集成到新的和新兴的编程语言中,其语法略有发展和扩展。上面fib示例中的前两个情况可以用更简洁的方式写成case 0 | 1:,其中|表示备选模式。此外,下划线_被广泛用作通配符,填充模式部分的结构或值无关紧要的地方。由于下划线已在 Python 的可迭代解包中经常以等效的能力使用(例如,_, _, third, _* = something),因此我们保留了这些通用标准。

值得注意的是,模式匹配的概念始终与函数的概念密切相关。不同的 case 子句始终被视为某种类似于半独立的函数,其中模式变量扮演参数的角色。当模式匹配写成重载函数时,这一点最为明显,类似于(Standard ML)

fun fib 0 = 1
  | fib 1 = 1
  | fib n = fib (n-1) + fib (n-2)

尽管 case 子句严格分离成独立函数的做法不适用于 Python,但我们发现模式与参数共享许多语法规则,例如将参数绑定到未限定的名称,或者对于特定模式/函数,变量/参数名称不得重复。

面向对象编程以其对抽象和封装的强调,对模式匹配提出了严峻挑战。简而言之:在面向对象编程中,我们不能再将对象视为带标签的元组。传递给构造函数的参数不一定指定对象的属性或字段。此外,对象字段不再具有严格的顺序,并且某些字段可能是私有的,因此无法访问。最重要的是,给定对象实际上可能是具有略微不同结构的子类的实例。

为了应对这一挑战,模式变得越来越独立于原始的元组构造函数。在类似Node(left, right)的模式中,Node不再是被动的标签,而是一个函数,可以主动检查任何给定对象是否具有正确的结构并提取leftright字段。换句话说:Node标签变成了一个函数,它将对象转换为元组,或者如果不可能则返回某个失败指示符。

在 Python 中,我们只需将isinstance()与类的__match_args__字段结合使用,以检查对象是否具有正确的结构,然后将其某些属性转换为元组。例如,对于上面的Node示例,我们将有__match_args__ = ('left', 'right')来指示应提取这两个属性以形成元组。也就是说,case Node(x, y)首先会检查给定对象是否为Node的实例,然后分别将left赋值给x,将right赋值给y

然而,为了向 Python 的动态特性“鸭子类型”致敬,我们还添加了一种更直接的方式来指定特定属性的存在或约束。您可以使用object(left=x, right=y)而不是Node(x, y),有效地消除了isinstance()检查,从而支持任何具有leftright属性的对象。或者您可以结合这些想法来编写Node(right=y),以便需要Node的实例,但仅提取right属性的值。

向后兼容性

通过使用“软关键字”和新的 PEG 解析器(PEP 617),该提案保持完全向后兼容。但是,使用 LL(1) 解析器解析 Python 源代码的第三方工具可能被迫切换解析器技术才能支持这些相同的功能。

安全隐患

我们预计此语言特性不会产生任何安全影响。

参考实现

可以在 GitHub 上找到功能完整的 CPython 实现

使用 Binder [2] 和 Jupyter [3]基于上述实现创建了一个交互式游乐场

参考文献


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

上次修改时间:2024-07-24 22:56:04 GMT