PEP 635 – 结构化模式匹配:动机与基本原理
- 作者:
- Tobias Kohn <kohnt at tobiaskohn.ch>, 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 636 开始,它提供了对模式概念、语法和语义的更温和的介绍。
动机
(结构化)模式匹配语法存在于许多语言中,从 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 应用程序和库并非以一致的 OO 风格编写——与 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
的成功取决于 Q
和 R
),但模式 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
的关键字来修正此问题,但未能产生任何令人满意的解决方案。
尽管平坦缩进会节省一些水平空间,但增加复杂性或异常规则的成本太高。它还会使简单代码编辑器的生活复杂化。最后,水平空间问题可以通过允许 match 语句“半缩进”(即两个空格而不是四个)来缓解(尽管我们不推荐这样做)。
在使用 match
的示例程序中,作为本 PEP 开发的一部分编写,代码简洁性有了显著提高,足以弥补额外的缩进级别。
语句与表达式。 一些建议围绕着将 match
作为一个表达式而不是语句。然而,这与 Python 以语句为中心的特性不符,并将导致异常冗长和复杂的表达式,以及需要发明新的语法结构或打破既定的语法规则。将 match
作为一个表达式的一个明显后果是,case 子句将无法再附加任意代码块,而只能附加单个表达式。总的来说,这种强大的限制无法抵消在某些特殊用例中的轻微简化。
硬关键字与软关键字。 有些选项是将 match 设为硬关键字,或者选择一个不同的关键字。尽管使用硬关键字会简化简单语法高亮工具的工作,但我们决定不使用硬关键字,原因如下:
- 最重要的是,新的解析器不需要我们这样做。与
async
不同,后者作为软关键字在几个版本中造成了困难,这里我们可以让match
成为一个永久的软关键字。 match
在现有代码中非常常用,以至于它会破坏几乎所有现有程序,并给许多可能甚至不会从新语法中受益的人带来修复代码的负担。- 很难找到一个替代关键字,它在现有程序中不常用作标识符,并且仍然能清楚地反映语句的含义。
为 case 子句使用“as”或“|”而不是“case”。 这里提出的模式匹配是多分支控制流(与 Algol 派生语言中的 switch
或 Lisp 中的 cond
一致)和函数式语言中找到的对象解构的组合。虽然提议的关键字 case
突出了多分支方面,但替代关键字(如 as
)同样可行,突出了解构方面。as
或 with
,例如,还具有已是 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 子句将无法再通过 return
或 break
等标准语句影响任何周围的控制流。因此,这种严格的作用域将导致不直观和令人惊讶的行为。
这直接导致任何变量绑定都会在相应的 case 或 match 语句之后仍然存在。即使只部分匹配主体的模式也可能绑定局部变量(事实上,这是守卫正常运行所必需的)。然而,这些变量绑定的语义与现有的 Python 结构(例如 for 循环和 with 语句)是一致的。
守卫
有些约束无法仅通过模式充分表达。例如,“小于”或“大于”关系违背了模式通常的“等于”语义。此外,不同的子模式是独立的,不能相互引用。守卫的添加解决了这些限制:守卫是附加到模式的任意表达式,它必须评估为“真值”才能使模式成功。
例如,case [x, y] if x < y:
使用守卫(if x < y
)来表达两个原本不相交的捕获模式 x
和 y
之间的“小于”关系。
从概念角度来看,模式以声明式风格描述了对主体的结构性约束,理想情况下没有副作用。特别要记住,模式与表达式截然不同,它们遵循不同的目标和语义。守卫以高度受控的方式用任意表达式(可能具有副作用)增强了 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 模式和类模式,以及绑定名称,例如 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 bar
或except 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 绑定变量 u 和 v,则 Q 必须绑定完全相同的变量 u 和 v。
关于是使用竖线符号 |
还是 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 的 fall-through 语义来完成的方式。然而,我们不想误导人们认为 match/case 使用 fall-through 语义(这是 *C* 中常见的 bug 来源)。此外,这将是一种新颖的缩进模式,这可能会使其在 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)
将匹配除了 3
或 4
之外的任何内容。然而,有来自其他语言的证据表明,这很少有用,主要用作双重否定 !!
来控制变量作用域并防止变量绑定(这不适用于 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.0
和 1
匹配完全相同的对象集,即 case 1.0:
和 case 1:
完全可以互换。原则上,True
也会匹配相同的对象集,因为 True == 1
成立。然而,我们认为许多用户会惊讶地发现 case True:
匹配了主体 1.0
,导致一些微妙的错误和复杂的变通方法。因此,我们采用了以下规则:三个单例模式 None
、False
和 True
通过身份(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
捕获模式
捕获模式采用名称的形式,该名称接受任何值并将其绑定到(局部)变量(除非该名称声明为 nonlocal
或 global
)。从这个意义上讲,捕获模式类似于函数定义中的参数(当函数被调用时,每个参数将相应的参数绑定到函数作用域中的局部变量)。
用于捕获模式的名称不得与同一模式中的另一个捕获模式重合。这再次类似于参数,参数同样要求参数列表中的每个参数名称都是唯一的。然而,它与可迭代解包赋值不同,在可迭代解包赋值中,变量名称作为目标重复使用是允许的(例如,x, x = 1, 2
)。不支持模式中 (x, x)
的理由是其模糊的含义:它可以被视为可迭代解包中只有第二次绑定到 x
生效。但它也可以被视为表示两个相等元素的元组(这带来了它自己的问题)。如果需要,仍然可以在以后引入对名称重复使用的支持。
曾有提议明确标记捕获模式,从而将其识别为绑定目标。根据该想法,捕获模式将写成,例如 ?x
、$x
或 =x
。此类明确捕获标记的目的是让未标记的名称成为值模式(见下文)。然而,这基于一种误解,即模式匹配是 switch
语句的扩展,将重点放在基于(序数)值的快速切换上。事实上,Python 之前也曾提议过这种 switch
语句(参见 PEP 275 和 PEP 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( admittedly vague)的使用标点符号的原则,即只以类似于英语常用用法或高中数学中的方式使用标点符号,除非该用法在其他编程语言中非常成熟(例如,使用点进行成员访问)。
问号在这两方面都失败了:它在其他编程语言中的用法是各种各样的,只是模糊地暗示了“问题”的概念。例如,它在 shell globbing 中表示“任何字符”,在正则表达式中表示“可能”,在 C 和许多 C 派生语言中表示“条件表达式”,在 Scheme 中表示“谓词函数”,在 Rust 中表示“修改错误处理”,在 TypeScript 中表示“可选参数”和“可选链式调用”(后者含义也曾被 PEP 505 提议用于 Python)。一个尚未命名的 PEP 提议用它来标记可选类型,例如 int?
。
在编程系统中,?
的另一个常见用途是“帮助”,例如在 IPython 和 Jupyter Notebooks 以及许多交互式命令行实用程序中。
此外,这将使 Python 处于一个相当独特的位置:在我们能找到的每个支持模式匹配的编程语言中,下划线都作为通配符模式(包括 C#、Elixir、Erlang、F#、Grace、Haskell、Mathematica、OCaml、Ruby、Rust、Scala、Swift 和 Thorn)。考虑到许多 Python 用户也使用其他编程语言,在学习 Python 时有先前的经验,并且在学习 Python 后可能会转向其他语言,我们认为这种成熟的标准对于可读性和可学习性至关重要。在我们看来,关于这个通配符意味着一个普通名称受到了特殊待遇的担忧不足以引入会使 Python 变得特殊的语法。
else 块。 一个不带守卫且模式为单个通配符的 case 块(即 case _:
)接受任何主体,而不将其绑定到变量或执行任何其他操作。因此,如果支持 else:
,它在语义上等同于 else:
。然而,向 match 语句语法添加这样的 else 块并不能消除在其他上下文中对通配符模式的需求。反对的另一个论点是,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
、==CONSTANT
、CONSTANT?
,或反引号中的单词),尽管没有明显或自然的选项。因此,当前的提案将关于此类“常量”标记的讨论和可能的引入留待未来的 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
的可变版本,也应该例外;但我们不打算枚举所有可能用于表示字节的其他类型(例如,一些但不是所有的 memoryview
和 array.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__
)。此外,解释器无法可靠地推断属性的顺序。对于表示圆的对象,例如,属性 x
、y
和 radius
没有固有的明显顺序。
我们设想两种处理此问题的方法:要么显式命名感兴趣的属性,要么提供一个额外的映射,告诉解释器要提取哪些属性以及以何种顺序提取。这两种方法都受支持。此外,显式命名感兴趣的属性可以让您进一步指定对象的所需结构;如果对象缺少模式指定的属性,则匹配失败。
- 显式命名的属性采用命名参数的语法。如果
Node
类的对象具有如上所示的两个属性left
和right
,则模式Node(left=x, right=y)
将分别提取这两个属性的值并将它们赋给x
和y
。从左到右的数据流看起来不寻常,但与映射模式一致,并且有先例,例如通过as
在 with 或 import 语句中进行赋值(以及 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)
有意镜像创建实例的语法。
内置类 bool
、bytearray
等的特殊情况(例如 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
不再是一个被动的标签,而是一个可以主动检查任何给定对象是否具有正确结构并提取 left
和 right
字段的函数。换句话说:Node
标签变成了一个将对象转换为元组的函数,如果不可能则返回一些失败指示。
在 Python 中,我们只需使用 isinstance()
以及类的 __match_args__
字段来检查对象是否具有正确的结构,然后将其某些属性转换为元组。例如,对于上面的 Node
示例,我们将有 __match_args__ = ('left', 'right')
来指示应提取这两个属性以形成元组。也就是说,case Node(x, y)
将首先检查给定对象是否是 Node
的实例,然后分别将 left
分配给 x
,将 right
分配给 y
。
然而,为了向 Python 具有“鸭子类型”的动态特性致敬,我们还添加了一种更直接的方式来指定特定属性的存在或约束。您不仅可以写 Node(x, y)
,还可以写 object(left=x, right=y)
,这样有效地消除了 isinstance()
检查,从而支持任何具有 left
和 right
属性的对象。或者您可以结合这些想法来写 Node(right=y)
,以便要求 Node
的实例,但只提取 right
属性的值。
向后兼容性
通过使用“软关键字”和新的 PEG 解析器 (PEP 617),该提案完全保持了向后兼容性。但是,使用 LL(1) 解析器解析 Python 源代码的第三方工具可能不得不更改解析器技术才能支持这些功能。
安全隐患
我们预计此语言功能不会带来任何安全隐患。
参考实现
一个功能完整的 CPython 实现已在 GitHub 上提供。
参考资料
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源: https://github.com/python/peps/blob/main/peps/pep-0635.rst
最后修改时间: 2025-02-01 08:59:27 GMT