Python 3.10新特性——模式匹配介绍
Python 3.10新特性——模式匹配介绍
摘要
本文对Python 3.10新特性模式匹配进行了介绍,原文见:https://peps.python.org/pep-0622/
模式与形状
模式语法基于Python现有的序列解包语法(例如,a, b = value
)。match
语句将一个值(主体)与几种不同的形状(模式)进行比较,直到找到一个匹配的形状。每个模式描述了可接受值的类型和结构,以及用于捕获其内容的变量。模式可以指定形状为:前面提到的要解包的序列、具有特定键的映射、具有(可选)特定属性的给定类的实例、特定值、通配符。模式可以通过多种方式组合。
语法
从语法上讲,match
语句包含:一个主体表达式、一个或多个case
子句。每个case
子句指定:一个模式(要匹配的整体形状)、一个可选的 “防护子句”(如果模式匹配则要检查的条件)、如果选择了该case
子句则要执行的代码块 。
概述
模式是一个新的句法类别,有其自身的规则和特殊情况。模式以新颖的方式混合了输入(给定值)和输出(捕获变量)。要有效地使用它们可能需要一些时间。作者在此提供了基本概念的简要介绍。请注意,本节并不完整,也并非完全准确。
模式,一种新的句法结构,以及解构
本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定义了几种类型的模式。这些肯定不是唯一可能的模式,因此设计决策是选择一组目前有用但保守的功能。随着这个特性的更广泛使用,可以在以后添加更多模式。有关更多详细信息,请参见被拒绝的想法和延期考虑的想法部分。
这里列出的模式将在下面更详细地描述,但为了简单起见,在此部分进行汇总:
True
、False
和None
等一些值),只匹配与字面量相等的对象,从不绑定变量。x
,等同于一个相同的赋值目标,总是匹配并将给定(简单)名称的变量绑定。_
,总是匹配,但不捕获任何变量(这可以防止与_
的其他用途冲突,并允许进行一些优化)。Color.RED
,只匹配与相应值相等的值,从不绑定。[a, *rest,b]
,类似于列表解包。一个重要的区别是其中嵌套的元素可以是任何类型的模式,而不仅仅是名称或序列。它只匹配长度合适的序列,只要所有子模式也匹配。它会对其所有子模式进行绑定。{"user": u,"emails": [*es]}
,匹配至少包含所提供键集的映射,并且所有子模式都与其对应的值匹配。它会绑定子模式在与对应键的值匹配时所绑定的内容。允许在模式末尾添加**rest
来捕获额外的项。datetime.date(year=y, day=d)
,匹配给定类型的实例,这些实例至少具有指定的属性,只要这些属性与相应的子模式匹配。它会绑定子模式在与给定属性的值匹配时所绑定的内容。一个可选的协议还允许匹配位置参数。[*x] |{"elems": [*x]}
,只要其中任何一个子模式匹配,整个模式就匹配。它使用最左边匹配的模式的绑定。d :=datetime(year=2020, month=m)
,只有当其子模式也匹配时才匹配。它绑定子模式匹配时所绑定的内容,并且还将命名变量绑定到整个对象。match语句
提议语法的简化、近似语法如下:
compound_statement:
| if_stmt
...
| match_stmt
match_stmt: "match" expression
我们提议将匹配操作设计为一条语句,而非一个表达式。尽管在许多语言中,模式匹配是一个表达式,但在Python中,作为语句更符合其语法的一般逻辑。更多讨论请见“被拒绝的想法”部分。允许的模式将在下面“模式”小节中详细描述。
match
和case
关键字被提议作为软关键字,这意味着它们仅在match
语句或case
块的开头被识别为关键字,在其他地方仍可作为变量名、参数名使用。
提议的缩进结构如下:
match some_expression:
case pattern_1:
...
case pattern_2:
...
这里,some_expression
代表被匹配的值,在后续内容中,它将被称为匹配的主体。
匹配语义
我们提议的整体匹配语义是选择第一个匹配的模式,并执行相应的代码块,后续的模式将不再进行尝试。如果没有匹配的模式,该语句将“落空”,程序继续执行后续的语句。
本质上,这等同于一系列if... elif... else
语句。需要注意的是,与之前提议的switch
语句不同,这里不适用预先计算的调度字典语义。
这里没有default
或else
子句,而是可以使用特殊的通配符_
(见“捕获模式”部分)作为最后的“兜底”模式。
在成功的模式匹配过程中创建的名称绑定在执行完相应代码块后仍然有效,并且可以在match
语句之后使用。这遵循了Python中其他可以绑定名称的语句(如for
循环和with
语句)的逻辑。例如:
match shape:
case Point(x, y):
...
case Rectangle(x, y, _, _):
...
print(x, y) # 这是可行的
在模式匹配失败的情况下,某些子模式可能会匹配成功。例如,在将值[0, 1, 2]
与模式(0, x, 1)
进行匹配时,如果从左到右匹配列表元素,子模式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
,如果""
这个case
子句被执行,可能会引发UnboundLocalError
,而不是NameError
:
match greeting:
case "":
print("Hello!")
case name:
print(f"Hi {name}!")
if name == "Santa": # <-- 可能会引发UnboundLocalError
... # 但如果greeting不为空,则代码正常运行
在与每个case
子句进行匹配时,一个名称最多只能绑定一次,出现两个同名的捕获模式会导致错误:
match data:
case [x, x]: # 错误!
...
注意:仍然可以使用防护子句来匹配包含相等元素的集合。此外,[x, y] |Point(x, y)
是一个合法的模式,因为这两个可选模式永远不会同时匹配。
单个下划线(_
)不被视为NAME
,而是被特殊处理为通配符模式。
提醒:None
、False
和True
是表示字面量的关键字,而不是名称。
通配符模式
简化语法:
wildcard_pattern: "_"
单个下划线(_
)名称是一种特殊的模式,它总是匹配,但从不绑定:
match data:
case [_, _]:
print("Some pair")
print(_) # 错误!
由于不会进行绑定,与捕获模式不同,它可以根据需要多次使用。
常量值模式
简化语法:
constant_pattern: NAME ('.' NAME)+
常量值模式用于匹配常量和枚举值。模式中的每个带点名称都会使用正常的Python名称解析规则进行查找,其值用于与匹配主体进行相等性比较(与字面量的比较方式相同):
from enum import Enum
class Sides(str, Enum):
SPAM = "Spam"
EGGS = "eggs"
...
match entree[-1]:
case Sides.SPAM: # 比较entree[-1] == Sides.SPAM
response = "Have you got anything without Spam?"
case side: # 赋值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
),也不能是迭代器。如果要匹配特定的集合类,请见下面的类模式。
_
通配符可以加上星号,以匹配不同长度的序列。例如:
[*_]
匹配任意长度的序列。(_, _, *_)
匹配长度为两个或更多的序列。["a", *_, "z"]
匹配长度为两个或更多、以"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: # 错误!
...
case x | 1: # 错误!
...
case one := [1] | two := [2]: # 错误!
...
case Foo(arg=x) | Bar(arg=x): # 有效,两个分支都绑定'x'
...
case [x] | x: # 有效,两个分支都绑定'x'
...
防护子句
每个顶级模式后面都可以跟一个形式为if expression
的防护子句。只有当模式匹配且防护子句求值为真值时,case
子句才会成功。例如:
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")
如果计算防护子句时引发异常,该异常将继续传播,而不是使case
子句失败。模式中出现的名称在防护子句成功之前进行绑定。因此,以下代码可以正常工作:
values = [0]
match values:
case [x] if x:
... # 这部分不会被执行
case _:
...
print(x) # 这将打印 "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
语句之后使用。但是,只有在子模式成功匹配时,该名称才会被绑定。另一个例子:
match group_shapes():
case [], [point := Point(x, y), *other]:
print(f"Got {point} in the second group")
process_coordinates(x, y)
...
从技术上讲,大多数这样的例子可以使用防护子句和/或嵌套的match
语句重写,但这样做可读性会更差,并且/或者会生成效率较低的代码。基本上,PEP 572中的大多数论点在这里同样适用。
通配符_
在这里不是一个有效的名称。
运行时规范
匹配协议
使用等效的isinstance
调用来确定一个对象是否匹配给定的类模式,并提取相应的属性。需要不同匹配语义(如鸭子类型)的类可以通过定义__instancecheck__
(一个已有的元类钩子)或使用typing.Protocol
来实现。
具体过程如下:
- 查找
Class(<sub-patterns>)
中Class
的类对象,并调用isinstance(obj, Class)
,其中obj
是被匹配的值。如果返回False
,则匹配失败。 - 否则,如果以位置参数或关键字参数的形式给出了任何子模式,则从左到右匹配这些子模式。一旦有一个子模式匹配失败,整个匹配就失败;如果所有子模式都成功匹配,则整个类模式匹配成功。
- 如果存在按位置匹配的项,并且类具有
__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__
——“面对歧义,拒绝猜测的诱惑”。 - 如果存在按关键字匹配的项,则在主体上查找这些关键字作为属性。如果查找成功,则将值与相应的子模式进行匹配;如果查找失败,则匹配失败 。
这样的协议更注重实现的简单性,而不是灵活性和性能。有关其他考虑过的替代方案,请见“扩展匹配”部分。
作者:KLZZ66