在写出Python之前,Python是ABC编程语言的贡献者,这是一个为初学者设计编程环境持续了10年的研究项目。ABC引入大量现在称为Pythonic的概念:对不同类型序列的通用操作、内置元组和映射类型,缩进的代码结构、无变量声明的强类型等等。Python如此友好不是一蹴而就的。
Python继承了ABC中的序列统一处理。字符串、列表、字节序列、数组、XML及数据库结果共享大量的通用操作,包括迭代、切片、排序和拼接。
理解Python中大量的序列有避免我们重复造轮子,并且它们的通用接口对我们创建支持和使用现有及未来的序列类型的 API提供了宝贵的参考。
本文中的大部分适用于所有序列,从大家熟悉的list到Python 3中所新增的str和bytes类型。有关列表、元组、数组和队列的具体内容这里也会涉及,但Unicode字符串和字节序列的详细内容在本系列系列四中讲解。此处旨在讲解开箱即用的序列类型。如何创建自己的序列类型在第12篇文章中讲解。
本文的主要内容有:
- 列表推导式和生成器表达式的基础
- 以记录使用元组和以不可变列表使用元组
- 序列解包和序列模式
- 读取切片及写入切片
- 专有序列类型,如数组和队列
内置序列概述
标准库中提供了大量的以C语言实现的序列类型:
- 容器序列:可以存储不同类型的数据,包含内嵌容器,例如:
list、tuple和collections.deque - 扁平序列:存储某个简单类型。例如:
str,bytes和array.array。
容器序列中存储其内对象的引用,可以是任意类型,而扁平序列在自有内容空间中存储其内容的值,并不存储为独特Python对象。参见图2-1。
图2-1: 元组和数据的内存简化图表,每个3项。灰格表示每个 Python对象的内存头(未按比例绘制)。元组拥有一组对数据的引用。每一条数据为单个 Python 对象,可能存储的是对另一个 Python 对象的引用,如其中的列表。相对应的,Python 中的数组是单个对象,存储着3个 double 类型数据的 C 语言数组。
因上扁平序列更为紧凑,但仅能存储像字节、整数和浮点数这样的原始机器值。
内存中每个Python对象都有一个包含元数据的头。最简单的Python对象,float有一个数值字段和两个元数据字段:
ob_refcnt:对象的引用数ob_type:对象类型的指针ob_fval:存储浮点值的 C 语言double
Python 64位版本中,每个字段包含8个字节。这也是为什么浮点数组要比浮点元组更紧凑:数组是存储原始浮点值的单个对象,而元组只多个对象组成-元组自身及其所包含的各个float对象。
另一种是通过可变性对序列类型进行分组:
- 可变序列:如
list、bytearray、array.array和collections.deque - 不可变序列:如
tuple、str和bytes
图2-2有助于从视觉上看可变序列如何继承不可变序列的所有方法,以及实现额外的一些方法。内置的具体序列类型实际上并不是Sequence 和 MutableSequence抽象基类(abstract base class - ABC)的子类,但它们是通过这些抽象基类注册的虚拟子类,在系列十三中会讲解。作为虚拟子类,tuple 和 list可通过如下测试:
>>> from collections import abc
>>> issubclass(tuple, abc.Sequence)
True
>>> issubclass(list, abc.MutableSequence)
True
图2-2:collections.abc中一些类的简化UML图(父类在左侧,继承由箭头通过子类指向父类,斜体名称为抽象类和抽象方法)
记住这些共同特征:可变和不可变,容器和扁平。对于从一个序列类型推导至其它类型会很有帮助。
最基础的序列类型是list:一个可变容器。读者对列表应该很熟悉了,所以我们会直接讲解列表推导式,这是一种构建列表很强大的工具,但由于写法看起来有些怪导致有时用得过少。掌握列表推导式为我们打开生成器表达式的大门,后者可以生成填充其它类型序列的元素,当然不止这个。我们在下面一节中进行讨论。
列表推导式和生成器表达式
创建序列最快速的方式是列表推导式(针对list)或生成器表达式(针对其它类型的序列)。如果读者日常不使用这些语法形式,通常无法快速写出可读性高的代码。
如果对这种结构的“可读性”表示怀疑的话,请继续往下看。
为进行简化,很多Python程序员会使用listcomps来表述list comprehensions(列表推导式),genexps来表述generator expressions(生成器表达式)。
列表推导式和可读性
下面是一个测试:示例2-1和示例2-2哪个更易读?
示例2-1: 通过字符串构建一个Unicode代码点列表
>>> symbols = '$¢£¥€¤'
>>> codes = []
>>> for symbol in symbols:
... codes.append(ord(symbol))
...
>>> codes
[36, 162, 163, 165, 8364, 164]
示例2-2:使用列表推导式通过字符串构建一个Unicode代码点列表
>>> symbols = '$¢£¥€¤'
>>> codes = [ord(symbol) for symbol in symbols]
>>> codes
[36, 162, 163, 165, 8364, 164]
稍有些 Python基础的读者都可以读懂示例2-1。但在学习过列表推导式之后,我发现示例2-2的可读性更强,因为其内容更清晰。
for循环可用于完成很多任务:扫描序列进行计数或提取子项、聚合运算(求和、平均值)或其它一些什么位置。示例2-1中的代码在构建一个 列表。而列表推导式则更为显式。其作用就是构建新的列表。
当然也可能滥用列表推导式写出难懂的代码。我见过误用列表推导式来重复代码块的代码。如果对所生成的列表不做任务操作的话,那么不应该使用这种语法。同时应当保持简短。如果列表推导式超过了两行,最好把它拆开或使用普通的for循环进行我一定。程序员需要自行判断,Python和英语一样,没有书写的定式。
语法小贴士: Python代码中,会忽略[]、{}或()内的换行。因此可以通过不使用``转义符换行创建多行列表、列表推导式、元组、字典等,并且这种转义在不小心后面有空格时还会失效。同时,这些定界符用于定义逗号分隔子项序列字面量,结尾的逗号会被忽略。例如,在编写多行列表字面量时,在最后一项的后面加上逗号是一件贴心的事,因为其它程序员在添加新增项时会更为轻松,对于读取变化也会减少噪音。
列表推导式和生成器表达式中的本地作用域
在Python 3中,列表推导式、生成器表达式及相应的集合和字典推导式,在for语句中带有一个持有变量的局部作用域。
但是,通过海象运算符:=赋值的变量在这些推导式或表达式返回后仍可以访问,这点和函数的局部变量不同。PEP 572—赋值表达式定义:=对象的作用域为闭包函数,除非对该对象使用了global或nonlocal声明。
>>> x = 'ABC'
>>> codes = [ord(x) for x in x]
>>> x # x未销毁,仍指向'ABC'
'ABC'
>>> codes
[65, 66, 67]
>>> codes = [last := ord(c) for c in x]
>>> last # last 仍可用
67
>>> c # c已消失,它仅存在于列表推导式中
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'c' is not defined
列表推导式通过过滤、转化子项来从序列或其它可迭代类型创建列表。内置的filter和map也可以完成相同的任务,但它们的可读性很差,接下来我们会学习。
列表推导式 vs map 和 filter
列表推导式可以完成filter和map函数的所有任务,无需对功能复杂的 Python lambda做任何修改。参见示例2-3。
示例2-3:由列表推导式和 map/filter 组合所创建的相同列表
>>> symbols = '$¢£¥€¤'
>>> beyond_ascii = [ord(s) for s in symbols if ord(s) > 127]
>>> beyond_ascii
[162, 163, 165, 8364, 164]
>>> beyond_ascii = list(filter(lambda c: c > 127, map(ord, symbols)))
>>> beyond_ascii
[162, 163, 165, 8364, 164]
我曾经以为filter和map要比对应的列表推导式更快,但Alex Martelli 指出并非如此-至少上例中并非这样。《流畅的Python》代码仓库中的02-array-seq/listcomp_speed.py脚本是一个对比列表推导式和filter/map运行速度的简单示例。
在本系列第7篇中会进一步讲解filter和map。下面我们要使用列表推导式来计算笛卡尔积:包含由两个或多个列表所有子项所创建元组的列表。
笛卡尔积
列表推导式可以通过两个或多个可迭代对象的笛卡尔积创建列表。组成笛卡尔积的子项为由各个输入迭代对象所构造的元素。所产生的列表的长度与输入迭代对象长度的积相等。参见图2-3。
例如,设想需要生成一个有两种颜色、三个尺码的 T 恤 列表。示例2-4展示了如何使用列表推导式生成列表。结果为6项。
示例2-4:使用列表推导式得到的笛卡尔积
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> tshirts = [(color, size) for color in colors for size in sizes] # 生成一个由颜色及尺码组成的列表
>>> tshirts
[('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'),
('white', 'M'), ('white', 'L')]
>>> for color in colors: # 注意结果列表的排序就像按同样顺序嵌入了 for 循环一样
... for size in sizes:
... print((color, size))
...
('black', 'S')
('black', 'M')
('black', 'L')
('white', 'S')
('white', 'M')
('white', 'L')
>>> tshirts = [(color, size) for size in sizes # 要获取先按尺寸再按颜色排序的子项,只需调整顺序,在列表推导式中添加换行更易于知道结果的排序
... for color in colors]
>>> tshirts
[('black', 'S'), ('white', 'S'), ('black', 'M'), ('white', 'M'),
('black', 'L'), ('white', 'L')]
图2-3:三个大小、四种花色扑克牌的笛卡尔积是一个12组的序列
第一篇示例1-1中我们使用了如下表达式来初始化由4种花色每种13张组成的52张一副扑克牌,先按花色然后按大小排序:
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]
列表推导式的一招鲜就是创建列表。要生成其它序列类型的数据,可以使用生成器表达式。下一节我们简单地学习创建列表外序列的生成器表达式。
生成器表达式
初始化元组、数组及其它类型的序列,我们可以先使用列表推导式,但生成器表达式更节省内存,因为它使用迭代器协议逐一生成子项,而非构建整个列表来存放其它的构造序列。
生成器表达式和列表推导式的语法相同,但外面使用小括号而非中括号。
示例2-5展示了创建元组和数组的基本用法。
示例2-5:通过生成器表达式初始化元组和数组
>>> symbols = '$¢£¥€¤'
>>> tuple(ord(symbol) for symbol in symbols) # 如果生成器表达式是函数中的唯一参数,则无需添加两端的括号
(36, 162, 163, 165, 8364, 164)
>>> import array
>>> array.array('I', (ord(symbol) for symbol in symbols)) # 数组构造器接收两个参数,因此首尾的括号必须添加。其第一参数为数组中数字的存储类型,在数组一节中会讲解。
array('I', [36, 162, 163, 165, 8364, 164])
示例2-6使用生成器表达式计算笛卡尔积打印两种颜色三种尺码的一组 T 恤。对比示例2-4,这里T 恤列表的6项未在内存中创建:生成器表达式在for循环中逐一填充。如果笛卡尔积中的两个列表分别有1000弋矶山街道,使用生成器表达式会节约仅对for循环喂数据构建百万子项列表产生的开销。
示例2-6:生成器表达式笛卡尔积
>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> for tshirt in (f'{c} {s}' for c in colors for s in sizes): # 生成器表达式逐一生成子项,本例中并未生成包含所有6种 T 恤变体的列表
... print(tshirt)
...
black S
black M
black L
white S
white M
white L
注: 系列十七详细讲解生成器的运行原理。此外仅用于展示如何使用生成表达式初始化列表外的序列,或生成无需保存在内存中的内容。
接下来我们学习Python中的另一个基本序列类型:元组。
元组不只是个不可变列表
Python一些介绍文本中把元组描述为“不可变列表”,但这只是为推广的简述。元组有两重职责:可用作不可变列表,也可用作不带字段名的记录。这一用法有时会被人忽略,所以我们先介绍这点。
元组用作记录
元组中存储记录:元组中的每一项存储一个字段的数据,子项的位置表达其含义。
如果把元组仅看成是不可变列表,其子项的数量及排序根据使用场景可能是重要的或无关紧要。但把元组用作字段集合时,子项的数量通常是固定的,其排序也非常重要。
示例2-7展示了用作记录的元组。注意在每个表达式中,对元组排序会毁坏其信息,因为每个字段的含义由元组中的位置给定。
示例2-7:用作记录的元组
>>> lax_coordinates = (33.9425, -118.408056) # 洛杉矶国际机场的纬度和经度
>>> city, year, pop, chg, area = ('Tokyo', 2003, 32_450, 0.66, 8014) # 有关东京的数据:名称、看人、人口(千)、人口变化率(%)、面积(km²)
>>> traveler_ids = [('USA', '31195855'), ('BRA', 'CE342567'), # (country_code, passport_number)形式的元组列表
... ('ESP', 'XDA205856')]
>>> for passport in sorted(traveler_ids): # 对列表进行绑定,护照与每个元组相绑定
... print('%s/%s' % passport) # %格式化运算符能解析元组、将每一项看作单独的字段
...
BRA/CE342567
ESP/XDA205856
USA/31195855
>>> for country, _ in traveler_ids: # for循环知道如何单独获取元组中的各项,这称之为“解包”。这里我们不使用第二项,因此将其赋值给虚拟变量 _。
... print(country)
...
USA
BRA
ESP
小贴士:把_用作虚拟变量是一种惯例。虽然奇怪但它是一个有效的变量名。而在match/case语句中,_是不与值绑定匹配任意值的通配符。参见序列的模式匹配一节。在Python控制台中,前一条命令的结果在不为None时被赋值给_。
我们经常认为记录是带有具名字段的数据结构。系列五中会讲解两种创建带有具名字段的元组。
但通常无需为对字段命名创建一个类,尤其是使用解包并避免使用索引访问字段时。在示例2-7中,我们在单条语句中对city, year, pop, chg, area赋值('Tokyo', 2003, 32_450, 0.66, 8014)。然后%运算符将passport元组中的每项赋值给print参数中格式化字符串的对应位置。这两处都是元组解包的示例。
注:元组解包(tuple unpacking)被 Python 死忠粉们广泛使用,而迭代解包的说法却存在阻力,参见PEP 3132 — 扩展的迭代解包。
序列和可迭代解包中会讲解元组、序列以及常用可迭代对象的解包。
元组用作不可变列表
Python解释器和标准库大量地将元组用作不可变列表,读者也应该这么用。这会带来两大好处:
- 清晰性:在代码中看到元组时,就知道其长度不会改变。
- 性能:元组比同等长度的列表占用更少的内存,允许Python做一些优化。
但是,请注意元组的不可变性仅作用于其所包含的引用。元组中的引用无法删除或替换。但如果其中一个引用指向可变对象,该对象改变时,元组的值也发生了改变。以下的代码段通过创建两个一开始相等的元组a和b来讲解这点。图2-4表示元组b在内存中的初始布局。
b中的最后一项发生改变时,b和a就不相等了:
>>> a = (10, 'alpha', [1, 2])
>>> b = (10, 'alpha', [1, 2])
>>> a == b
True
>>> b[-1].append(99)
>>> a == b
False
>>> b
(10, 'alpha', [1, 2, 99])
图2-4: 元组本身的内容不可变,但这仅表示元组存储的引用会一直指向同一对象。但如果其中的引用对象可变,比如说是列表,那么内容也随之改变。
带有可变子项的元组可能会产生 bug。在什么是可哈希值一节中,我们会学到仅在值完全不会改变时对象才是可哈希的。不可哈希的元组是不能作为字典的键或集合(set)的元素的。
如果希望显示决定元组(或任意对象)的值是否固定,可认使用内置hash函数创建一个像下面这样的fixed函数:
>>> def fixed(o):
... try:
... hash(o)
... except TypeError:
... return False
... return True
...
>>> tf = (10, 'alpha', (1, 2))
>>> tm = (10, 'alpha', [1, 2])
>>> fixed(tf)
True
>>> fixed(tm)
False
我们会在未来的元组的相对可变性一节中进行更深入的探讨。
尽管存在这一问题,元组仍被广泛地用作不可变列表。Python的核心开发者Raymond Hettinger在对StackOverflow上的问题Python中的元组是否比列表更高效?的解答中说明了其性能上的一些优势。总结Hettinger所写的如下:
- 为运算元组字面量,Python编译器在运算中对元组常量生成了字节码,而对于列表字面量,所生成的字节码将每个常以单独的元素压入数据栈,然后再构建列表。
- 对于元组
t,tuple(t)仅会返回对同一的t引用。无需进行拷贝。而对于列表l,list(l)构造器必须新建一个l的拷贝。 - 因其定长,会对
tuple实例分配所需的精确内存空间。而list的实例则会分配多余的空间,用于缓解未来新增所带来的开销。 - 元组中子项的引用在元组结构体中心数组进行存储,而列表对引用数组的指针却存储在其它位置。这种间接性是因为列表在超过当前所分配的空间时,Python会需要重新分配引用的数组来添加空间。额外的间接引用会让CPU缓存更为低效。
比对元组和列表方法
在将元组用作列表的不可变替代品时,最好知道其API的相似性。参见表2-1,元组支持所有不涉及添加或删除子项的列表方法,有一个例外,那就是元组没有__reversed__方法。但这是出于优化目的,reversed(my_tuple)不需要使用它。
表2-1: 列表或元组中的方法及属性(为进行简化省略了对象实现的方法)
| 列表 | 元组 | ||
|---|---|---|---|
| s.add(s2) | ● | ● | s + s2—拼接 |
| s.iadd(s2) | ● | s += s2—原地拼接 | |
| s.append(e) | ● | 在最后添加一个元素 | |
| s.clear() | ● | 删除所有项 | |
| s.contains(e) | ● | ● | e in s |
| s.copy() | ● | 列表的浅拷贝 | |
| s.count(e) | ● | ● | 元素出现次数 |
| s.delitem(p) | ● | 在位置p删除子项 | |
| s.extend(it) | ● | 通过可迭代对象it添加子项 | |
| s.getitem(p) | ● | ● | s[p]—获取指定位置子项 |
| s.getnewargs() | ● | 支持通过pickle的序列化优化 | |
| s.index(e) | ● | ● | 查找e第一次出现的位置 |
| s.insert(p, e) | ● | 在位置p的子项前插入元素e | |
| s.iter() | ● | ● | 获取迭代器 |
| s.len() | ● | ● | len(s)—子项的数量 |
| s.mul(n) | ● | ● | s * n—反复拼接 |
| s.imul(n) | ● | s *= n—原地反复拼接 | |
| s.rmul(n) | ● | ● | n * s—反向反复拼接 |
| s.pop([p]) | ● | 删除并返回最后一项或可选位置 p的子项 | |
| s.remove(e) | ● | 通过值删除第一次出现的元素e | |
| s.reverse() | ● | 原地对各子项进行反向排序 | |
| s.reversed() | ● | 获取迭代器从最后到第一个扫描子项 | |
| s.setitem(p, e) | ● | s[p] = e—将 e放到位置p,重写已有子项,也用作重写子序列 | |
| s.sort([key], [reverse]) | ● | 通过可选关键字key 和 reverse对子项进行原地排序 |
注:反向运算符在系列十六中讲解
下面我们切换到有关 Python编程常用的一个重要话题:元组、列表和迭代解包。
序列和迭代解包
解包非常重要,因为在从序列中提取元素时它可以避免不必要及易于出错的索引。解包可将任意可迭代对象用作数据源,包含那些不支持索引符号[]的迭代器。唯一的要求是可迭代对象对每个接收端变量仅产生一个子项,除非按照使用*获取多余子项一节那样使用星号(*)获取多余子项。
最易查看的解包是并行赋值,即将可迭代对象的子项赋值给一组变量,可参见下例:
>>> lax_coordinates = (33.9425, -118.408056)
>>> latitude, longitude = lax_coordinates # unpacking
>>> latitude
33.9425
>>> longitude
-118.408056
解包的一种优雅应用是不使用临时变量实现值变量值的互换:
>>> b, a = a, b
另一个解包的示例是调用函数时在参数前加*:
>>> divmod(20, 8)
(2, 4)
>>> t = (20, 8)
>>> divmod(*t)
(2, 4)
>>> quotient, remainder = divmod(*t)
>>> quotient, remainder
(2, 4)
以上代码展示了解包的另一种应用:允许函数返回多个值方便调用者使用。另一个例子,os.path.split()函数通过文件路径构建一个元组(path, last_part):
>>> import os
>>> _, filename = os.path.split('/home/luciano/.ssh/id_rsa.pub')
>>> filename
'id_rsa.pub'
还有一种方式使用*语法来在解包时仅使用其中的一些子项,一会儿我们就会学到。
使用*获取多余子项
通过*args定义参数来获取自定义的额外参数是Python一种经典特性。
在Python 3中,这一做法被扩展到了并行赋值中:
>>> a, b, *rest = range(5)
>>> a, b, rest
(0, 1, [2, 3, 4])
>>> a, b, *rest = range(3)
>>> a, b, rest
(0, 1, [2])
>>> a, b, *rest = range(2)
>>> a, b, rest
(0, 1, [])
在并行赋值的场景中,*前缀可用于具体的某个变量,但可以放在任意位置:
>>> a, *body, c, d = range(5)
>>> a, body, c, d
(0, [1, 2], 3, 4)
>>> *head, b, c, d = range(5)
>>> head, b, c, d
([0, 1], 2, 3, 4)
在函数调用和序列字面量中使用*解包
PEP 448—其它解包总结中为迭代解包引入更灵活的语法,在Python 3.5新增中进行了很好的总结 。
在函数调用中,我们可以多次使用*:
>>> def fun(a, b, c, d, *rest):
... return a, b, c, d, rest
...
>>> fun(*[1, 2], 3, *range(4, 7))
(1, 2, 3, 4, (5, 6))
*也可在定义list、tuple或set字面量时使用,参见Python 3.5新增中的这些示例:
>>> *range(4), 4
(0, 1, 2, 3, 4)
>>> [*range(4), 4]
[0, 1, 2, 3, 4]
>>> {*range(4), 4, *(5, 6, 7)}
{0, 1, 2, 3, 4, 5, 6, 7}
PEP 448为**引入了类似的新语法,我们在映射解包中会进行学习。
最后,元组解包的一个强大特性是其可用于嵌套结构。
嵌套解包
解包可用于嵌套,即(a, b, (c, d))。如果值有相同的嵌套结构Python会执行相应操作。示例2-8演示了嵌套解包。
示例2-8: 解包嵌套元组获取经度
metro_areas = [
('Tokyo', 'JP', 36.933, (35.689722, 139.691667)), # 每个元组存储带4个字段的记录,最后一个是坐标对
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]
def main():
print(f'{"":15} | {"latitude":>9} | {"longitude":>9}')
for name, _, _, (lat, lon) in metro_areas: # 通过将最一个字段赋值给嵌套元组,我们解包了坐标
if lon <= 0: # lon <= 0 仅选取西半球的城市
print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')
if __name__ == '__main__':
main()
示例2-8的输出结果为:
| latitude | longitude
Mexico City | 19.4333 | -99.1333
New York-Newark | 40.8086 | -74.0204
São Paulo | -23.5478 | -46.6358
解包赋值也给用于列表,但几乎没有好的应用场景。我只知道一个,如果有数据库查询返回一条记录(如带有LIMIT 1语句的SQL查询),那么可以使用下面的代码在解包的同时确保仅有一条结果:
>>> [record] = query_returning_single_row()
如果记录仅有一个字段,可以像这样直接获取:
>>> [[field]] = query_returning_single_row_with_single_field()
这两种都可以使用元组编写,但别忘记单元素元组那个奇怪的写法:懒得做在最后加一个逗号。所以第一个应使用(record,),第二个使用((field,),)。两个例子中如果没加逗号的话会默默地产生 bug。
接下来要学习模式匹配了,它支持更为强大的序列解包方法。
序列的模式匹配
Python 3.10中最明显的新特性就是PEP 634—结构化模式匹配:规范中提议的match/case语句的模式匹配。
注: Python核心开发者Carol Willing在Python 3.10新增的结构化模式匹配一节有关于模式匹配非常好的介绍。可以阅读一下快速了解。本系列中选择将模式匹配按模式类型分散至不同文章中:映射的模式匹配、类实例的模式匹配。扩展的示例参见系列十八中的lis.py中的模式匹配:案例研究。
这里是match/case处理序列的第一个示例。假设 你在设计一个机器人,可以接收以单词和数字序列发送的命令,如BEEPER 440 3。在分割解析数字后,得到['BEEPER', 440, 3]这样的消息。可以使用这样的方法来处理这些消息:
示例2-9
def handle_command(self, message):
match message: # match关键字后的表达式是主体。主体为Python在每个case语句中尝试进行模式匹配的数据。
case ['BEEPER', frequency, times]: # 这一模式匹配任意为3个子项序列的主体。第一项必须为字符串'BEEPER'。第二、三项可为任意值,并且会按顺序绑定变量frequency和times
self.beep(times, frequency)
case ['NECK', angle]: # 它会匹配任意有两个子项且第一个为'NECK'的主体
self.rotate_neck(angle)
case ['LED', ident, intensity]: #这会匹配以'LED'开头带三个子项的主体。如果子项数量不匹配,Python会推进到下一个case语句。
self.leds[ident].set_brightness(ident, intensity)
case ['LED', ident, red, green, blue]: # 又一个以'LED'开头的序列模式, 这里是5个子项(含常量)
self.leds[ident].set_color(ident, red, green, blue)
case _: # 这是默认的case。它会匹配前面模式无法匹配的任意主体。_ 是一个特殊变量,稍后会学到
raise InvalidCommand(message)
表面上match/case和C 语言中的switch/case相似,但这仅是片面的理解。match相对switch的一个关键改进是解构-比解包更高级的形式。解构在Python是属于新增的概念,但对于支持模式匹配的语言,如Scala和Elixir,文档中很常见。
示例2-10为解构的第一个示例,它对示例2-8的部分代码使用match/case进行了重写。
示例2-10: 解构嵌套元组,要求使用Python ≥ 3.10
metro_areas = [
('Tokyo', 'JP', 36.933, (35.689722, 139.691667)),
('Delhi NCR', 'IN', 21.935, (28.613889, 77.208889)),
('Mexico City', 'MX', 20.142, (19.433333, -99.133333)),
('New York-Newark', 'US', 20.104, (40.808611, -74.020386)),
('São Paulo', 'BR', 19.649, (-23.547778, -46.635833)),
]
def main():
print(f'{"":15} | {"latitude":>9} | {"longitude":>9}')
for record in metro_areas:
match record: # 这个match的主体是record—即metro_areas中的每个元组
case [name, _, _, (lat, lon)] if lon <= 0: # case语句有两部分:模式和带 if 关键词的可选守卫
print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')
通常来说,在主体满足以下条件时匹配序列模式:
- 主体为序列且:
- 主体和序列元素数量致且:
- 每个子项含嵌套子项相匹配
例如,示例2-10中的[name, _, _, (lat, lon)]模式匹配具有4个子项的序列,最后一项必须为二元序列。
序列模式可为元组或列表或嵌套元组及列表的任意组合,但使用的语法并有任何分别:模式中元组和列表匹配任意序列。在示例2-10中使用带有二元元组的列表模式只是为了避免重复的中括号或小括号。
序列模式可以匹配collections.abc.Sequence的大多数子类或虚拟子类,除str、bytes和bytearray。
警告:str、bytes和bytearray的实例在match/case中不按序列进行处理。这些类型的match主体被看成原子化的值,就像整数987会被看成一个值,而非数字序列。把这三种类型看成序列会因未预期的匹配而道理 bug。如果希望将这些类型的对象看作序列主体,将其转化为匹配语句。例如参见下面的tuple(phone):
match tuple(phone):
case ['1', *rest]: # 北美及加勒比
...
case ['2', *rest]: # 非洲及部分领地
...
case ['3' | '4', *rest]: # 欧洲
...
在标准库中,以下类型与序列模式相匹配:
list memoryview array.array
tuple range collections.deque
不同于解包,模式不解构非序列的迭代对象(如迭代器)。
模式中的_符号很特殊:它匹配该处的任意单项,但不绑定匹配项的值。_是唯一能在模式中多次出现的变量。
可以使用as关键字通过变量绑定模式的任意部分:
case [name, _, _, (lat, lon) as coord]:
假定主体为['Shanghai', 'CN', 24.9, (31.1, 121.3)],以上的模式会匹配,并设置如下变量:
-
name'Shanghai' -
lat31.1 -
lon121.3 -
coord(31.1, 121.3)
我们可以通过添加类型信息让模式更为具体。例如,以下模式匹配上例中相同的嵌套序列结构,但第一项必须为str的实例,并且二元元组的两项必须为float实例。
case [str(name), _, _, (float(lat), float(lon))]:
小贴士: str(name)和float(lat)表达式像是构造调用,但在模式匹配中,这种语句用作运行时类型检查:以上的模式匹配带有4项的序列,第0项必须为str,第3项必须为一对浮点数。此外,第0项中的str会绑定到name对象上,第3项的浮点值会分别绑定到lat和lon上。因此虽然str(name)借用了构造调用的语法,在模式匹配中其语义完全不同。在模式中使用自定义的内容在系列五的模式匹配类实例一节中讲解。
另外,如果我们希望匹配以str开头,并以两个浮点值的嵌套序列结尾的主体,可以这么写:
case [str(name), *_, (float(lat), float(lon))]:
*_匹配任意数量的子项,无需绑定变量。使用*extra替换*_会将这些子项绑定到一个有0项或多项的列表extra中。
可选的以if开头的守卫语句仅在模式匹配时运行,并可使用模式中绑定的变量,如在示例2-10中:
match record:
case [name, _, _, (lat, lon)] if lon <= 0:
print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')
带有print语句的嵌套代码块仅在模式匹配且守卫表达式为真时运行。
小贴士: 运用模式的解构极具表达力,有时带有单个case的match会让代码简化。龟叔有一组case/match示例,其中有一个题为深度迭代和类型匹配提取。
示例2-10不是对示例2-8的改进。仅是对执行相关操作时两种方法的对比。下面的例子展示模式匹配如何让代码更清晰、简洁、高效。
解释器中的模式匹配序列
斯坦福大学的Peter Norvig编写了lis.py:一个以132行优雅、易读Python代码编写的针对Lisp编程语言Scheme方言子集的解释器。这里取了Norvig的 MIT 协议授权的代码并将其更新为Python 3.10,用于展示模式匹配。本节中,我们将Norvig一段使用了if/elif和解包的关键代码与使用match/case重写的版本进行对比。
lis.py 的两个主要函数为parse和evaluate。解析器接收Scheme括号表达式,返回Python列表,以下是两个示例:
>>> parse('(gcd 18 45)')
['gcd', 18, 45]
>>> parse('''
... (define double
... (lambda (n)
... (* n 2)))
... ''')
['define', 'double', ['lambda', ['n'], ['*', 'n', 2]]]
求值程序接收这样的列表并执行。第一个示例以18和45为参数调用gcd函数。运行后计算出它们的最大公约数:9。第二个示例定义了一个带有参数n的函数double。函数体是一个表达式(* n 2)。在Scheme中调用函数的结果是函数体最后一个表达式的值。
这里的关注点是解构序列,因此不会解释求值程序的操作。参见系列十八中的lis.py中的模式匹配:案例研究一节学习有关lis.py如何运行的更多详情。
以下是对Norvig求值程序进行了微调,简化仅显示序列模式部分:
示例2-11: 不使用match/case的模式匹配
def evaluate(exp: Expression, env: Environment) -> Any:
"Evaluate an expression in an environment."
if isinstance(exp, Symbol): # variable reference
return env[exp]
# ... lines omitted
elif exp[0] == 'quote': # (quote exp)
(_, x) = exp
return x
elif exp[0] == 'if': # (if test conseq alt)
(_, test, consequence, alternative) = exp
if evaluate(test, env):
return evaluate(consequence, env)
else:
return evaluate(alternative, env)
elif exp[0] == 'lambda': # (lambda (parm…) body…)
(_, parms, *body) = exp
return Procedure(parms, body, env)
elif exp[0] == 'define':
(_, name, value_exp) = exp
env[name] = evaluate(value_exp, env)
# ... more lines omitted
注意各个elif语句是如何检测列表第一项然后对列表解包并忽略第一项的。大量地使用了解包表明Norvig忠爱模式匹配,但他最初是用Python 2编写(虽然现在与Python 3兼容)。
在Python ≥ 3.10中使用match/case,我们可以这样重构evaluate:
示例2-12: 使用match/case的模式匹配,要求Python ≥ 3.10
def evaluate(exp: Expression, env: Environment) -> Any:
"Evaluate an expression in an environment."
match exp:
# ... lines omitted
case ['quote', x]: # 匹配主体是否为以'quote'开头的二元序列
return x
case ['if', test, consequence, alternative]: # 匹配主体是否为以'if'开头的四元序列
if evaluate(test, env):
return evaluate(consequence, env)
else:
return evaluate(alternative, env)
case ['lambda', [*parms], *body] if body: # 匹配主体是否为三元序列或以'lambda'开头的多元序列。守卫确保了body不为空。
return Procedure(parms, body, env)
case ['define', Symbol() as name, value_exp]: # 匹配主体是否为以'define'开头的三元序列,包含Symbol的实例
env[name] = evaluate(value_exp, env)
# ... more lines omitted
case _: # 有一个兜底case是良好实践。本例中,如果exp不匹配所有模式,表达式格式错误,抛出SyntaxError。
raise SyntaxError(lispstr(exp))
不进行兜底的话,在主体不匹配所有case时,整个match语句什么也不做,这是一种静默失败。
Norvig在lis.py中刻意避免错误检查,以保持代码更易于理解 。通过模式匹配,我们可以添加更多的检测,同时仍易于阅读。例如,在'define'模式中,原来的代码无法保证name是Symbol的实例:那会要求有if代码块、一个isinstance调用以及更多的代码一。示例2-12较示例2-11更简短、更安全。
lambda的替代模式
这是Scheme中的lambda语句,使用了后缀…用于表示元素可能出现一次或多次:
(lambda (parms…) body1 body2…)
'lambda'的一个简单模式可以是:
case ['lambda', parms, *body] if body:
但这会匹配parms处的任意值,包含无效主体中的第一个'x':
['lambda', 'x', ['*', 'x', 2]]
Scheme中lambda关键词后的嵌套列表存储了函数的正式参数名称,即使仅有一个元素也必须是列表。如果函数无参数,类似Python中的random.random(),可以为空列表。
示例2-12中使用了嵌套序列模式来让'lambda'模式更为安全:
case ['lambda', [*parms], *body] if body:
return Procedure(parms, body, env)
在序列模式中,*在每个序列中仅能出现一次。这里有两个序列:外层序列和内层序列。
在parms周围添加[*]可以让模式看起来更像Scheme所处理的语句,并增加了结构检查。
函数定义的短语法
Scheme有一种替代define语法用于不使用嵌套lambda创建具名函数。语句如下:
(define (name parm…) body1 body2…)
define关键词后接新函数名称及0个或多个参数名的列表。其后为有一条或多条表达式的函数体。
在match中添加如下两行可以处理这种实现:
case ['define', [Symbol() as name, *parms], *body] if body:
env[name] = Procedure(parms, body, env)
把这段case语句放到示例2-12中的define case之后。本例中define case的顺序不重要,因为没有主体可以同时匹配这两种模式:原来的define case中第二个元素必须为Symbol,便在函数定义短语法中必须为一个以Symbol开头的序列。
现在试想一下在示例2-11中不借助模拟匹配需要多少工作来添加对第二个define语法的支持。match比类 C语言中的switch语句完成的任务更多。
模式匹配是声明式编程的一个示例:代码描述想要匹配“什么”,而不是“如何”匹配。代码的形状遵循数据的形状,如表2-2所示。
表2-2:一些Scheme语句形式和处理它们的case模式
| Scheme语句 | 序列模式 |
|---|---|
| (quote exp) | ['quote', exp] |
| (if test conseq alt) | ['if', test, conseq, alt] |
| (lambda (parms…) body1 body2…) | ['lambda', [*parms], *body] if body |
| (define name exp) | ['define', Symbol() as name, exp] |
| (define (name parms…) body1 body2…) | ['define', [Symbol() as name, *parms], *body] if body |
希望使用模式匹配对Norvig的evaluate进行重构能够让读者理解match/case可使用代码更易读、更安全。
注:我们会在系列十八中的lis.py中的模式匹配:案例研究一节中复习evaluate中完整的match/case示例时更深入地讲解lis.py 。如果想要学习Norvig的lis.py,请阅读他所写的文章如何用 Python 编写 Lisp 解释器。
至此完成了解包、解构、通过序列进行模式匹配的首站。我们在后续文章中会讲解其它类型的模式。