PEP 622 – 结构化模式匹配
- 作者:
- Brandt Bucher <brandt at python.org>, Daniel F Moisset <dfmoisset at gmail.com>, Tobias Kohn <kohnt at tobiaskohn.ch>, Ivan Levkivskyi <levkivskyi at gmail.com>, Guido van Rossum <guido at python.org>, Talin <viridia at gmail.com>
- BDFL 代表:
- 讨论列表:
- Python-Dev 列表
- 状态:
- 已取代
- 类型:
- 标准跟踪
- 创建:
- 2020 年 6 月 23 日
- Python 版本:
- 3.10
- 历史记录:
- 2020 年 6 月 23 日,2020 年 7 月 8 日
- 取代版本:
- 634
摘要
本 PEP 提出向 Python 添加一个**模式匹配语句**,灵感来自 Scala、Erlang 和其他语言中类似的语法。
模式和形状
**模式语法**建立在 Python 现有的序列解包语法基础上(例如,a, b = value
)。
match
语句将一个值(**主体**)与多个不同的形状(**模式**)进行比较,直到找到一个合适的形状。每个模式都描述了可接受值的类型和结构,以及用于捕获其内容的变量。
模式可以指定形状为
- 如前所述,要解包的序列
- 具有特定键的映射
- 给定类的实例,并具有(可选)特定属性
- 特定值
- 通配符
模式可以以多种方式组合。
语法
从语法上讲,match
语句包含
- 一个主体表达式
- 一个或多个
case
子句
每个 case
子句指定
- 一个模式(要匹配的整体形状)
- 一个可选的“守卫”(如果模式匹配则要检查的条件)
- 一个代码块,如果选择 case 子句则执行该代码块
动机
PEP 的其余部分
- 阐述了我们为什么认为模式匹配是 Python 的一个很好的补充
- 解释了我们的设计选择
- 包含精确的语法和运行时规范
- 为静态类型检查器提供指导(以及对
typing
模块的一个小补充) - 讨论了在对提案进行广泛讨论期间提出的主要异议和替代方案,包括作者团队内部和 python-dev 社区。
最后,我们讨论了一些可能在未来考虑的扩展,一旦社区积累了足够使用当前提议的语法和语义的经验。
概述
模式是一个新的语法类别,有其自身规则和特殊情况。模式以新颖的方式混合了输入(给定值)和输出(捕获变量)。它们可能需要一段时间才能有效使用。作者在此提供了对基本概念的简要介绍。请注意,本节并非旨在完整或完全准确。
模式,一种新的语法结构,以及解构
本 PEP 引入了一种称为**模式**的新语法结构。从语法上讲,模式看起来像是表达式的子集。以下是模式的示例
[first, second, *rest]
Point2d(x, 0)
{"name": "Bruce", "age": age}
42
以上表达式可能看起来像是对象构造的示例,其中构造函数将一些值作为参数,并根据这些组件构建对象。
当被视为模式时,上述模式表示构造的反向操作,我们称之为**解构**。**解构**获取主体值并提取其组件。
对象构造和解构之间的语法相似性是有意的。它还遵循现有的 Python 风格的上下文,使赋值目标(写入上下文)看起来像表达式(读取上下文)。
模式匹配永远不会创建对象。这与 [a, b] = my_list
不创建新的 [a, b]
列表,也不读取 a
和 b
的值相同。
匹配过程
在此匹配过程中,模式的结构可能不适合主体,并且匹配失败。
例如,将模式 Point2d(x, 0)
与主体 Point2d(3, 0)
匹配成功。匹配还将模式的自由变量 x
**绑定**到主体的值 3
。
另一个示例是,如果主体是 [3, 0]
,则匹配失败,因为主体的类型 list
不是模式的 Point2d
。
第三个示例是,如果主体是 Point2d(3, 7)
,则匹配失败,因为主体的第二个坐标 7
与模式的 0
不相同。
match
语句尝试将其 case
子句中的每个模式与单个主体进行匹配。在首次成功匹配 case
子句中的模式时
- 模式中的变量将被赋值,并且
- 将执行相应的代码块。
每个 case
子句还可以指定一个可选的布尔条件,称为**守卫**。
让我们看一个 match
语句的更详细示例。 match
语句用于函数中以定义 3D 点的构建。在此示例中,函数可以接受以下任何内容作为输入:具有 2 个元素的元组、具有 3 个元素的元组、现有的 Point2d 对象或现有的 Point3d 对象
def make_point_3d(pt):
match pt:
case (x, y):
return Point3d(x, y, 0)
case (x, y, z):
return Point3d(x, y, z)
case Point2d(x, y):
return Point3d(x, y, 0)
case Point3d(_, _, _):
return pt
case _:
raise TypeError("not a point we support")
如果没有模式匹配,此函数的实现将需要多个 isinstance()
检查、一个或两个 len()
调用以及更复杂的控制流。 match
示例版本和不使用 match
的传统 Python 版本在底层转换为类似的代码。熟悉模式匹配的用户在阅读使用 match
的此函数时,可能会发现此版本比传统方法更清晰。
基本原理和目标
Python 程序经常需要处理类型、属性/键的存在或元素数量不同的数据。典型示例是对混合结构(如 AST)的节点进行操作、处理不同类型的 UI 事件、处理结构化输入(如结构化文件或网络消息)或“解析”函数的参数,这些函数可以接受不同类型的组合和参数数量。实际上,经典的“访问者”模式就是这种情况的一个例子,以 OOP 方式完成——但匹配使编写起来变得不那么乏味。
执行此操作的大部分代码往往由复杂的嵌套 if
/elif
语句链组成,包括多次调用 len()
、isinstance()
和索引/键/属性访问。在这些分支内部,用户有时需要进一步解构数据以提取所需的组件值,这些值可能嵌套在多个对象中。
许多其他语言中存在的模式匹配为此问题提供了一种优雅的解决方案。这些范围从像 F# 和 Haskell 这样的静态编译函数式语言,到像 Scala 和 Rust 这样的混合范式语言,再到像 Elixir 和 Ruby 这样的动态语言,并且正在考虑用于 JavaScript。我们感谢这些语言为 Python 式模式匹配指明了方向,就像 Python 感谢许多其他语言的许多特性一样:许多基本的语法特性继承自 C,异常来自 Modula-3,类受到 C++ 的启发,切片来自 Icon,正则表达式来自 Perl,装饰器类似于 Java 注解,等等。
操作异构数据的常用逻辑可以概括如下
- 对数据的形状(类型和组成部分)进行一些分析:这可能涉及到
isinstance()
或len()
调用和/或提取(通过索引或属性访问)被检查特定值或条件的组成部分。 - 如果形状符合预期,则可能提取更多组件,并使用提取的值执行某些操作。
例如,请查看Django Web框架的这部分代码
if (
isinstance(value, (list, tuple)) and
len(value) > 1 and
isinstance(value[-1], (Promise, str))
):
*value, label = value
value = tuple(value)
else:
label = key.replace('_', ' ').title()
我们可以看到顶部对value
的形状分析,然后是内部的解构。
请注意,这里的形状分析涉及检查容器及其其中一个组件的类型,以及对其元素数量的一些检查。一旦我们匹配了形状,我们就需要分解序列。使用本PEP中的提案,我们可以将该代码重写为
match value:
case [*v, label := (Promise() | str())] if v:
value = tuple(v)
case _:
label = key.replace('_', ' ').title()
这种语法更明确地指出了输入数据的哪些格式是可能的,以及从哪里提取哪些组件。您可以看到类似于列表解包的模式,但也包括类型检查:Promise()
模式不是对象构造,而是表示Promise
的任何实例。模式运算符|
分隔替代模式(类似于正则表达式或EBNF语法),而_
是通配符。(请注意,此处使用的匹配语法将接受用户定义的序列,以及列表和元组。)
在某些情况下,信息提取与识别结构相比并不那么重要。请查看来自Python标准库的以下示例
def is_tuple(node):
if isinstance(node, Node) and node.children == [LParen(), RParen()]:
return True
return (isinstance(node, Node)
and len(node.children) == 3
and isinstance(node.children[0], Leaf)
and isinstance(node.children[1], Node)
and isinstance(node.children[2], Leaf)
and node.children[0].value == "("
and node.children[2].value == ")")
此示例展示了在不进行大量提取的情况下找出数据“形状”的示例。这段代码的可读性不是很好,并且它试图匹配的预期形状并不明显。请与使用建议语法的更新代码进行比较
def is_tuple(node: Node) -> bool:
match node:
case Node(children=[LParen(), RParen()]):
return True
case Node(children=[Leaf(value="("), Node(), Leaf(value=")")]):
return True
case _:
return False
请注意,建议的代码无需对Node
和其他类在此处的定义进行任何修改即可工作。如上例所示,该提案不仅支持解包序列,还支持执行isinstance
检查(如LParen()
或str()
),查看对象属性(例如Leaf(value="(")
)以及与字面量的比较。
最后一个特性有助于处理某些更像其他语言中存在的“switch”语句的代码
match response.status:
case 200:
do_something(response.data) # OK
case 301 | 302:
retry(response.location) # Redirect
case 401:
retry(auth=get_credentials()) # Login first
case 426:
sleep(DELAY) # Server is swamped, try after a bit
retry()
case _:
raise RequestError("we couldn't get the data")
尽管这可以工作,但这不一定是提案的重点,并且新语法旨在最佳地支持解构场景。
有关更详细的规范,请参阅下面的语法部分。
我们建议可以通过新的特殊__match_args__
属性来自定义对象解构。作为本PEP的一部分,我们指定了通用API及其在某些标准库类(包括命名元组和数据类)中的实现。请参阅下面的运行时部分。
最后,我们的目标是为静态类型检查器和类似工具提供全面支持。为此,我们建议引入一个@typing.sealed
类装饰器,它在运行时将是一个空操作,但会指示静态工具此类的所有子类都必须在同一模块中定义。这将允许有效的静态穷举性检查,并且与数据类一起,将为代数数据类型提供基本支持。有关更多详细信息,请参阅静态检查器部分。
语法和语义
模式
模式是一种新的语法结构,可以被认为是赋值目标的松散泛化。模式的关键属性是它接受哪些类型和形状的主体、它捕获哪些变量以及它如何从主体中提取它们。例如,模式[a, b]
仅匹配恰好包含2个元素的序列,将第一个元素提取到a
中,将第二个元素提取到b
中。
本PEP定义了几种类型的模式。这些当然不是唯一可能的模式,因此设计决策是选择当前有用但保守的功能子集。随着此功能的更广泛使用,以后可以添加更多模式。有关更多详细信息,请参阅被拒绝的想法和延迟的想法部分。
此处列出的模式将在下面详细描述,但为了简单起见,在本节中进行了总结
- 字面量模式用于过滤结构中的常量值。它看起来像一个Python字面量(包括一些值,如
True
、False
和None
)。它仅匹配等于字面量的对象,并且从不绑定。 - 捕获模式看起来像
x
,等效于相同的赋值目标:它始终匹配并将变量绑定到给定的(简单)名称。 - 通配符模式是一个下划线:
_
。它始终匹配,但不捕获任何变量(这可以防止与_
的其他用途发生冲突,并允许进行一些优化)。 - 常量值模式与字面量类似,但适用于某些命名常量。请注意,鉴于与捕获模式可能存在的歧义,它必须是限定的(带点的)名称。它看起来像
Color.RED
,仅匹配等于相应值的值。它从不绑定。 - 序列模式看起来像
[a, *rest, b]
,类似于列表解包。一个重要的区别是嵌套在其内部的元素可以是任何类型的模式,而不仅仅是名称或序列。它仅匹配长度合适的序列,只要所有子模式也匹配即可。它进行其所有子模式的绑定。 - 映射模式看起来像
{"user": u, "emails": [*es]}
。它匹配至少具有提供的键集的映射,如果所有子模式都匹配其对应的值。它在与对应于键的值匹配时绑定子模式绑定的任何内容。允许在模式末尾添加**rest
以捕获额外的项目。 - 类模式与此类似,但匹配属性而不是键。它看起来像
datetime.date(year=y, day=d)
。它匹配给定类型的实例,至少具有指定的属性,只要属性与对应的子模式匹配即可。它在与给定属性的值匹配时绑定子模式绑定的任何内容。可选协议还允许匹配位置参数。 - OR模式看起来像
[*x] | {"elems": [*x]}
。如果其任何子模式匹配,则匹配。它使用匹配的最左侧模式的绑定。 - 海象模式看起来像
d := datetime(year=2020, month=m)
。仅当其子模式也匹配时才匹配。它绑定子模式匹配所做的任何绑定,并将命名变量绑定到整个对象。
match
语句
建议语法的简化近似语法为
...
compound_statement:
| if_stmt
...
| match_stmt
match_stmt: "match" expression ':' NEWLINE INDENT case_block+ DEDENT
case_block: "case" pattern [guard] ':' block
guard: 'if' expression
pattern: walrus_pattern | or_pattern
walrus_pattern: NAME ':=' or_pattern
or_pattern: closed_pattern ('|' closed_pattern)*
closed_pattern:
| literal_pattern
| capture_pattern
| wildcard_pattern
| constant_pattern
| sequence_pattern
| mapping_pattern
| class_pattern
有关完整的未删节语法,请参阅附录A。本节中的简化语法是为了帮助读者,而不是作为完整规范。
我们建议匹配操作应为语句,而不是表达式。尽管在许多语言中它是一个表达式,但作为语句更适合Python语法的通用逻辑。有关更多讨论,请参阅被拒绝的想法。允许的模式在下面的模式子部分中进行了详细描述。
建议match
和case
关键字为软关键字,因此分别在匹配语句或case块的开头将它们识别为关键字,但在其他地方允许将其用作变量或参数名称。
建议的缩进结构如下
match some_expression:
case pattern_1:
...
case pattern_2:
...
这里,some_expression
表示要匹配的值,此后将被称为匹配的主体。
匹配语义
选择匹配的建议的大规模语义是选择第一个匹配的模式并执行相应的代码块。不尝试剩余的模式。如果没有匹配的模式,则语句“贯穿”,并在以下语句处继续执行。
从本质上讲,这等效于if ... elif ... else
语句链。请注意,与之前提出的switch
语句不同,此处不适用预先计算的分发字典语义。
没有default
或else
情况 - 相反,可以使用特殊的通配符_
(请参阅有关capture_pattern的部分)作为最终的“捕获所有”模式。
在成功模式匹配期间进行的名称绑定将比执行的代码块存活更久,并且可以在匹配语句之后使用。这遵循其他可以绑定名称的Python语句的逻辑,例如for
循环和with
语句。例如
match shape:
case Point(x, y):
...
case Rectangle(x, y, _, _):
...
print(x, y) # This works
在模式匹配失败时,一些子模式可能会成功。例如,当用模式(0, x, 1)
匹配值[0, 1, 2]
时,如果列表元素是从左到右匹配的,则子模式x
可能会成功。实现可以选择是否为这些部分匹配创建持久绑定。包含match
语句的用户代码不应依赖于为失败匹配创建的绑定,但也应该不假设变量在失败匹配后保持不变。这部分行为被有意地留作未指定,以便不同的实现可以添加优化,并防止引入可能限制此功能扩展性的语义限制。
请注意,下面的一些模式类型定义了关于何时创建绑定的更具体的规则。
允许的模式
我们逐步引入提议的语法。这里我们从主要的构建块开始。支持以下模式
字面量模式
简化语法
literal_pattern:
| number
| string
| 'None'
| 'True'
| 'False'
文字模式由一个简单的文字组成,例如字符串、数字、布尔文字(True
或False
)或None
match number:
case 0:
print("Nothing")
case 1:
print("Just one")
case 2:
print("A couple")
case -1:
print("One less than nothing")
case 1-1j:
print("Good luck with that...")
文字模式使用右侧文字的相等性,因此在上面的示例中,将评估number == 0
,然后可能评估number == 1
等。请注意,虽然从技术上讲负数使用一元减号表示,但为了模式匹配的目的,它们被视为文字。不允许使用一元加号。二元加号和减号仅允许连接实数和虚数以形成复数,例如1+1j
。
请注意,由于使用了相等性(__eq__
),以及布尔值与整数0
和1
之间的等价性,以下两者之间没有实际差异
case True:
...
case 1:
...
支持三引号字符串。支持原始字符串和字节字符串。不允许使用f-字符串(因为通常它们不是真正的文字)。
捕获模式
简化语法
capture_pattern: NAME
捕获模式用作匹配表达式的赋值目标
match greeting:
case "":
print("Hello!")
case name:
print(f"Hi {name}!")
仅允许单个名称(带点的名称是常量值模式)。捕获模式总是成功的。出现在作用域中的捕获模式使名称成为该作用域的局部变量。例如,如果使用了""
情况子句,则在上述代码段之后使用name
可能会引发UnboundLocalError
而不是NameError
。
match greeting:
case "":
print("Hello!")
case name:
print(f"Hi {name}!")
if name == "Santa": # <-- might raise UnboundLocalError
... # but works fine if greeting was not empty
在针对每个情况子句进行匹配时,最多只能绑定一次名称,具有重合名称的两个捕获模式是一个错误
match data:
case [x, x]: # Error!
...
注意:仍然可以使用守卫匹配具有相同项的集合。此外,[x, y] | Point(x, y)
是一个合法的模式,因为这两个备选方案永远不会同时匹配。
单个下划线(_
)不被视为NAME
,并被特殊地视为通配符模式。
提醒:None
、False
和True
是表示文字的关键字,而不是名称。
通配符模式
简化语法
wildcard_pattern: "_"
单个下划线(_
)名称是一种特殊的模式,它始终匹配但从不绑定
match data:
case [_, _]:
print("Some pair")
print(_) # Error!
鉴于没有创建绑定,它可以根据需要使用多次,与捕获模式不同。
常量值模式
简化语法
constant_pattern: NAME ('.' NAME)+
这用于匹配常量和枚举值。模式中的每个带点名称都使用正常的 Python 名称解析规则进行查找,并且该值用于与匹配主体进行相等性比较(与文字相同)
from enum import Enum
class Sides(str, Enum):
SPAM = "Spam"
EGGS = "eggs"
...
match entree[-1]:
case Sides.SPAM: # Compares entree[-1] == Sides.SPAM.
response = "Have you got anything without Spam?"
case side: # Assigns side = entree[-1].
response = f"Well, could I have their Spam instead of the {side} then?"
请注意,无法将非限定名称用作常量值模式(它们始终表示要捕获的变量)。有关为常量值模式考虑的其他语法备选方案,请参阅被拒绝的想法。
序列模式
简化语法
sequence_pattern:
| '[' [values_pattern] ']'
| '(' [value_pattern ',' [values pattern]] ')'
values_pattern: ','.value_pattern+ ','?
value_pattern: '*' capture_pattern | pattern
序列模式遵循与解包赋值相同的语义。与解包赋值一样,可以使用元组式和列表式语法,语义相同。每个元素可以是任意模式;最多还可以有一个*name
模式来捕获所有剩余的项
match collection:
case 1, [x, *others]:
print("Got 1 and a nested sequence")
case (1, x):
print(f"Got 1 and {x}")
要匹配序列模式,主题必须是collections.abc.Sequence
的实例,并且不能是任何类型的字符串(str
、bytes
、bytearray
)。它不能是迭代器。有关匹配特定集合类,请参见下面的类模式。
_
通配符可以加星号以匹配不同长度的序列。例如
[*_]
匹配任何长度的序列。(_, _, *_)
匹配长度为 2 或更大的任何序列。["a", *_, "z"]
匹配长度为 2 或更大的任何序列,该序列以"a"
开头,以"z"
结尾。
映射模式
简化语法
mapping_pattern: '{' [items_pattern] '}'
items_pattern: ','.key_value_pattern+ ','?
key_value_pattern:
| (literal_pattern | constant_pattern) ':' or_pattern
| '**' capture_pattern
映射模式是将可迭代解包推广到映射。其语法类似于字典显示,但每个键和值都是模式"{" (pattern ":" pattern)+ "}"
。还允许使用**rest
模式来提取剩余的项。仅在键位置允许使用文字和常量值模式
import constants
match config:
case {"route": route}:
process_route(route)
case {constants.DEFAULT_PORT: sub_config, **rest}:
process_config(sub_config, rest)
主题必须是collections.abc.Mapping
的实例。即使没有**rest
,主题中的额外键也会被忽略。这与序列模式不同,在序列模式中,额外项会导致匹配失败。但映射实际上与序列不同:它们具有自然的结构子类型化行为,即,在某个地方传递具有额外键的字典很可能可以正常工作。
因此,**_
在映射模式中无效;它将始终是一个无操作,可以将其删除而不会造成任何影响。
匹配的键值对必须已存在于映射中,而不是由__missing__
或__getitem__
动态创建。例如,collections.defaultdict
实例仅匹配在进入match
块时已存在的键的模式。
类模式
简化语法
class_pattern:
| name_or_attr '(' ')'
| name_or_attr '(' ','.pattern+ ','? ')'
| name_or_attr '(' ','.keyword_pattern+ ','? ')'
| name_or_attr '(' ','.pattern+ ',' ','.keyword_pattern+ ','? ')'
keyword_pattern: NAME '=' or_pattern
类模式提供对解构任意对象的支持。有两种可能的方式来匹配对象属性:按位置,例如Point(1, 2)
,以及按名称,例如Point(x=1, y=2)
。这两种方式可以组合,但位置匹配不能在按名称匹配之后进行。类模式中的每个项目都可以是任意模式。一个简单的例子
match shape:
case Point(x, y):
...
case Rectangle(x0, y0, x1, y1, painted=True):
...
匹配是否成功由等效于isinstance
调用的操作确定。如果主题(示例中的shape
)不是命名类(Point
或Rectangle
)的实例,则匹配失败。否则,它将继续(请参阅运行时部分中的详细信息)。
命名类必须继承自type
。它可以是单个名称或带点名称(例如some_mod.SomeClass
或mod.pkg.Class
)。前导名称不能为_
,因此例如_(...)
和_.C(...)
是无效的。使用object(foo=_)
来检查匹配的对象是否具有属性foo
。
默认情况下,子模式只能通过关键字为用户定义的类匹配。为了支持位置子模式,需要自定义__match_args__
属性。运行时允许通过适当地链接所有实例检查和属性查找来匹配任意嵌套的模式。
组合多个模式(或模式)
可以使用|
将多个备选模式组合成一个。这意味着如果至少有一个备选方案匹配,则整个模式将匹配。备选方案从左到右尝试,并具有短路特性,如果一个匹配,则不会尝试后续的模式。示例
match something:
case 0 | 1 | 2:
print("Small number")
case [] | [_]:
print("A short sequence")
case str() | bytes():
print("Something string-like")
case _:
print("Something else")
备选方案可以绑定变量,只要每个备选方案绑定相同的变量集(不包括_
)。例如
match something:
case 1 | x: # Error!
...
case x | 1: # Error!
...
case one := [1] | two := [2]: # Error!
...
case Foo(arg=x) | Bar(arg=x): # Valid, both arms bind 'x'
...
case [x] | x: # Valid, both arms bind 'x'
...
守卫
每个顶级模式后面都可以跟一个守卫,其形式为if expression
。如果模式匹配并且守卫计算结果为真值,则情况子句成功。例如
match input:
case [x, y] if x > MAX_INT and y > MAX_INT:
print("Got a pair of large numbers")
case x if x > MAX_INT:
print("Got a large number")
case [x, y] if x == y:
print("Got equal items")
case _:
print("Not an outstanding input")
如果评估守卫引发异常,则会将其传播到 आगे而不是使情况子句失败。出现在模式中的名称在守卫成功之前被绑定。所以这将起作用
values = [0]
match values:
case [x] if x:
... # This is not executed
case _:
...
print(x) # This will print "0"
请注意,嵌套模式不允许使用守卫,因此[x if x > 0]
是SyntaxError
,并且1 | 2 if 3 | 4
将被解析为(1 | 2) if (3 | 4)
。
海豹模式
匹配子模式和将相应的值绑定到名称通常很有用。例如,编写更有效的匹配或简单地避免重复可能很有用。为了简化此类情况,任何模式(海豹模式本身除外)都可以由名称和海豹运算符(:=
)开头。例如
match get_shape():
case Line(start := Point(x, y), end) if start == end:
print(f"Zero length line at {x}, {y}")
海豹运算符左侧的名称可以在守卫、匹配套件或匹配语句之后使用。但是,只有在子模式成功时才会绑定名称。另一个例子
match group_shapes():
case [], [point := Point(x, y), *other]:
print(f"Got {point} in the second group")
process_coordinates(x, y)
...
从技术上讲,大多数此类示例可以使用守卫和/或嵌套匹配语句重写,但这可读性较差和/或会产生效率较低的代码。PEP 572 中的大多数论点在这里同样适用。
通配符 _
在此处不是有效的名称。
运行时规范
匹配协议
使用等效于 isinstance
调用的方式来判断对象是否匹配给定的类模式,并提取相应的属性。需要不同匹配语义(例如鸭子类型)的类可以通过定义 __instancecheck__
(一个预先存在的元类钩子)或使用 typing.Protocol
来实现。
过程如下:
- 查找
Class(<sub-patterns>)
中Class
的类对象,并调用isinstance(obj, Class)
,其中obj
是正在匹配的值。如果为假,则匹配失败。 - 否则,如果以位置参数或关键字参数的形式给出了任何子模式,则从左到右匹配这些子模式,如下所示。一旦子模式失败,匹配就失败;如果所有子模式都成功,则类模式匹配整体成功。
- 如果有按位置匹配的项,并且类具有
__match_args__
属性,则位置i
处的项将与通过属性__match_args__[i]
查找的值进行匹配。例如,模式Point2d(5, 8)
,其中Point2d.__match_args__ == ["x", "y"]
,被转换(近似)为obj.x == 5 and obj.y == 8
。 - 如果位置项的数量多于
__match_args__
的长度,则会引发TypeError
。 - 如果匹配的类上不存在
__match_args__
属性,并且匹配中出现了一个或多个位置项,则也会引发TypeError
。我们不会回退到使用__slots__
或__annotations__
——“在面对歧义时,拒绝猜测的诱惑。” - 如果有任何按关键字匹配的项,则将关键字作为属性在主题上查找。如果查找成功,则将值与相应的子模式进行匹配。如果查找失败,则匹配失败。
这样的协议更偏向于实现的简单性,而不是灵活性与性能。有关考虑的其他替代方案,请参见扩展匹配。
对于最常匹配的内置类型(bool
、bytearray
、bytes
、dict
、float
、frozenset
、int
、list
、set
、str
和 tuple
),允许将单个位置子模式传递给调用。它不是与主题上的任何特定属性匹配,而是与主题本身匹配。这为这些对象创建了有用且直观的行为。
bool(False)
匹配False
(但不匹配0
)。tuple((0, 1, 2))
匹配(0, 1, 2)
(但不匹配[0, 1, 2]
)。int(i)
匹配任何int
并将其绑定到名称i
。
重叠子模式
某些类型的重叠匹配在运行时会被检测到,并且会引发异常。除了上一小节中描述的基本检查之外,
- 解释器将检查两个匹配项是否未针对同一属性,例如
Point2d(1, 2, y=3)
是错误的。 - 它还将检查映射模式是否未尝试多次匹配相同的键。
特殊属性 __match_args__
__match_args__
属性始终在模式中命名的类型对象上查找。如果存在,它必须是一个字符串列表或元组,命名允许的位置参数。
在决定哪些名称可用于匹配时,建议的做法是类模式应该反映构造;也就是说,可用名称及其类型应该类似于 __init__()
的参数。
默认情况下,只有按名称匹配才能工作,并且如果类希望支持按位置匹配,则应该将 __match_args__
定义为类属性。此外,数据类和命名元组将开箱即用地支持按位置匹配。有关更多详细信息,请参见下文。
异常和副作用
在匹配每个情况时,match
语句可能会触发其他函数的执行(例如 __getitem__()
、__len__()
或属性)。由这些函数引起的几乎所有异常都会像正常一样在 match
语句之外传播。唯一不会传播异常的情况是在尝试查找属性以匹配类模式的属性时引发的 AttributeError
;在这种情况下,只会导致匹配失败,并且语句的其余部分会照常继续。
匹配过程中明确执行的唯一副作用是名称的绑定。但是,该过程依赖于属性访问、实例检查、len()
、相等性和主题及其某些组件的项访问。它还会评估常量值模式和类模式的左侧。虽然这些通常不会产生任何副作用,但其中一些对象可能会产生副作用。本提案有意省略对调用哪些方法或调用多少次的任何规范。依赖于此行为的用户代码应被视为有错误。
标准库
为了便于使用模式匹配,将对标准库进行一些更改。
- 命名元组和数据类将具有自动生成的
__match_args__
。 - 对于数据类,生成的
__match_args__
中属性的顺序将与生成的__init__()
方法中相应参数的顺序相同。这包括属性从超类继承的情况。
此外,将进行系统的努力,遍历现有的标准库类并添加 __match_args__
,在看起来有益的地方。
静态检查器规范
穷尽性检查
从可靠性的角度来看,经验表明,在处理一组可能的数据值时遗漏了一种情况会导致难以调试的问题,从而迫使人们添加诸如以下的安全断言
def get_first(data: Union[int, list[int]]) -> int:
if isinstance(data, list) and data:
return data[0]
elif isinstance(data, int):
return data
else:
assert False, "should never get here"
PEP 484 指定静态类型检查器应支持关于枚举值的条件检查中的穷尽性。PEP 586 后来将此要求推广到文字类型。
本 PEP 将此要求进一步推广到任意模式。这适用的一种典型情况是使用联合类型匹配表达式。
def classify(val: Union[int, Tuple[int, int], List[int]]) -> str:
match val:
case [x, y] if x > 0 and y > 0:
return f"A pair of {x} and {y}"
case [x, *other]:
return f"A sequence starting with {x}"
case int():
return f"Some integer"
# Type-checking error: some cases unhandled.
穷尽性检查也应该应用于模式匹配和枚举值组合的情况。
from enum import Enum
from typing import Union
class Level(Enum):
BASIC = 1
ADVANCED = 2
PRO = 3
class User:
name: str
level: Level
class Admin:
name: str
account: Union[User, Admin]
match account:
case Admin(name=name) | User(name=name, level=Level.PRO):
...
case User(level=Level.ADVANCED):
...
# Type-checking error: basic user unhandled
显然,不需要 Matchable
协议(根据PEP 544),因为每个类都是可匹配的,因此受上述检查的约束。
密封类作为代数数据类型
通常,希望对一组类应用穷尽性,而无需定义临时联合类型,如果联合定义中缺少类,则联合类型本身很脆弱。在其他支持模式匹配的语言中,将一组类似记录的类组合成联合的设计模式很流行,并且被称为代数数据类型。
我们建议向 typing
模块添加一个特殊的装饰器类 @sealed
,它在运行时不会有任何影响,但会指示静态类型检查器此类的所有子类(直接和间接)都应在与基类相同的模块中定义。
其想法是,由于所有子类都是已知的,因此类型检查器可以将密封基类视为所有子类的联合。结合数据类,这允许在 Python 中干净安全地支持代数数据类型。请考虑以下示例
from dataclasses import dataclass
from typing import sealed
@sealed
class Node:
...
class Expression(Node):
...
class Statement(Node):
...
@dataclass
class Name(Expression):
name: str
@dataclass
class Operation(Expression):
left: Expression
op: str
right: Expression
@dataclass
class Assignment(Statement):
target: str
value: Expression
@dataclass
class Print(Statement):
value: Expression
有了这样的定义,类型检查器可以安全地将 Node
视为 Union[Name, Operation, Assignment, Print]
,还可以安全地将例如 Expression
视为 Union[Name, Operation]
。因此,这将在下面的代码段中导致类型检查错误,因为未处理 Name
(并且类型检查器可以给出有用的错误消息)
def dump(node: Node) -> str:
match node:
case Assignment(target, value):
return f"{target} = {dump(value)}"
case Print(value):
return f"print({dump(value)})"
case Operation(left, op, right):
return f"({dump(left)} {op} {dump(right)})"
类型擦除
类模式受运行时类型擦除的影响。也就是说,尽管可以定义类型别名 IntQueue = Queue[int]
,以便像 IntQueue()
这样的模式在语法上有效,但类型检查器应该拒绝此类匹配。
queue: Union[Queue[int], Queue[str]]
match queue:
case IntQueue(): # Type-checking error here
...
请注意,上面的代码段在 typing
模块中泛型类的当前实现以及最近接受的PEP 585中的内置泛型类中实际上会在运行时失败,因为它们禁止 isinstance
检查。
澄清一下,泛型类通常不会被禁止参与模式匹配,只是它们的类型参数不能被显式指定。如果子模式或文字绑定类型变量,仍然是可以的。例如
from typing import Generic, TypeVar, Union
T = TypeVar('T')
class Result(Generic[T]):
first: T
other: list[T]
result: Union[Result[int], Result[str]]
match result:
case Result(first=int()):
... # Type of result is Result[int] here
case Result(other=["foo", "bar", *rest]):
... # Type of result is Result[str] here
关于常量的说明
捕获模式始终是赋值目标这一事实可能会在用户错误地尝试将值与常量“匹配”(而不是使用常量值模式)时产生不希望的后果。结果,在运行时,这种匹配将始终成功,而且会覆盖常量的值。因此,静态类型检查器必须警告此类情况。例如
from typing import Final
MAX_INT: Final = 2 ** 64
value = 0
match value:
case MAX_INT: # Type-checking error here: cannot assign to final name
print("Got big number")
case _:
print("Something else")
请注意,CPython 参考实现也为此情况生成了一个SyntaxWarning
消息。
星号匹配的精确类型检查
类型检查器应执行模式匹配中星号项的精确类型检查,为其提供异构的list[T]
类型或TypedDict
类型,如PEP 589中所指定。例如
stuff: Tuple[int, str, str, float]
match stuff:
case a, *b, 0.5:
# Here a is int and b is list[str]
...
性能考虑
理想情况下,match
语句应具有良好的运行时性能,与等效的if语句链相比。尽管编程语言的历史充满了新功能的例子,这些功能提高了工程师的生产力,但以增加 CPU 周期的代价为代价,但如果match
的好处被运行时性能的整体显著下降所抵消,那将是不幸的。
虽然本 PEP 未指定任何特定的实现策略,但需要简单介绍一下原型实现及其如何尝试最大化性能。
基本上,原型实现将所有match
语句语法转换为等效的if/else块——更准确地说,转换为具有相同效果的Python字节码。换句话说,所有用于测试实例类型、序列长度、映射键等的逻辑都内联在match
的位置。
这不是唯一可能的策略,也不是必然最好的策略。例如,可以对实例检查进行记忆化,尤其是在单个匹配语句中存在多个相同类类型的实例但参数不同的情况下。从理论上讲,未来的实现也可以使用决策树并行处理case子句或子模式,而不是逐一测试它们。
向后兼容性
此 PEP 完全向后兼容:建议match
和case
关键字为(并保持!)软关键字,因此它们用作变量、函数、类、模块或属性名称不会受到任何阻碍。
这很重要,因为match
是re
模块中一个流行且众所周知的函数和方法的名称,我们不想破坏或弃用它。
硬关键字和软关键字的区别在于,硬关键字始终是保留字,即使在它们毫无意义的位置(例如x = class + 1
),而软关键字仅在上下文中具有特殊含义。自从PEP 617以来,解析器回溯,这意味着在尝试解析代码片段的不同尝试中,它可以对软关键字进行不同的解释。
例如,假设解析器遇到以下输入
match [x, y]:
解析器首先尝试将其解析为表达式语句。它将match
解释为NAME标记,然后将[x, y]
视为双下标。然后它遇到冒号,必须回溯,因为表达式语句后面不能跟冒号。然后解析器回溯到行的开头,发现match
是在此位置允许的软关键字。然后它将[x, y]
视为列表表达式。然后冒号正是解析器所期望的,解析成功。
对第三方工具的影响
Python 生态系统中有很多操作 Python 源代码的工具:linter、语法高亮器、自动格式化程序和 IDE。所有这些都需要更新以包含对match
语句的认识。
通常,这些工具分为两类
浅层解析器不会尝试理解 Python 的完整语法,而是扫描源代码以查找特定的已知模式。IDE(例如 Visual Studio Code、Emacs 和 TextMate)倾向于属于此类别,因为源代码在编辑时经常无效,并且严格的解析方法将失败。
对于这些类型的工具,添加新关键字的知识相对容易,只需添加到表中,或者修改正则表达式即可。
深层解析器理解 Python 的完整语法。一个例子是自动格式化程序Black。这些类型的工具的一个特殊要求是,它们不仅需要理解当前 Python 版本的语法,还需要理解旧版本的 Python。
match
语句使用软关键字,并且它是第一个利用新 PEG 解析器功能的主要 Python 功能之一。这意味着不“兼容 PEG”的第三方解析器将难以处理新语法。
有人注意到,许多这些第三方工具利用通用的解析库(例如 Black 使用 lib2to3 解析器的分支)。识别广泛使用的解析库(例如parso和libCST)并将其升级为兼容 PEG 可能会有所帮助。
但是,由于这项工作不仅需要为匹配语句完成,还需要为任何利用 PEG 解析器功能的新 Python 语法完成,因此它被认为超出本 PEP 的范围。(尽管建议这将成为一个不错的 Google 代码之夏项目。)
参考实现
一个功能完整的 CPython 实现可在 GitHub 上获得。
示例代码
一个小的示例代码集合可在 GitHub 上获得。
被拒绝的想法
这个总体思路已经存在相当长一段时间了,并且做出了许多来回的决定。在这里,我们总结了许多曾经采用但最终放弃的替代路径。
不要这样做,模式匹配很难学习
在我们看来,提议的模式匹配并不比向可迭代解包添加isinstance()
和getattr()
更难。此外,我们认为提议的语法通过允许表达想要做什么而不是如何做,显著提高了各种代码模式的可读性。我们希望我们在上面 PEP 中包含的一些真实代码片段能够很好地说明这种比较。有关更多真实代码示例及其翻译,请参见参考文献[1]。
不要这样做,使用现有的方法分派机制
我们认识到,match
语句的一些用例与使用类继承的传统面向对象编程 (OOP) 设计技术所能实现的重叠。对于严格的 OOP 纯粹主义者来说,根据测试匹配主题的运行时类型来选择备用行为的能力甚至可能显得异端。
但是,Python 一直以来都是一种拥抱各种编程风格和范式的语言。“鸭子”类型等经典 Python 设计习惯用法超出了传统的 OOP 模型。
我们认为,在某些重要的用例中,使用match
会导致更简洁、更易于维护的架构。这些用例往往具有以下一些特征
- 跨越传统数据封装线的算法。如果算法正在处理不同类型的异构元素(例如评估或转换抽象语法树,或进行数学符号的代数操作),则强制用户将算法实现为每个元素类型的单独方法会导致逻辑分散在整个代码库中,而不是整齐地集中在一个地方。
- 程序架构,其中可能的数据类型集相对稳定,但要对这些数据类型执行的操作集不断扩展。在严格的 OOP 方式下执行此操作需要不断向基类和子类添加新方法以支持新方法,“污染”基类中大量非常专业的方**法定义,并导致代码中的广泛中断和混乱。相比之下,在基于
match
的分派中,添加新行为只需编写新的match
语句。 - OOP 也不处理基于对象形状的分派,例如元组的长度或属性的存在——相反,任何此类分派决策都必须编码到对象的类型中。当处理“鸭子”类型对象时,基于形状的分派特别有趣。
OOP 在相反的情况下明显优越:其中可能的操作集相对稳定且定义明确,但要操作的数据类型集却不断增长。一个经典的例子是 UI 小部件工具包,其中有一组固定的交互类型(重绘、鼠标点击、按键等),但小部件类型的集合随着开发人员发明新的和创造性的用户交互样式而不断扩展。添加一种新的小部件只需编写一个新的子类,而使用基于匹配的方法,您最终需要在许多广泛的匹配语句中添加一个新的 case 子句。因此,我们不建议在这种情况下使用match
。
允许更灵活的赋值目标
曾经有一个想法,即仅将可迭代解包推广到更通用的赋值目标,而不是添加一种新的语句类型。这个概念在一些其他语言中被称为“不可反驳的匹配”。我们决定不这样做,因为对现实生活中的潜在用例的检查表明,在绝大多数情况下,解构与if
条件有关。而且其中许多都分组在一系列互斥的选择中。
将其设为表达式
在大多数其他语言中,模式匹配由表达式而不是语句表示。但是将其设为表达式将与 Python 中的其他语法选择不一致。所有决策逻辑几乎都以语句的形式表达,因此我们决定不偏离这一点。
使用硬关键字
可以选择将match
设为硬关键字,或选择不同的关键字。尽管使用硬关键字会简化简单语法高亮器的使用,但我们出于以下几个原因决定不使用硬关键字
- 最重要的是,新的解析器不需要我们这样做。与
async
在几个版本中作为软关键字导致的困难不同,在这里我们可以将match
永久设置为软关键字。 match
在现有代码中非常常用,因此它会破坏几乎所有现有的程序,并给许多可能无法从新语法中受益的人带来修复代码的负担。- 很难找到一个在现有程序中不会被普遍用作标识符的替代关键字,并且仍然能够清晰地反映语句的含义。
使用 as
或 |
代替 case
用于 case 子句
此处提出的模式匹配结合了多分支控制流(与 Algol 派生语言中的switch
或 Lisp 中的cond
一致)和函数式语言中的对象解构。虽然建议的关键字case
突出了多分支方面,但诸如as
之类的替代关键字也同样适用,突出了解构方面。as
或with
例如,也具有已经是 Python 中关键字的优势。但是,由于case
作为关键字只能在match
语句内部作为引导关键字出现,因此解析器很容易区分其用作关键字还是变量。
其他变体将使用类似|
或=>
的符号,或者完全不使用特殊标记。
由于 Python 是一种遵循 Algol 传统的面向语句的语言,并且每个复合语句都以一个标识关键字开头,因此case
似乎最符合 Python 的风格和传统。
使用扁平缩进方案
有一个想法是使用另一种缩进方案,例如,每个 case 子句相对于初始match
部分不进行缩进。
match expression:
case pattern_1:
...
case pattern_2:
...
其动机在于,尽管扁平缩进节省了一些水平空间,但对于 Python 程序员来说可能看起来很别扭,因为在其他所有地方,冒号后面都跟着缩进。这也会使简单的代码编辑器变得复杂。最后,可以通过允许匹配语句使用“半缩进”(即两个空格而不是四个)来缓解水平空间问题。
在作为此 PEP 开发的一部分编写的使用match
的示例程序中,观察到代码简洁性的显著改进,超过了额外缩进级别的补偿。
另一个考虑的提议是使用扁平缩进,但将表达式放在match:
后面的行上,如下所示
match:
expression
case pattern_1:
...
case pattern_2:
...
这最终被拒绝了,因为第一个块在 Python 语法中将是一个新颖事物:一个块,其唯一内容是一个表达式而不是一系列语句。
常量值模式的替代方案
这可能是最棘手的一项。对某些预定义常量进行匹配非常常见,但 Python 的动态特性也使其与捕获模式产生歧义。考虑了其他五个备选方案
- 使用一些隐式规则。例如,如果某个名称在全局范围内定义,则它指的是一个常量,而不是表示捕获模式
# Here, the name "spam" must be defined in the global scope (and # not shadowed locally). "side" must be local. match entree[-1]: case spam: ... # Compares entree[-1] == spam. case side: ... # Assigns side = entree[-1].
但是,如果有人在 match 语句之前定义了一个不相关的重合名称,这可能会导致意外和远程操作。
- 使用基于名称大小写的规则。特别是,如果名称以小写字母开头,则它将是捕获模式,而如果名称以大写字母开头,则它将指的是一个常量
match entree[-1]: case SPAM: ... # Compares entree[-1] == SPAM. case side: ... # Assigns side = entree[-1].
这与PEP 8中关于命名常量的建议非常吻合。主要反对意见是,核心 Python 的其他任何部分都没有名称的大小写具有语义意义。此外,Python 允许标识符使用不同的脚本,其中许多(例如 CJK)没有大小写区分。
- 使用额外的括号来指示给定名称的查找语义。例如
match entree[-1]: case (spam): ... # Compares entree[-1] == spam. case side: ... # Assigns side = entree[-1].
这可能是一个可行的选择,但如果经常使用,它可能会产生一些视觉噪声。而且老实说,它看起来很不寻常,尤其是在嵌套上下文中。
这也存在一个问题,即我们可能希望或需要括号来消除模式中的分组歧义,例如在
Point(x, y=(y := complex()))
中。 - 引入一个特殊符号,例如
.
、?
、$
或^
来指示给定名称是要匹配的值,而不是要分配给的值。此提议的早期版本使用了前导点规则match entree[-1]: case .spam: ... # Compares entree[-1] == spam. case side: ... # Assigns side = entree[-1].
虽然可能有用,但它引入了看起来很奇怪的新语法,而没有使模式语法更具表现力。实际上,可以通过将命名常量转换为
Enum
类型或将它们包含在它们自己的命名空间中来使命名常量与现有规则一起工作(作者认为这是一个非常棒的想法)。match entree[-1]: case Sides.SPAM: ... # Compares entree[-1] == Sides.SPAM. case side: ... # Assigns side = entree[-1].
如果需要,可以稍后添加前导点规则(或类似的变体),而不会出现向后兼容性问题。
- 还有一个想法是将查找语义设为默认值,并要求在捕获模式中使用
$
或?
match entree[-1]: case spam: ... # Compares entree[-1] == spam. case side?: ... # Assigns side = entree[-1].
这方面存在一些问题
- 捕获模式在典型代码中更常见,因此不需要为它们提供特殊语法。
- 作者不知道有任何其他语言以这种方式装饰捕获。
- 所有提议的语法在 Python 中都没有任何先例;Python 中没有其他绑定名称的地方(例如
import
、def
、for
)使用特殊标记语法。 - 它会破坏当前语法的语法并行性
match coords: case ($x, $y): return Point(x, y) # Why not "Point($x, $y)"?
最后,由于上述缺点,这些备选方案被拒绝了。
在模式中不允许浮点数字面量
由于浮点数的不精确性,此提议的早期版本不允许使用浮点常量作为匹配模式。禁止此操作的部分理由是 Rust 也是这样做的。
但是,在实现过程中,人们发现区分浮点值和其他类型需要在虚拟机中添加额外的代码,这会普遍降低匹配速度。鉴于 Python 和 Rust 是两种非常不同的语言,具有不同的用户群和底层理念,因此认为允许浮点文字不会造成太大危害,并且对用户来说不那么令人惊讶。
范围匹配模式
这将允许诸如1...6
之类的模式。但是,存在许多歧义
- 范围是开范围、半开范围还是闭范围?(即,在上述示例中
6
是否包含在内?) - 范围是否匹配单个数字或范围对象?
- 范围匹配通常用于字符范围('a'...'z'),但这在 Python 中不起作用,因为 Python 没有字符数据类型,只有字符串。
- 如果可以预先构建跳转表,则范围匹配可以成为一项重要的性能优化,但这在 Python 中通常是不可能的,因为名称可以动态重新绑定。
与其为范围创建特殊语法,不如决定允许自定义模式对象(InRange(0, 6)
)会更加灵活且不那么模糊;但是这些想法目前已被推迟(参见推迟的想法)。
对匹配使用分派字典语义
经典switch
语句的实现有时会使用预先计算的哈希表而不是链式相等比较来提高性能。在match
语句的上下文中,对于针对文字模式的匹配,这在技术上也是可能的。但是,对于不同类型的模式具有细微不同的语义会让人感到意外,而潜在的性能提升却很有限。
如果不会导致语义差异,我们仍然可以尝试在这个方向上进行可能的性能优化。
在 case 子句中使用 continue
和 break
。
另一个被拒绝的提议是在match
内部重新定义continue
和break
的含义,这将具有以下行为
continue
将退出当前的 case 子句,并在下一个 case 子句处继续匹配。break
将退出 match 语句。
但是,此提议有一个严重的缺点:如果match
语句嵌套在循环内,则continue
和break
的含义现在已更改。这可能会在重构期间导致意外行为;此外,可以说还有其他方法可以获得相同行为(例如使用保护条件),并且在实践中,continue
和break
的现有行为更有用。
与 (&
) 模式
此提议定义了一个 OR 模式(|
)来匹配多个备选方案中的一个;为什么不也定义一个 AND 模式(&
)呢?尤其是考虑到其他一些语言(例如 F#)支持这一点。
但是,目前尚不清楚这将有多大用处。匹配字典、对象和序列的语义已经包含了一个隐式“and”:所有提到的属性和元素都必须存在才能使匹配成功。保护条件还可以支持假设的“and”运算符将用于的许多用例。
最后,决定这会使语法更加复杂,而不会带来显著益处。
负匹配模式
使用运算符!
作为前缀对匹配模式进行否定将完全匹配,如果模式本身不匹配。例如,!(3 | 4)
将匹配除3
或4
之外的任何内容。
这被拒绝了,因为有文件证据表明此功能很少有用(在支持它的语言中)或用作双重否定!!
来控制变量作用域并防止变量绑定(这在 Python 中不适用)。它也可以使用保护条件模拟。
在运行时检查穷尽性
问题是,如果没有任何 case 子句具有匹配的模式,并且没有默认 case,该怎么办。该提议的早期版本规定,在这种情况下,行为将是抛出异常而不是静默地贯穿。
争论有很多,但最终“显式优于隐式”(EIBTI)的论点胜出:如果程序员希望抛出异常,最好让他们显式地抛出异常。
对于密封类和枚举等情况,其中所有模式都已知是离散集的成员,静态检查器可以警告缺少的模式。
模式变量的类型注释
提案是将模式与类型注释结合起来。
match x:
case [a: int, b: str]: print(f"An int {a} and a string {b}:)
case [a: int, b: int, c: int]: print(f"Three ints", a, b, c)
...
这个想法有很多问题。首先,冒号只能用在方括号或圆括号内,否则语法就会变得模棱两可。而且,由于 Python 不允许对泛型类型进行isinstance()
检查,因此包含泛型的类型注释将无法按预期工作。
允许 *rest
在类模式中
有人提议允许在类模式中使用*rest
,将一个变量绑定到所有位置参数(类似于其在解包赋值中的用法)。这将提供与序列模式的一些对称性。但它可能会与一次提供所有位置参数的值的功能混淆。而且似乎对此没有实际需求,因此被放弃了。(如果需要,可以在以后的阶段轻松添加它。)
不允许 _.a
在常量值模式中
第一个公开草案指出,常量值模式中的初始名称不能是_
,因为_
在模式匹配中具有特殊含义,因此这将无效。
case _.a: ...
(但是,a._
是合法的,并且像往常一样加载对象a
的名称为_
的属性。)
在 python-dev 上对此有一些抵制(有些人对_
作为重要的全局变量有正当的用途,尤其是在 i18n 中),而禁止此操作的唯一原因是为了防止一些用户混淆。但这并不是值得为此而放弃的原则。
使用其他标记作为通配符
有人提议使用...
(即省略号标记)或*
(星号)作为通配符。但是,这两个看起来都像是省略了任意数量的项目。
case [a, ..., z]: ...
case [a, *, z]: ...
两者看起来都像匹配两个或多个项目的序列,捕获第一个和最后一个值。
此外,如果*
用作通配符,我们将不得不想出其他方法来捕获序列的其余部分,目前拼写如下。
case [first, second, *rest]: ...
在文档和示例中使用省略号也会更令人困惑,其中...
通常用于表示某些显而易见或无关紧要的内容。(是的,这也可以作为反对 Python 中其他...
用法的论点,但那已经是过去的事情了。)
另一个提议是使用?
。这可能是可以接受的,尽管它需要修改标记器。
此外,_
已在其他上下文中用作一次性目标,并且此用法非常相似。此示例来自标准库中的difflib.py
。
for tag, _, _, j1, j2 in group: ...
也许最令人信服的论点是,_
在我们查看过的所有其他支持模式匹配的语言中都用作通配符:C#、Elixir、Erlang、F#、Haskell、Mathematica、OCaml、Ruby、Rust、Scala 和 Swift。现在,总的来说,我们不应该太关心其他语言做了什么,因为 Python 明显不同于所有这些语言。但是,如果存在如此压倒性和强烈的共识,那么 Python 不应该费尽心思去做一些完全不同的事情——尤其是在_
在 Python 中运行良好并且已经用作一次性目标的情况下。
请注意,_
不会被模式赋值——这避免了与_
用作可翻译字符串的标记以及gettext.gettext
的别名(如gettext
模块文档推荐)的冲突。
使用其他语法代替 |
用于或模式
有人提议了一些替代方案,而不是使用|
来分隔 OR 模式中的备选方案。而不是
case 401|403|404:
print("Some HTTP error")
以下提案已被提出
- 使用逗号
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")
- 使用
or
关键字case 401 or 403 or 404: print("Some HTTP error")
这可以工作,并且可读性与使用
|
没有太大区别。一些用户表示更喜欢or
,因为他们将|
与按位 OR 相关联。然而- 许多其他具有模式匹配的语言都使用
|
(列表包括 Elixir、Erlang、F#、Mathematica、OCaml、Ruby、Rust 和 Scala)。 |
更短,这可能有助于提高嵌套模式的可读性,例如Point(0|1, 0|1)
。- 有些人错误地认为
|
的优先级错误;但由于模式不支持其他运算符,因此它与表达式中的优先级相同。 - Python 用户非常频繁地使用
or
,并且可能会产生它与布尔短路密切相关的印象。 |
用于正则表达式和 EBNF 语法(如 Python 自己的)中的备选方案之间。|
不仅用于按位 OR——它用于集合联合、字典合并(PEP 584)并且正在考虑作为typing.Union
的替代方案(PEP 604)。|
作为视觉分隔符效果更好,尤其是在字符串之间。比较case "spam" or "eggs" or "cheese":
到
case "spam" | "eggs" | "cheese":
- 许多其他具有模式匹配的语言都使用
添加 else
子句
我们决定不添加else
子句,原因有几个。
- 它是多余的,因为我们已经有
case _:
。 - 关于
else:
的缩进级别将永远存在混淆——它应该与 case 列表对齐还是与match
关键字对齐? - 像“每个其他语句都有一个”这样的完美主义论点是错误的——只有那些在添加新功能时才具有
else
子句的语句。
延迟的想法
有很多扩展匹配语法的提案,我们决定推迟到未来的 PEP 中。这些属于“很酷的想法,但不是必需的”的范畴,人们认为在继续推进其中一些提案之前,最好先获取一些关于 match 语句在实践中如何使用的真实数据。
请注意,在每种情况下,该想法都被认为是“双向门”,这意味着以后添加这些功能不应该有任何向后兼容性问题。
一次性语法变体
在检查一些可能从提议的语法中获益最多的代码库时,发现单子句匹配将相对频繁地使用,主要用于各种特殊情况。在其他语言中,这以一次性匹配的形式得到支持。我们建议也支持这种一次性匹配。
if match value as pattern [and guard]:
...
或者,或者,不带if
match value as pattern [if guard]:
...
等同于以下扩展
match value:
case pattern [if guard]:
...
为了说明这将如何提高可读性,请考虑此(略微简化)来自实际代码的片段
if isinstance(node, CallExpr):
if (isinstance(node.callee, NameExpr) and len(node.args) == 1 and
isinstance(node.args[0], NameExpr)):
call = node.callee.name
arg = node.args[0].name
... # Continue special-casing 'call' and 'arg'
... # Follow with common code
这可以用更直接的方式重写为
if match node as CallExpr(callee=NameExpr(name=call), args=[NameExpr(name=arg)]):
... # Continue special-casing 'call' and 'arg'
... # Follow with common code
这种一次性形式不允许elif match
语句,因为它只用于处理单个模式情况。它旨在成为match
语句的特例,而不是if
语句的特例。
if match value_1 as patter_1 [and guard_1]:
...
elif match value_2 as pattern_2 [and guard_2]: # Not allowed
...
elif match value_3 as pattern_3 [and guard_3]: # Not allowed
...
else: # Also not allowed
...
这将违背一次性匹配作为穷举完整匹配的补充的目的——在这种情况下,最好且更清晰地使用完整匹配。
类似地,不允许使用if not match
,因为match ... as ...
不是表达式。我们也没有提议在某些支持模式匹配的语言中存在的while match
结构,因为虽然它可能很方便,但它可能很少使用。
其他基于模式的构造
许多其他支持模式匹配的语言都将其作为多种语言结构的基础,包括匹配运算符、广义赋值形式、循环过滤器、同步通信的方法或专门的 if 语句。在第一版讨论中提到了其中一些。另一个问题是,为什么选择这种特定形式(结合绑定和条件选择)而没有选择其他形式。
鉴于我们使用模式的经验,引入更多模式的用法会过于大胆和过早,并且会使本提案过于复杂。所提出的陈述提供了一种足够通用且有用的功能形式,并且不会对整个语言的语法和语义产生重大影响。
在使用此功能一段时间后,社区可能会更好地了解模式匹配的其他哪些用法在 Python 中可能很有价值。
重复名称的代数匹配
在 Erlang 和 Elixir 等函数式语言中偶尔会看到一种技术,即在同一模式中多次使用匹配变量。
match value:
case Point(x, x):
print("Point is on a diagonal!")
这里的想法是,x
的第一次出现会将值绑定到名称,后续出现会验证传入的值是否等于之前绑定的值。如果值不相等,则匹配将失败。
但是,混合捕获模式的加载-存储语义涉及许多细微差别。目前,我们决定将同一模式内名称的重复使用视为错误;我们以后可以随时放宽此限制,而不会影响向后兼容性。
请注意,您**可以**在备选选择中多次使用相同的名称。
match value:
case x | [x]:
# etc.
自定义匹配协议
在该 PEP 的初始设计讨论期间,围绕自定义匹配器提出了很多想法。这有几个动机。
- 某些类可能希望公开一组与实际类属性不同的“可匹配”名称。
- 某些类可能具有计算成本高的属性,因此除非匹配模式实际上需要访问它们,否则不应对其进行评估。
- 有一些关于奇特匹配器的想法,例如
IsInstance()
、InRange()
、RegexMatchingGroup()
等。 - 为了使内置类型和标准库类能够以合理且直观的方式支持匹配,人们认为这些类型需要实现特殊的匹配逻辑。
这些自定义匹配行为将由类名上的特殊 __match__
方法控制。有两种竞争方案。
- 一种“功能齐全”的匹配协议,它不仅会传入要匹配的主题,还会传入有关指定模式感兴趣的属性的详细信息。
- 一种简化的匹配协议,它只传入主题值,并返回一个包含可匹配属性的“代理对象”(在大多数情况下可以只是主题)。
以下是一个更复杂的协议版本示例。
match expr:
case BinaryOp(left=Number(value=x), op=op, right=Number(value=y)):
...
from types import PatternObject
BinaryOp.__match__(
(),
{
"left": PatternObject(Number, (), {"value": ...}, -1, False),
"op": ...,
"right": PatternObject(Number, (), {"value": ...}, -1, False),
},
-1,
False,
)
该协议的一个缺点是,__match__
的参数构建起来成本很高,并且由于名称绑定方式,Python 中没有真正的常量,因此无法预先计算。这也意味着 __match__
方法必须重新实现大部分匹配逻辑,否则这些逻辑将在 Python VM 中的 C 代码中实现。因此,与等效的 if
语句相比,此选项的性能会很差。
更简单的协议存在一个问题,即虽然它性能更高,但灵活性却大大降低,并且不允许人们梦寐以求的许多创造性的自定义匹配器。
然而,在设计过程的后期,人们意识到对自定义匹配协议的需求远低于预期。几乎所有提出的现实(而不是异想天开)用例都可以通过内置匹配行为来处理,尽管在少数情况下需要额外的保护条件才能获得所需的效果。
此外,事实证明,除了适当的 __match_args__
属性外,没有一个标准库类真正需要任何特殊的匹配支持。
推迟此功能的决定是基于这样一种认识:这不是单行道;可以稍后添加更灵活和可自定义的匹配协议,尤其是在我们积累了更多现实世界用例和实际用户需求的经验之后。
本 PEP 的作者预计,随着使用模式和习惯用法的演变,match
语句会随着时间的推移而演变,这与过去其他“多阶段”PEP 的做法类似。发生这种情况时,可以重新审视扩展匹配问题。
参数化匹配语法
(也称为“类实例匹配器”。)
这是“自定义匹配类”想法的另一种变体,它允许使用上一节中提到的各种自定义匹配器——但是,它不是使用扩展匹配协议,而是通过引入具有自己语法的附加模式类型来实现。此模式类型将接受两组不同的参数:一组包含实际传递到模式对象构造函数的参数,另一组表示模式的绑定变量。
这些对象的 __match__
方法可以在决定什么匹配有效时使用构造函数参数值。
这将允许诸如 InRange<0, 6>(value)
之类的模式,它将匹配 0..6 范围内的数字并将匹配的值分配给“value”。类似地,可以有一个模式来测试正则表达式匹配结果中是否存在命名组(“匹配”一词的不同含义)。
尽管这个想法得到了一些支持,但在语法上存在大量争论(没有很多有吸引力的选项可用),并且没有达成明确的共识,因此决定目前此功能对 PEP 来说不是必需的。
模式实用程序库
前两个想法都将伴随着一个新的 Python 标准库模块,该模块将包含一组丰富的有用匹配器。但是,在不采用上一节中给出的扩展模式提案之一的情况下,实际上不可能实现这样的库,因此此想法也被推迟。
致谢
我们感谢以下个人(以及许多其他人)在该 PEP 的撰写各个阶段提供的帮助。
- Gregory P. Smith
- Jim Jewett
- Mark Shannon
- Nate Lust
- Taine Zhao
版本历史
- 初始版本
- 重大重写,包括
- 细微的澄清、语法和错别字更正
- 重命名各种概念
- 关于被拒绝想法的额外讨论,包括
- 为什么我们选择
_
作为通配符模式 - 为什么我们选择
|
作为 OR 模式 - 为什么我们选择不使用特殊语法来捕获变量
- 为什么是这种模式匹配操作而不是其他操作
- 为什么我们选择
- 阐明异常和副作用语义
- 阐明部分绑定语义
- 取消在加载上下文中使用
_
的限制 - 取消默认单个位置参数为整个主题,除了少数内置类型
- 简化
__match_args__
的行为 - 删除
__match__
协议(已移至推迟的想法) - 删除
ImpossibleMatchError
异常 - 删除加载的前导点(已移至推迟的想法)
- 重新编写了初始部分(语法之前的所有内容)
- 在详细描述之前添加了所有模式类型的概述
- 在每个模式的描述旁边添加了简化的语法
- 将通配符与捕获模式的描述分开
- 添加 Daniel F Moisset 作为第六位合著者
参考文献
附录 A – 全部语法
match_stmt
的完整语法如下。这是 compound_stmt
的另一个替代方案。应该理解,match
和 case
是软关键字,即它们在其他语法上下文中不是保留字(包括如果预期没有冒号的行首)。按照惯例,硬关键字使用单引号,而软关键字使用双引号。
除了标准 EBNF 之外的其他符号
SEP.RULE+
是RULE (SEP RULE)*
的简写!RULE
是一个负前瞻断言
match_expr:
| star_named_expression ',' star_named_expressions?
| named_expression
match_stmt: "match" match_expr ':' NEWLINE INDENT case_block+ DEDENT
case_block: "case" patterns [guard] ':' block
guard: 'if' named_expression
patterns: value_pattern ',' [values_pattern] | pattern
pattern: walrus_pattern | or_pattern
walrus_pattern: NAME ':=' or_pattern
or_pattern: '|'.closed_pattern+
closed_pattern:
| capture_pattern
| literal_pattern
| constant_pattern
| group_pattern
| sequence_pattern
| mapping_pattern
| class_pattern
capture_pattern: NAME !('.' | '(' | '=')
literal_pattern:
| signed_number !('+' | '-')
| signed_number '+' NUMBER
| signed_number '-' NUMBER
| strings
| 'None'
| 'True'
| 'False'
constant_pattern: attr !('.' | '(' | '=')
group_pattern: '(' patterns ')'
sequence_pattern: '[' [values_pattern] ']' | '(' ')'
mapping_pattern: '{' items_pattern? '}'
class_pattern:
| name_or_attr '(' ')'
| name_or_attr '(' ','.pattern+ ','? ')'
| name_or_attr '(' ','.keyword_pattern+ ','? ')'
| name_or_attr '(' ','.pattern+ ',' ','.keyword_pattern+ ','? ')'
signed_number: NUMBER | '-' NUMBER
attr: name_or_attr '.' NAME
name_or_attr: attr | NAME
values_pattern: ','.value_pattern+ ','?
items_pattern: ','.key_value_pattern+ ','?
keyword_pattern: NAME '=' or_pattern
value_pattern: '*' capture_pattern | pattern
key_value_pattern:
| (literal_pattern | constant_pattern) ':' or_pattern
| '**' capture_pattern
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0622.rst
上次修改时间:2023-09-09 17:39:29 GMT