在写出Python之前,Python是ABC编程语言的贡献者,这是一个为初学者设计编程环境持续了10年的研究项目。ABC引入大量现在称为Pythonic的概念:对不同类型序列的通用操作、内置元组和映射类型,缩进的代码结构、无变量声明的强类型等等。Python如此友好不是一蹴而就的。
Python继承了ABC中的序列统一处理。字符串、列表、字节序列、数组、XML及数据库结果共享大量的通用操作,包括迭代、切片、排序和拼接。
理解Python中大量的序列有避免我们重复造轮子,并且它们的通用接口对我们创建支持和使用现有及未来的序列类型的 API提供了宝贵的参考。
本文中的大部分适用于所有序列,从大家熟悉的list
到Python 3中所新增的str
和bytes
类型。有关列表、元组、数组和队列的具体内容这里也会涉及,但Unicode字符串和字节序列的详细内容在本系列系列四中讲解。此处旨在讲解开箱即用的序列类型。如何创建自己的序列类型在第12篇文章中讲解。
更多 Python 教程请见 GitHub 仓库
本文的主要内容有:
- 列表推导式和生成器表达式的基础
- 以记录使用元组和以不可变列表使用元组
- 序列解包和序列模式
- 读取切片及写入切片
- 专有序列类型,如数组和队列
内置序列概述
标准库中提供了大量的以C语言实现的序列类型:
- 容器序列:可以存储不同类型的数据,包含内嵌容器,例如:
list
、tuple
和collections.deque
- 扁平序列:存储某个简单类型。例如:
str
,bytes
和array.array
。
容器序列中存储其内对象的引用,可以是任意类型,而扁平序列在自有内容空间中存储其内容的值,并不存储为独特Python对象。参见图2-1。
图2-1:元组和数据的内存简化图表,每个3项。灰格表示每个 Python对象的内存头(未按比例绘制)。元组拥有一组对数据的引用。每一条数据为单个 Python 对象,可能存储的是对另一个 Python 对象的引用,如其中的列表。相对应的,Python 中的数组是单个对象,存储着3个 double 类型数据的 C 语言数组。
因上扁平序列更为紧凑,但仅能存储像字节、整数和浮点数这样的原始机器值。
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
可通过如下测试:
1 2 3 4 5 |
>>> from collections import abc >>> issubclass(tuple, abc.Sequence) True >>> issubclass(list, abc.MutableSequence) True |
图2-2:collections.abc中一些类的简化UML图(父类在左侧,继承由箭头通过子类指向父类,斜体名称为抽象类和抽象方法)
记住这些共同特征:可变和不可变,容器和扁平。对于从一个序列类型推导至其它类型会很有帮助。
最基础的序列类型是list
:一个可变容器。读者对列表应该很熟悉了,所以我们会直接讲解列表推导式,这是一种构建列表很强大的工具,但由于写法看起来有些怪导致有时用得过少。掌握列表推导式为我们打开生成器表达式的大门,后者可以生成填充其它类型序列的元素,当然不止这个。我们在下面一节中进行讨论。
列表推导式和生成器表达式
创建序列最快速的方式是列表推导式(针对list
)或生成器表达式(针对其它类型的序列)。如果读者日常不使用这些语法形式,通常无法快速写出可读性高的代码。
如果对这种结构的“可读性”表示怀疑的话,请继续往下看。
列表推导式和可读性
下面是一个测试:示例2-1和示例2-2哪个更易读?
示例2-1:通过字符串构建一个Unicode代码点列表
1 2 3 4 5 6 7 |
>>> symbols = '$¢£¥€¤' >>> codes = [] >>> for symbol in symbols: ... codes.append(ord(symbol)) ... >>> codes [36, 162, 163, 165, 8364, 164] |
示例2-2:使用列表推导式通过字符串构建一个Unicode代码点列表
1 2 3 4 |
>>> 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 3中,列表推导式、生成器表达式及相应的集合和字典推导式,在for
语句中带有一个持有变量的局部作用域。
但是,通过海象运算符:=
赋值的变量在这些推导式或表达式返回后仍可以访问,这点和函数的局部变量不同。PEP 572—赋值表达式定义:=
对象的作用域为闭包函数,除非对该对象使用了global
或nonlocal
声明。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
>>> 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 组合所创建的相同列表
1 2 3 4 5 6 7 |
>>> 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:使用列表推导式得到的笛卡尔积
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
>>> 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张一副扑克牌,先按花色然后按大小排序:
1 2 |
self._cards = [Card(rank, suit) for suit in self.suits for rank in self.ranks] |
列表推导式的一招鲜就是创建列表。要生成其它序列类型的数据,可以使用生成器表达式。下一节我们简单地学习创建列表外序列的生成器表达式。
生成器表达式
初始化元组、数组及其它类型的序列,我们可以先使用列表推导式,但生成器表达式更节省内存,因为它使用迭代器协议逐一生成子项,而非构建整个列表来存放其它的构造序列。
生成器表达式和列表推导式的语法相同,但外面使用小括号而非中括号。
示例2-5展示了创建元组和数组的基本用法。
示例2-5:通过生成器表达式初始化元组和数组
1 2 3 4 5 6 |
>>> 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:生成器表达式笛卡尔积
1 2 3 4 5 6 7 8 9 10 11 |
>>> 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:用作记录的元组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
>>> 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
参数中格式化字符串的对应位置。这两处都是元组解包的示例。
序列和可迭代解包中会讲解元组、序列以及常用可迭代对象的解包。
元组用作不可变列表
Python解释器和标准库大量地将元组用作不可变列表,读者也应该这么用。这会带来两大好处:
- 清晰性:在代码中看到元组时,就知道其长度不会改变。
- 性能:元组比同等长度的列表占用更少的内存,允许Python做一些优化。
但是,请注意元组的不可变性仅作用于其所包含的引用。元组中的引用无法删除或替换。但如果其中一个引用指向可变对象,该对象改变时,元组的值也发生了改变。以下的代码段通过创建两个一开始相等的元组a
和b
来讲解这点。图2-4表示元组b
在内存中的初始布局。
b
中的最后一项发生改变时,b
和a
就不相等了:
1 2 3 4 5 6 7 8 9 |
>>> 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
函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
>>> 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编程常用的一个重要话题:元组、列表和迭代解包。
序列和迭代解包
解包非常重要,因为在从序列中提取元素时它可以避免不必要及易于出错的索引。解包可将任意可迭代对象用作数据源,包含那些不支持索引符号[]
的迭代器。唯一的要求是可迭代对象对每个接收端变量仅产生一个子项,除非按照使用*获取多余子项一节那样使用星号(*
)获取多余子项。
最易查看的解包是并行赋值,即将可迭代对象的子项赋值给一组变量,可参见下例:
1 2 3 4 5 6 |
>>> lax_coordinates = (33.9425, -118.408056) >>> latitude, longitude = lax_coordinates # unpacking >>> latitude 33.9425 >>> longitude -118.408056 |
解包的一种优雅应用是不使用临时变量实现值变量值的互换:
1 |
>>> b, a = a, b |
另一个解包的示例是调用函数时在参数前加*
:
1 2 3 4 5 6 7 8 |
>>> 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)
:
1 2 3 4 |
>>> import os >>> _, filename = os.path.split('/home/luciano/.ssh/id_rsa.pub') >>> filename 'id_rsa.pub' |
还有一种方式使用*
语法来在解包时仅使用其中的一些子项,一会儿我们就会学到。
使用*获取多余子项
通过*args
定义参数来获取自定义的额外参数是Python一种经典特性。
在Python 3中,这一做法被扩展到了并行赋值中:
1 2 3 4 5 6 7 8 9 |
>>> 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, []) |
在并行赋值的场景中,*
前缀可用于具体的某个变量,但可以放在任意位置:
1 2 3 4 5 6 |
>>> 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新增中进行了很好的总结 。
在函数调用中,我们可以多次使用*
:
1 2 3 4 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新增中的这些示例:
1 2 3 4 5 6 |
>>> *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:解包嵌套元组获取经度
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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的输出结果为:
1 2 3 4 |
| 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查询),那么可以使用下面的代码在解包的同时确保仅有一条结果:
1 |
>>> [record] = query_returning_single_row() |
如果记录仅有一个字段,可以像这样直接获取:
1 |
>>> [[field]] = query_returning_single_row_with_single_field() |
这两种都可以使用元组编写,但别忘记单元素元组那个奇怪的写法:懒得做在最后加一个逗号。所以第一个应使用(record,)
,第二个使用((field,),)
。两个例子中如果没加逗号的话会默默地产生 bug。
接下来要学习模式匹配了,它支持更为强大的序列解包方法。
序列的模式匹配
Python 3.10中最明显的新特性就是PEP 634—结构化模式匹配:规范中提议的match/case
语句的模式匹配。
这里是match/case
处理序列的第一个示例。假设 你在设计一个机器人,可以接收以单词和数字序列发送的命令,如BEEPER 440 3
。在分割解析数字后,得到['BEEPER', 440, 3]
这样的消息。可以使用这样的方法来处理这些消息:
示例2-9
1 2 3 4 5 6 7 8 9 10 11 12 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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)
:
1 2 3 4 5 6 7 |
match tuple(phone): case ['1', *rest]: # 北美及加勒比 ... case ['2', *rest]: # 非洲及部分领地 ... case ['3' | '4', *rest]: # 欧洲 ... |
在标准库中,以下类型与序列模式相匹配:
1 2 |
list memoryview array.array tuple range collections.deque |
不同于解包,模式不解构非序列的迭代对象(如迭代器)。
模式中的_
符号很特殊:它匹配该处的任意单项,但不绑定匹配项的值。_
是唯一能在模式中多次出现的变量。
可以使用as
关键字通过变量绑定模式的任意部分:
1 |
case [name, _, _, (lat, lon) as coord]: |
假定主体为['Shanghai', 'CN', 24.9, (31.1, 121.3)]
,以上的模式会匹配,并设置如下变量:
name
'Shanghai'
lat
31.1
lon
121.3
coord
(31.1, 121.3)
我们可以通过添加类型信息让模式更为具体。例如,以下模式匹配上例中相同的嵌套序列结构,但第一项必须为str
的实例,并且二元元组的两项必须为float
实例。
1 |
case [str(name), _, _, (float(lat), float(lon))]: |
str(name)
和float(lat)
表达式像是构造调用,但在模式匹配中,这种语句用作运行时类型检查:以上的模式匹配带有4项的序列,第0项必须为str
,第3项必须为一对浮点数。此外,第0项中的str
会绑定到name
对象上,第3项的浮点值会分别绑定到lat
和lon
上。因此虽然str(name)
借用了构造调用的语法,在模式匹配中其语义完全不同。在模式中使用自定义的内容在系列五的模式匹配类实例一节中讲解。另外,如果我们希望匹配以str
开头,并以两个浮点值的嵌套序列结尾的主体,可以这么写:
1 |
case [str(name), *_, (float(lat), float(lon))]: |
*_
匹配任意数量的子项,无需绑定变量。使用*extra
替换*_
会将这些子项绑定到一个有0项或多项的列表extra
中。
可选的以if
开头的守卫语句仅在模式匹配时运行,并可使用模式中绑定的变量,如在示例2-10中:
1 2 3 |
match record: case [name, _, _, (lat, lon)] if lon <= 0: print(f'{name:15} | {lat:9.4f} | {lon:9.4f}') |
带有print
语句的嵌套代码块仅在模式匹配且守卫表达式为真时运行。
示例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列表,以下是两个示例:
1 2 3 4 5 6 7 8 |
>>> 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
的模式匹配
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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
语句,使用了后缀…
用于表示元素可能出现一次或多次:
1 |
(lambda (parms…) body1 body2…) |
'lambda'
的一个简单模式可以是:
1 |
case ['lambda', parms, *body] if body: |
但这会匹配parms
处的任意值,包含无效主体中的第一个'x'
:
1 |
['lambda', 'x', ['*', 'x', 2]] |
Scheme中lambda
关键词后的嵌套列表存储了函数的正式参数名称,即使仅有一个元素也必须是列表。如果函数无参数,类似Python中的random.random()
,可以为空列表。
示例2-12中使用了嵌套序列模式来让'lambda'
模式更为安全:
1 2 |
case ['lambda', [*parms], *body] if body: return Procedure(parms, body, env) |
在序列模式中,*
在每个序列中仅能出现一次。这里有两个序列:外层序列和内层序列。
在parms
周围添加[*]
可以让模式看起来更像Scheme所处理的语句,并增加了结构检查。
函数定义的短语法
Scheme有一种替代define
语法用于不使用嵌套lambda
创建具名函数。语句如下:
1 |
(define (name parm…) body1 body2…) |
define
关键词后接新函数名称及0个或多个参数名的列表。其后为有一条或多条表达式的函数体。
在match
中添加如下两行可以处理这种实现:
1 2 |
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
可使用代码更易读、更安全。
evaluate
中完整的match/case
示例时更深入地讲解lis.py 。如果想要学习Norvig的lis.py,请阅读他所写的文章如何用 Python 编写 Lisp 解释器。至此完成了解包、解构、通过序列进行模式匹配的首站。我们在后续文章中会讲解其它类型的模式。
每个Python程序都知道序列可以使用s[a:b]
语句来进行切片。接下来我们就学习切片一些鲜为人知的知识。
切片
list
、tuple
、str
以及其它序列类型的具有一个共同的功能,那就是对切片运算的支持,这一功能比很多人所认知的要更为强大。
本节我们讲解切片高级形式的用法。其在自定义类中的实现将在系列十二中进行讲解。
为何切片和 range 或排除最后一项
切片和 range排除最后一项这一Pythonic惯例适用于Python、C和其它以0为初始索引的编程语言。这一惯例提供下述便利:
- 在仅提供结束位置时易于查看切片或range的长度:
range(3)
和my_list[:3]
都生成三项。 - 在提供了开始(start)和结束(stop)位置时易于计算切片或range的长度:只需使用
stop - start
。 - 易于在任意索引
x
处将序列分成两部分而不会存在重叠:即my_list[:x]
和my_list[x:]
。例如:
123456789>>> l = [10, 20, 30, 40, 50, 60]>>> l[:2] # split at 2[10, 20]>>> l[2:][30, 40, 50, 60]>>> l[:3] # split at 3[10, 20, 30]>>> l[3:][40, 50, 60]
关于这一惯例的最佳论证由荷兰计算机科学家Edsger W. Dijkstra所写(参见扩展阅读部分)。
下面我们来仔细学习Python是如何解析切片标记的。
切片对象
这可能已经是常识,但有必要重复说明下:s[a:b:c]
可用于指定步长c
,使用得结果切片跳过指定数量的元素。步长也可为负数,此时逆向返回各元素。举三个例子来说明:
1 2 3 4 5 6 7 |
>>> s = 'bicycle' >>> s[::3] 'bye' >>> s[::-1] 'elcycib' >>> s[::-2] 'eccb' |
还有一个示例是在系列一中我们使用了deck[12::13]
在尚未洗牌时获取所有的“老A”:
1 2 3 |
>>> deck[12::13] [Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'), Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')] |
a:b:c
这种写法仅在索引或下标运算时放在[]
中有效,产生一个切片对象slice(a, b, c)
。在系列十二的切片如何运行中会讲到,运算seq[start:stop:step]
表达式,Python会调用seq.__getitem__(slice(start, stop, step))
。即使不自己实现序列类型,了解切片对象也是有益的,因为这样我们可以对切片命名,就像是在 Excel 中对一组单元格命名。
假设需求解析例2-13中所示这种普通文本数据发票。我们可以不在代码中对切片进行硬编码,而是对其进行命名。参见在下面的for
循环中如何增强了其可读性。
示例2-13: 取自普通发票的几行数据
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
>>> invoice = """ ... 0.....6.................................40........52...55........ ... 1909 Pimoroni PiBrella $17.50 3 $52.50 ... 1489 6mm Tactile Switch x20 $4.95 2 $9.90 ... 1510 Panavise Jr. - PV-201 $28.00 1 $28.00 ... 1601 PiTFT Mini Kit 320x240 $34.95 1 $34.95 ... """ >>> SKU = slice(0, 6) >>> DESCRIPTION = slice(6, 40) >>> UNIT_PRICE = slice(40, 52) >>> QUANTITY = slice(52, 55) >>> ITEM_TOTAL = slice(55, None) >>> line_items = invoice.split('\n')[2:] >>> for item in line_items: ... print(item[UNIT_PRICE], item[DESCRIPTION]) ... $17.50 Pimoroni PiBrella $4.95 6mm Tactile Switch x20 $28.00 Panavise Jr. - PV-201 $34.95 PiTFT Mini Kit 320x240 |
在系列十二的向量#2:可进行切片的序列中讨论创建自己的集合时我们还会讲slice
对象。同时从用户视角看,切片包含一些额外的功能,如多维切片以及展开符(...
)写法。
多维切片与展开运算符
[]
运算符还可接收以逗号分隔的多个索引或切片。处理[]
运算的专有方法__getitem__
和__setitem__
会a[i, j]
中的索引按元组进行接收。也即运算a[i, j]
时,Python调用的是a.__getitem__((i, j))
。
在外部包NumPy中就有使用,可使用a[i, j]
来获取二维的numpy.ndarray
、使用a[m:n, k:l]
获取二维切片。本文稍后的例2-22有这种用法。
除memoryview
外,Python内置的序列类型都是一维的,因此均只支持一个索引或切片,无法使用它样的元组。
展开运算符使用三个点(...
) ,而非普通的省略号…
(Unicode U+2026),Python解析器会将其识别为一个令牌。它是Ellipsis
对象的别名,也是ellipsis
类的唯一实例。因此,其可以参数传递给函数或切片定义时使用,如f(a, ..., z)
或a[i:...]
。NumPy在获取多维数组切片时使用...
作为一种简写方式,例如,假定x
是四维数组,x[i, ...]
就是x[i, :, :, :,]
的简写。参见NumPy快速起步进行更详细的了解。
在写本文时,Python标准库中尚未有Ellipsis
或多维索引及切片的用法。读者如果发现有,欢迎在评论区告知。这类语法特性用于支持自定义类型及NumPy这样的扩展。
切片不仅可用于提供序列中的信息,还可以原地修改可变序列,无需重新进行构建。
对切片赋值
可变序列可以使用切片符号进行切割、删除以及原地修改,这一符号可用在赋值语句左侧或作为del
语句的目标。下面的例子演示了切片符号的强大之处:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
>>> l = list(range(10)) >>> l [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] >>> l[2:5] = [20, 30] >>> l [0, 1, 20, 30, 5, 6, 7, 8, 9] >>> del l[5:7] >>> l [0, 1, 20, 30, 5, 8, 9] >>> l[3::2] = [11, 22] >>> l [0, 1, 20, 11, 5, 22, 9] >>> l[2:5] = 100 # 赋值的目标方是切片时,即便只有一个元素右值也必须是可迭代对象。 Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: can only assign an iterable >>> l[2:5] = [100] >>> l [0, 1, 100, 22, 9] |
每个工程师都知道拼接是序列的常规操作。这时Python入门教程中都会讲解到+
和*
的用法,但两者的运行存在一些细节,下一部分中会进行讲解。
对序列使用+和*
Python程序员默认序列支持+
和*
。通常+
的两边应当为相同序列类型,两者都不会被修改,而是新建一个同类型的序列作为拼接的结果。
要对相同序列的多个拷贝进行拼接,可使用一个整数乘上该序列。同样,这也会新建一个序列:
1 2 3 4 5 |
>>> l = [1, 2, 3] >>> l * 5 [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3] >>> 5 * 'abcd' 'abcdabcdabcdabcdabcd' |
+
和*
总是新建对象,而不对运算项进行修改。
a * n
且其中的a
为包含可变子项的序列时要格外小心,因为结果会惊掉你的下巴。例如,使用my_list = [[]] * 3
来初始化列表时会产生一个对同一列表的三个引用,这可能不是你预期的结果。下一节会讲解使用*
初始化列表的列表时存在的坑。
构建列表的列表
有时我们需要使用一定数量的内嵌列表初始化列表,例如,将学生分到一组队伍里或表示游戏板中方块。实现的最佳方式是列表推导式,参见示例2-14.
示例2-14:一个由3组3元列表组成的列表可用于表示井字棋
1 2 3 4 5 6 |
>>> board = [['_'] * 3 for i in range(3)] # 创建3个3元列表组成的列表。查看结构 >>> board [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']] >>> board[1][2] = 'X' # 在第1行第2列放入一个标记,查看结果 >>> board [['_', '_', '_'], ['_', '_', 'X'], ['_', '_', '_']] |
大家经常容易走示例2-15这种错误的捷径。
示例2-15:指向同一列表的三个引用的列表毫无意义
1 2 3 4 5 6 |
>>> weird_board = [['_'] * 3] * 3 # 外部的列表由三个都指向同一内层列表的引用组成。在不进行修改时看不出异常。 >>> weird_board [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']] >>> weird_board[1][2] = 'O' # 在第1行第2列放入标记后,会发现所有行都是引用同一对象的别名。 >>> weird_board [['_', '_', 'O'], ['_', '_', 'O'], ['_', '_', 'O']] |
示例2-15的问题是本质其类似如下代码:
1 2 3 4 |
row = ['_'] * 3 board = [] for i in range(3): board.append(row) # 同一个row连续3次添加到board中 |
而示例2-14中的列表推导式等价于如下代码:
1 2 3 4 5 6 7 8 9 10 |
>>> board = [] >>> for i in range(3): ... row = ['_'] * 3 # 每次迭代会新建一个row并添加至board ... board.append(row) ... >>> board [['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']] >>> board[2][0] = 'X' >>> board # 和预想的一样,只有行号为2的进行了修改 [['_', '_', '_'], ['_', '_', '_'], ['X', '_', '_']] |
至此我们讨论了对序列使用+
和*
运算符,但还有+=
和*=
运算符,根据目标序列的可变性会产生截然不同的结果。下一节会讲解个中原理。
序列的增量赋值
增量赋值运算符+=
和*=
根据这个操作数的不同行为也不同。为便于讨论,我们先主要讲增量加(+=
),但其原理同样适用*=
及其它增量赋值运算符。
+=
背后的特殊方法是__iadd__
(用于原地加)。但如果未实现__iadd__
,Python会自动降级至调用__add__
。思考如下的简单表达式:
1 |
>>> a += b |
如果a
实现了__iadd__
,就会调用该方法。对于可变序列(如list
、bytearray
、array.array
),a
会在原地改变(即效果类似于a.extend(b)
)。但在如果a
没有实现__iadd__
时,a += b
表达式和a = a + b
具有同等效果:先计算a + b
表达式,生成新的对象,然后与a
进行绑定。换句话说,绑定a
的对象内存地址根据__iadd__
的可用性会改变或不变。
对于可变序列,一般都实现了__iadd_
,通常可假定+=
为原地修改。对于不可变序列显示不可能做原地修改。
以上有关+=
的讨论同样适用于*=
,由__imul__
进行实现。__iadd__
和__imul__
专用方法会在系列十六中进行讨论。
下面是*=
对可变序列以及不可变序列的演示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
>>> l = [1, 2, 3] >>> id(l) 4311953800 # 初始列表的 >>> l *= 2 >>> l [1, 2, 3, 1, 2, 3] >>> id(l) 4311953800 # 相乘后,列表为新增了元素后的同一对象 >>> t = (1, 2, 3) >>> id(t) 4312681568 # 初始元组的ID >>> t *= 2 >>> id(t) 4301348296 # 相乘后,新建了一个元组 |
对不可变序列反复拼接效率很低,因为它并不是直接添加元素,而是由解释器拷贝整个对象创建一个拼接了新元素的新对象。
我们已经学习了+=
的常用用法。下一节通过有趣的极端案例讲解对于元组“不可变”是什么意思。
A +=赋值迷题
试着不用控制台操作先回答这个问题: 例2-16中的两个表达式的运算结果是什么?
示例2-16:猜一猜
1 2 |
>>> t = (1, 2, [30, 40]) >>> t[2] += [50, 60] |
接下来会发生什么?选择最佳答案:
A. t
变为(1, 2, [30, 40, 50, 60])
。
B. 抛出错误消息为'tuple' object does not support item assignment
的TypeError
。
C. A 和 B都不对。
D. A 和 B都对。
看到这一题时,我一开始坚定地选择了 B,但正确答案是 D:A 和 B都对。例2-17中演示了在Python 3.9控制台中实际输出结果。
示例2-17:意料之外的结果:t2被修改且抛出了异常
1 2 3 4 5 6 7 |
>>> t = (1, 2, [30, 40]) >>> t[2] += [50, 60] Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'tuple' object does not support item assignment >>> t (1, 2, [30, 40, 50, 60]) |
Python在线课堂是一个用于详细可视化Python运行原理的很棒的工具。图2-5由两张截图组成,演示了示例2-17中元组t
的初始状态和最终状态。
图2-5:元组赋值迷题的初始和最终状态(该图由 Python 在线课堂生成)
如果查看Python为s[a] += b
表达式生成的字节码(例2-18),背后发生了什么会变得清晰。
示例2-18:s[a] += b表达式的字节码
1 2 3 4 5 6 7 8 9 10 11 |
>>> dis.dis('s[a] += b') 1 0 LOAD_NAME 0 (s) 2 LOAD_NAME 1 (a) 4 DUP_TOP_TWO 6 BINARY_SUBSCR # 将s[a]的值放在栈顶(TOS) 8 LOAD_NAME 2 (b) 10 INPLACE_ADD # 执行TOS += b。如果TOS指向可变对象则成功(例2-17中为列表) 12 ROT_THREE 14 STORE_SUBSCR # 赋值s[a] = TOS。如s为不可变则失败(例2-17中 t中为元组) 16 LOAD_CONST 0 (None) 18 RETURN_VALUE |
本例是一种极端的情况,在原作者20年的Python编程中,也未一例因此影响到实际程序的。
从这里面我学习到了三点:
- 不要将可变元素放到元组中
- 增量赋值并非原子运算-我们只看到执行部分任务后其抛出异常
- 查看Python字节码并不是太复杂,有助于了解底层的问题。
在学习了+
和*
拼接的细节后,我们可以进入另一个序列的基本运算:排序。
list.sort vs. 内置的sorted
list.sort
方法在原处对列表进行排序,即不使用拷贝。其返回值为None
,这告诉我们它修改了接收器且未新建列表。这是一个重要的Python API公约:原地修改对象的函数或方法应返回None
,以让调用者清楚接收器发生了变化,且未创建新对象。例如可以在random.shuffle(s)
函数中看到类似的行为,它在原地对可变序列s
进行了随机排序并返回None
。
与之对应的内置函数sorted
新建列表并返回。它的参数可接收任意可迭代对象,包含不可变序列和生成器(见系列十七)。不论赋值给sorted
的可迭代类型是什么,它总是返回一个新创建的列表。
list.sort
和sorted
都可接收两个可选的关键词参数:
reverse
:若为True
,返回的元素按降序排列(即元素按反向比较)。其默认值为False
。key
:对每个元素应用一个单参数函数,生成排序的键。例如,在对一个字符串列表进行排序时,key=str.lower
可用于执行一个不区分大小写的排序,key=len
会按字符长度对字符串排序。默认为id 函数(即对元素自身进行比较)。
key
使用内置函数min()
和max()
以及其它标准库中的函数(如itertools.groupby()
和heapq.nlargest()
)。以下示例用于说明这些函数和关键字参数的用法。这些示例也演示了Python的排序算法是稳定的(即它在元素相等时保留相对排序):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
>>> fruits = ['grape', 'raspberry', 'apple', 'banana'] >>> sorted(fruits) ['apple', 'banana', 'grape', 'raspberry'] # 会生成按字母排序的新字符串列表 >>> fruits ['grape', 'raspberry', 'apple', 'banana'] # 查看原有列表并未改变 >>> sorted(fruits, reverse=True) ['raspberry', 'grape', 'banana', 'apple'] # 也字母反向排序 >>> sorted(fruits, key=len) ['grape', 'apple', 'banana', 'raspberry'] # 按升序排序的新字符串列表。因排序算法是稳定的,grape和apple的长度都是5,保留了原始排序。 >>> sorted(fruits, key=len, reverse=True) ['raspberry', 'banana', 'grape', 'apple'] # 字符串按长度降序排列。它并不是前面结果的反转,因为排序是稳定的,所以grape还是在apple前面。 >>> fruits ['grape', 'raspberry', 'apple', 'banana'] # 至此原来的列表fruits未发生改变 >>> fruits.sort() # 这会在原地对列表排序,返回None(在控制台中会省去) >>> fruits ['apple', 'banana', 'grape', 'raspberry'] # 此时fruits进行了排序 |
序列进行排序后,可以进行高效搜索。在Python标准库的bisect
模块中提供了二进制搜索算法。这个模块还包含bisect.insort
函数,可用于确保排序的序列保持为排序状态。在配套网站 fluentpython.com的使用二分查找管理已排序序列有对bisect
模块的讲解。
本文至此所学的都适用于所有序列,而不仅仅是列表或元组。Python工程师有时会过度使用list
类型因其非常顺手,我就这和干过。例如在处理大数据量的列表时,应考虑使用数组。本文接下来会讲解列表和元组的一些替代品。
在列表并非答案时
list
类型灵活易用,但有某些特殊要求时,还有更好的选择。例如,在处理数百万浮点数时array
会节省大量的内存。另外,如果一直在列表的另一端添加、删除元素,deque
(双端队列)是一个性能更好的先进先出数据结构。
item in my_collection
),考虑对my_collection
使用set
,尤其是在存储大量元素时。集合对快速成员查找做了优化。也可对其进行迭代,但它不是序列,因为集合中元素的排序是不固定的。在系列三中会进行讲解。本文接下来会讨论可替代列表的可变序列类型,先从数组开始。
数组
如果列表中仅包含数字,array.array
是一个更高效的替代。数组支持所有的可变序列运算(包含.pop
、.insert
和.extend
),也支持其它用于快速加载和保存的方法,如.frombytes
和.tofile
。
Python的数组和C的数组一样轻量。如图2-1所示,浮点值数组并不存储完整的float
实例,而仅仅是表示其机器值的已封装字节,类似C语言中的double
数组。在创建array
时,提供一个表示C语言底层用于存储数组中元素数据类型的类型代码。例如,b
是C中称为signed char
的类型代码,该类型的值在–128到127之间。如若创建一个array('b')
,每个元素会在单个字节中进行存储并被解释为整型。对于数字的大型序列,这会节约大量内存。Python不允许将不匹配数组类型的数字放到其中。
示例2-19演示了创建、保存和加载一个包含一千万随机浮点值的数组。
示例2-19:创建、保存及加载大型浮点数组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
>>> from array import array # 导入array类型 >>> from random import random >>> floats = array('d', (random() for i in range(10**7))) # 通过任意迭代对象(此处为生成式表达式)创建一个双精度浮点值的数组(类型代码 'd') >>> floats[-1] # 查看数组的最后一个数 0.07802343889111107 >>> fp = open('floats.bin', 'wb') >>> floats.tofile(fp) # 将数组保存为二进制文件 >>> fp.close() >>> floats2 = array('d') # 创建双精度的空数组 >>> fp = open('floats.bin', 'rb') >>> floats2.fromfile(fp, 10**7) # 从二进制文件中读取1000万个数字 >>> fp.close() >>> floats2[-1] # 查看数组的最后一个数 0.07802343889111107 >>> floats2 == floats # 验证数组的内容是否一致 True |
可以看出,array.tofile
和array.fromfile
使用起来看容易。如果测试本例,会发现运行很快。快速的实验表明array.fromfile
从二进制文件加载1千万个双精度浮点值仅需0.1秒。它比从文本文件读取数字要快近60倍,那样会需要使用内置的float
解析每一行。使用array.tofile
进行保存比在文本文件中每行写一个浮点数要快7倍。此外,1千万个双精度值的二进制文件的大小为80,000,000字节(每个双精度值为8个字节,没有额外开销),而同样的数据文本文件要占到181,515,739字节。
具体表示二进制数据的数字数组,如栅格图像,Python有bytes
和bytearray
类型,在系列四中会进行讲解。
我们使用表2-3来结束数组这部分内容,其中对比了list
和array.array
的特性。
表2-3:列表或数组的方法和属性(为保持简洁省去了已淘汰的数组方法及对象中也实现了的方法)
list | array | ||
s.__add__(s2) | ● | ● | s + s2—拼接 |
s.__iadd__(s2) | ● | ● | s += s2—原地拼接 |
s.append(e) | ● | ● | 在最后添加一个元素 |
s.byteswap() | ● | 根据大小端惯例交换数组中所有元素的字节 | |
s.clear() | ● | 删除所有元素 | |
s.__contains__(e) | ● | ● | e in s |
s.copy() | ● | 列表的浅拷贝 | |
s.__copy__() | ● | 对copy.copy的支持 | |
s.count(e) | ● | ● | 计算某个二维出现的次数 |
s.__deepcopy__() | ● | 对copy.deepcopy的优化后支持 | |
s.__delitem__(p) | ● | ● | 在位置p处删除元素 |
s.extend(it) | ● | ● | 对可迭代的it添加元素 |
s.frombytes(b) | ● | 通过解释为对齐机器值的字节序列添加元素 | |
s.fromfile(f, n) | ● | 通过解释为对齐机器值的二进制文件f添加 n 个元素 | |
s.fromlist(l) | ● | 通过列表添加元素;如出现类型错误则都不会添加 | |
s.__getitem__(p) | ● | ● | s[p]—在指定位置获取元素或切片 |
s.index(e) | ● | ● | 查找首次出现e的位置 |
s.insert(p, e) | ● | ● | 在位置 p 元素前插入元素 e |
s.itemsize | ● | 每个数组元素的字节长度 | |
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原地对元素排序 | |
s.tobytes() | ● | 按字节对象以对齐机器值返回元素 | |
s.tofile(f) | ● | 将元素以对齐机器值保存到二进制文件f中 | |
s.tolist() | ● | 按列表以数字对象返回元素 | |
s.typecode | ● | 标识元素的 C 语言类型的单字符字符串 |
list.sort()
这样的原地排序方法。如需对数组排序,使用内置的sorted
函数重建数组:
1 |
a = array.array(a.typecode, sorted(a))在 |
在添加元素时如需保留数组排序,可使用
bisect.insort
函数。
如果大量使用数组却不知道memoryview
,就少了些意思。参见下一节。
内存视图
内置的memoryview
类是一个共享内存序列类型,可无需拷贝字节处理数组切片。这是受NumPy库的记性。NumPy的首席作者Travis Oliphant,在何时应使用memoryview
?中这样回答了这个问题:
memoryview基本上是一个Python内NumPy归纳的数组结构(不带有数学运算)。它允许我们无需先拷贝应在数据结构间共享内存(类似PIL图片、SQLite数据库、NumPy等)。对于大型数据集这非常重要。
使用array
模块中类似符号,memoryview.cast
方法可让我们无需移动比特流修改多字节读取或写入单元的方式。memoryview.cast
返回另一个memoryview
对象,共享相同的内存。
例2-20演示如何对同一个6字节数组创建替代视图,以2×3或3×2矩阵进行运算。
示例2-20:以1×6、2×3或3×2视图处理6字节内存:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
>>> from array import array >>> octets = array('B', range(6)) # 创建一个6字节数组(类型代码'B'). >>> m1 = memoryview(octets) # 通过该数组创建memoryview,然后以列表导出 >>> m1.tolist() [0, 1, 2, 3, 4, 5] >>> m2 = m1.cast('B', [2, 3]) # 通过前一个新建一个两行三列的memoryview >>> m2.tolist() [[0, 1, 2], [3, 4, 5]] >>> m3 = m1.cast('B', [3, 2]) # 另一个三行两列的memoryview >>> m3.tolist() [[0, 1], [2, 3], [4, 5]] >>> m2[1,1] = 22 # 在m2的一行一列使用22进行重写 >>> m3[1,1] = 33 # 在m3的一行一列使用33重写 >>> octets # 显示原始数组,证明octets, m1, m2和m3之间共享了内存 array('B', [0, 1, 2, 33, 22, 5]) |
memoryview
的强大能力可用于修改数据。例2-21演示如何在16位整数的数组中修改元素的单个字节。
示例2-21:通过其中一个字节修改16位整数数组元素的值
1 2 3 4 5 6 7 8 9 10 11 12 |
>>> numbers = array.array('h', [-2, -1, 0, 1, 2]) >>> memv = memoryview(numbers) # 通过5个16位有符号整数(类型代码'h')的数组创建memoryview >>> len(memv) 5 >>> memv[0] # memv中的5个元素与原数组相同 -2 >>> memv_oct = memv.cast('B') # 通过将memv中的元素转换为字节(类型代码'B')创建memv_oct >>> memv_oct.tolist() # 以10字节列表导出memv_oct的元素以供查看 [254, 255, 255, 255, 0, 0, 1, 0, 2, 0] >>> memv_oct[5] = 4 # 将4赋值给字节偏移位置5 >>> numbers array('h', [-2, -1, 1024, 1, 2]) # 注意数字的变化:2字节无符号整数最高有效字节中的4为1024 |
如果在数组中进行高阶数字处理的话,应当使用NumPy库。下面就会进行简短的学习。
NumPy
本书中,我强调了Python标准库已存在的方法,这样大家可以使用到它们。但NumPy太过优秀,值得为它花费些篇幅。
对于高级数组和矩阵运算,NumPy是Python在科学计算应用中成为主流的一大原因。NumPy实现了多维同质数组和矩阵类型,不仅可存储数字,还可存储用记定义记录,并且提供了高效的元素级运算。
SciPy在NumPy基础上编写的库,提供了多种线性代数、微积分和数据统计的科学计算算法。SciPy快速可靠的原因是它使用 Netlib仓库中大量使用的C和Fortran代码。换句话说,SciPy给予科技两个领域的最佳:交互命令行和高级Python API,以及使用C和Fortran优化的业界强大的真大数据运算。
作为一个简短的NumPy演示,例2-22中展现了一些二维数组的基础运算。
示例2-22:numpy.ndarray中的行列基础运算
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
>>> import numpy as np # 安装后导入NumPy(它不属于Python标准库)。按照惯例,numpy导入为np。 >>> a = np.arange(12) # 创建、查看一个整数0到11的numpy.ndarray >>> a array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) >>> type(a) <class 'numpy.ndarray'> >>> a.shape # 查看数组的维度,这是个一维12个元素的数组。 (12,) >>> a.shape = 3, 4 # 改变数组的形状,添加一维,然后查看结果 >>> a array([[ 0, 1, 2, 3], [ 4, 5, 6, 7], [ 8, 9, 10, 11]]) >>> a[2] # 获取索引为2的行 array([ 8, 9, 10, 11]) >>> a[2, 1] # 获取索引2, 1处的元素 9 >>> a[:, 1] # 获取索引为1的列 array([1, 5, 9]) >>> a.transpose() # 通过转置新建数组(交换行和列) array([[ 0, 4, 8], [ 1, 5, 9], [ 2, 6, 10], [ 3, 7, 11]]) |
NumPy还支持加载、保存和对numpy.ndarray
的所有元素进行运算的高级运算。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
>>> import numpy >>> floats = numpy.loadtxt('floats-10M-lines.txt') # 通过文本文件加载1千万个浮点数 >>> floats[-3:] # 使用序列切片符号查看最后三个数 array([ 3016362.69195522, 535281.10514262, 4566560.44373946]) >>> floats *= .5 # 对浮点数组中的每个元素乘上.5并再次查看最后三个元素 >>> floats[-3:] array([ 1508181.34597761, 267640.55257131, 2283280.22186973]) >>> from time import perf_counter as pc # 导入高阶性能计量定时器(Python 3.3中加入) >>> t0 = pc(); floats /= 3; pc() - t0 # 将每个元素除以3;1千万个浮点数花费时间小于40微秒 0.03690556302899495 >>> numpy.save('floats-10M', floats) # 在.npy二进制文件中保存数组 >>> floats2 = numpy.load('floats-10M.npy', 'r+') # 将数组以内存映射文件加载到另一个数组中;即使它在内存中不能完整拟合,也会让数组切片的处理更高效 >>> floats2 *= 6 >>> floats2[-3:] # 在每个元素乘上6后查看最后三个元素 memmap([ 3016362.69195522, 535281.10514262, 4566560.44373946]) |
以上只是开胃菜。
NumPy 和 SciPy 都是强大的库,是一些其它强大工具的基础,如Pandas,它实现了可存储非数字数据的高效数组类型,并为.csv、.xls、SQL导出文件和HDF5等格式文件提供了导入导出函数,还有Scikit-learn,它是当前最广泛使用的机器学习工具集。NumPy和SciPy的大部分函数使用C或C++实现,这样可以利用CPU的所有内核,因为它们释放了的Python的GIL(全局解释器锁)。Dask项目支持跨服务器集群的并行NumPy、Pandas和Scikit-Learn运行。这些包可以写好几本书。本书不是介绍它们的。但不讲解NumPy数组对于Python序列又不完整。
在学习了平铺式序列-标准数组和NumPy数组之后,我们现在可以学习对于普通老列表完全不同的替代集:队列。
Deque和其它队列
.append
和.pop
方法让list
可以像栈或队列那样使用(若使用.append
和.pop(0)
的话,会实现先进先出)。但在列表头部(索引为0的那端)插入、删除元素开销较大,因为整个列表都要在内存中偏移。
collections.deque
类是一个线程安全的双端队列,设计用于快速在两端快速插入和删除。如果想像维护一个“最后查看”或此类性质的列表的话它也是好选择,因为deque
可带边界,即使用最大长度创建。如果deque
设置的边界已满,在添加新元素时它会在另一端删除一个元素。例2-23展示一些对deque
执行的一些典型运算。
示例2-23:操作deque
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
>>> from collections import deque >>> dq = deque(range(10), maxlen=10) # 可选的maxlen参数设置在deque实例中允许的最大元素数;这会设置一个只读maxlen实例属性 >>> dq deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10) >>> dq.rotate(3) # n > 0时旋转在右端取出元素并在左端新增,n < 0时旋转在左端取出元素并在右端新增 >>> dq deque([7, 8, 9, 0, 1, 2, 3, 4, 5, 6], maxlen=10) >>> dq.rotate(-4) >>> dq deque([1, 2, 3, 4, 5, 6, 7, 8, 9, 0], maxlen=10) >>> dq.appendleft(-1) # 在deque已满时(len(d) == d.maxlen)添加元素会在另一端删除元素;注意在下一行中删去了0 >>> dq deque([-1, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10) >>> dq.extend([11, 22, 33]) # 在右端添加3个元素,会在左侧删除 -1, 1 和 2 >>> dq deque([3, 4, 5, 6, 7, 8, 9, 11, 22, 33], maxlen=10) >>> dq.extendleft([10, 20, 30, 40]) # 注意extendleft(iter)通过在deque左侧添加iter参数的每个连续元素,因此元素最后的位置进行了翻转 >>> dq deque([40, 30, 20, 10, 3, 4, 5, 6, 7, 8], maxlen=10) |
表2-4比较了list
和deque
的具体方法(此处删去了object
中已包含的那些)
可以看到deque
实现了list
大部分的方法,并添加了一些适合其设计的方法,如popleft
和rotate
。但存在一个隐藏的开销:在deque
的中间删除元素不怎么快。它主要的优化是在两端添加和删除元素。
append
和popleft
是原子运算,因此在多线程应用中无需使用锁deque
将作为先进先出队列使用是安全的。
表2-4:或deque中实现的方法 (为简化省去了object所实现的那些方法)
list | deque | ||
s.__add__(s2) | ● | s + s2—拼接 | |
s.__iadd__(s2) | ● | ● | s += s2—原地拼接 |
s.append(e) | ● | ● | 在右侧(最后一个元素之后)添加一个元素 |
s.appendleft(e) | ● | 在左侧(第一个元素之前)添加一个元素 | |
s.clear() | ● | ● | 删除所有元素 |
s.__contains__(e) | ● | e in s | |
s.copy() | ● | 列表的浅拷贝 | |
s.__copy__() | ● | 对copy.copy的支持 (浅拷贝) | |
s.count(e) | ● | ● | 计算一个元素出现的次数 |
s.__delitem__(p) | ● | ● | 在位置p处删除元素 |
s.extend(i) | ● | ● | 通过可迭代对象i在右侧添加元素 |
s.extendleft(i) | ● | 通过可迭代对象i在左侧添加元素 | |
s.__getitem__(p) | ● | ● | s[p]—从指定位置获取元素或切片 |
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—逆向反复拼接(系列16中讲解) | |
s.pop() | ● | ● | 删除并返回最后一个元素a_list.pop(p)允许在位置p处删除,但deque并不支持 |
s.popleft() | ● | 删除并返回第一个元素 | |
s.remove(e) | ● | ● | 按值删除第一个出现的e元素 |
s.reverse() | ● | ● | 原地对元素进行反向排序 |
s.__reversed__() | ● | ● | 获取迭代器从后向前扫描元素 |
s.rotate(n) | ● | 从一端将 n 个元素移到另一端 | |
s.__setitem__(p, e) | ● | ● | s[p] = e—把e放到位置 p处,重写已有元素或切片 |
s.sort([key], [reverse]) | ● | 使用可选关键字参数key和 reverse对元素进行原地排序 |
除deque
外,Python标准包还实现了其它的队列:
queue
:提供了同步(即线程安全)类SimpleQueue
、Queue
、LifoQueue
和PriorityQueue
。它们可用于线程间的安全通信。除SimpleQueue
外均可通过在构造函数中提供一个大于0的maxsize
参数设置边界。但它们不会像deque
那样会删除元素释放空间。而是在队列已满插入新元素时,等待其它线从队列中取出元素来释放空间,这对于限制活跃线程数很有用。multiprocessing
:实现其自己的无边界multiprocessing
和有界Queue
,类似于queue
包中那些类,但设计用于跨进程通信。提供了一个专有的multiprocessing.JoinableQueu
用于任务管理。asyncio
:提供带有受queue
和multiprocessing
模块中类启发的API的Queue
、LifoQueue
、PriorityQueue
和JoinableQueue
,但它适配了异步编程中的任务管理。heapq
:与前面三上模块不同,heapq
没有实现任何队列类,而是提供了heappush
和heappop
函数,让我们可使用可变序列作为堆队列或优先级队列。
以上就讲完了list
替代类型的综述,并且综合探讨了除str
和二进制序列外的序列类型,这两个序列在系列四中会单独讲解。