0%

流畅的 Python 第 2 版(2):丰富的序列

Python 从 ABC 语言继承了对序列的统一处理方式。字符串、列表、字节序列、数组、XML 元素和数据库查询结果,这些序列在操作上有很多共通之处,都可以迭代、切片、排序和拼接。深入理解 Python 中不同的序列类型,不但能避免重新发明轮子,还可以从它们共通的接口上受到启发,在自己实现 API 时合理支持及利用现有和将来可能添加的序列类型。

内置序列类型概览

Python 标准库用 C 语言实现了丰富的序列类型,列举如下:

  • 容器序列:可存放不同类型的项,其中包括嵌套容器。示例:list、tuple 和 collections.deque
  • 扁平序列:可存放一种简单类型的项。示例:str、bytes 和 array.array

容器序列存放的是所包含对象的引用,对象可以是任何类型。扁平序列 在自己的内存空间中存储所含内容的值,而不是各自不同的 Python 对象。下图展示了这种区别,可以看到,扁平序列更加紧凑,但是只能存放原始机器值,例如字节、整数和浮点数

任何 Python 对象在内存中都有一个包含元数据的标头(上图中的灰色方块)。最简单的 Python 对象,例如一个 float,内存标头中有一个值字段和两个元数据字段。

  • ob_refcnt:对象的引用计数
  • ob_type:指向对象类型的指针
  • ob_fval:一个 C 语言 double 类型值,存放 float 的值

假如有一个浮点数数组和一个浮点数元组,显然前者比后者更紧凑,因为数组整体是一个对象,存放各个浮点数的原始值,而元组由多个对象构成:元组自身和存放的各个 float 对象

另外,还可按可变性对序列类型分类:

  • 可变序列:例如 list、bytearray、array.array和collections.deque
  • 不可变序列:例如 tuple、str 和 bytes

可变序列继承不可变序列的所有方法,另外还多实现了几个方法。记住不同序列类型的共同点:有些是可变的,有些是不可变的;有些是容器,有些是扁平的。这有助于你把相关概念延伸到不太熟悉的序列类型上。

list 是最基本的序列类型,是一种可变容器。接下来将介绍列表推导式和生成器表达式。

列表推导式和生成器表达式

使用列表推导式(目标是列表)或生成器表达式(目标是其他序列类型)可以快速构建一个序列。很多 Python 程序员把列表推导式简称为 listcomps,把生成器表达式简称为 genexps。

列表推导式

如下代码基于一个字符串构建一个 Unicode 码点列表:

1
2
3
4
5
6
7
8
9
10
11
>>> symbols = 'abcdefg'
>>> codes = []
>>> for c in symbols:
... codes.append(ord(c))
...
>>> codes
[97, 98, 99, 100, 101, 102, 103]

>>> codes = [ord(c) for c in symbols]
>>> codes
[97, 98, 99, 100, 101, 102, 103]

可以看到,使用列表推导式更易于理解,其意图是明确的。当然列表推导式应保持简短。如果超过两行,那么最好把语句拆开。如果你不打算使用生成的列表,那就不要使用列表推导式句法。

Python 会忽略[​]​、{} 和 () 内部的换行。因此,列表、列表推导式、元组、字典等结构完全可以分成几行来写,无须使用续行转义符 \。另外,使用这 3 种括号定义字面量时,项与项之间使用逗号分隔,末尾的逗号将被忽略。因此,跨多行定义列表字面量时,最好在最后一项后面添加一个逗号。这样不仅能方便其他程序员为列表添加更多项,还可以减少代码差异给阅读带来的干扰。

Python3 中的列表推导式、生成器表达式,以及类似的集合推导式和字典推导式,for 子句中赋值的变量在局部作用域内。然而,使用 海象运算符 := 赋值的变量在推导式或生成器表达式返回后依然可以访问,这与函数内的局部变量行为不同。

1
2
3
4
5
6
7
8
9
10
11
>>> x = "ABC"
>>> codes = [ord(x) for x in x]
>>> x
'ABC'
>>> codes = [last := ord(c) for c in x]
>>> last
67
>>> c
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'c' is not defined

列表推导式涵盖 map 和 filter 两个函数的功能,但写出的代码不像 Python 的 lambda 表达式那样晦涩难懂。

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]

如下使用列表推导式计算笛卡尔乘积:

1
2
3
4
5
6
7
>>> colors = ['r', 'g', 'b']
>>> size = ['S', 'M', 'L']
>>> [(c, s) for c in colors for s in size]
[('r', 'S'), ('r', 'M'), ('r', 'L'), ('g', 'S'), ('g', 'M'), ('g', 'L'), ('b', 'S'), ('b', 'M'), ('b', 'L')]

>>> [(c, s) for s in size for c in colors]
[('r', 'S'), ('g', 'S'), ('b', 'S'), ('r', 'M'), ('g', 'M'), ('b', 'M'), ('r', 'L'), ('g', 'L'), ('b', 'L')]

可以看到,[(c, s) for c in colors for s in size] 中是先按照 color,再按 size 进行排列,等同于:

1
2
3
>>> for c in colors:
... for s in size:
... print(c, s)

列表推导式的作用很单一,就是构建列表。如果想生成其他类型的序列,则应当使用生成器表达式。

生成器表达式

虽然列表推导式也可以生成元组、数组或其他类型的序列,但是生成器表达式占用的内存更少,因为生成器表达式使用迭代器协议逐个产出项,而不是构建整个列表提供给其他构造函数。生成器表达式的句法跟列表推导式几乎一样,只不过把方括号换成圆括号而已。

1
2
3
4
5
6
>>> symbols = "abcdefg"
>>> tuple(ord(c) for c in symbols)
(97, 98, 99, 100, 101, 102, 103)
>>> import array
>>> array.array('I', (ord(c) for c in symbols))
array('I', [97, 98, 99, 100, 101, 102, 103])
  • 如果生成器表达式是函数唯一的参数,则不需要额外再使用圆括号括起来
  • array 构造函数接受两个参数,因此必须在生成器表达式两侧加上圆括号
  • array 构造函数的第一个参数指定数组中数值的存储类型

如下代码使用使用生成器表达式,一次产出一项,提供给 for 循环。如果是两个各有 1000 项的列表,则使用生成器表达式计算笛卡儿积可以节省大量内存,因为不用先构建一个包含 100 万项的列表提供给 for 循环。

1
2
3
4
>>> colors = ['r', 'g', 'b']
>>> size = ['S', 'M', 'L']
>>> for t in (f'({c}, {s})' for c in colors for s in size):
... print(t)

元组不仅仅是不可变列表

元组有两个作用,除了可以作为不可变列表使用之外,还可用作没有字段名称的记录。用元组存放记录,元组中的一项对应一个字段的数据,项的位置决定数据的意义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> positions = [(1, 2), (3, 4), (5, 6)]
>>> for p in positions:
... print("%s/%s" % p)
...
1/2
3/4
5/6

>>> for x, _ in positions:
... x
...
1
3
5
  • 可以看到,% 格式化运算符理解元组结构,把每一项当作不同的字段
  • for 循环知道如何获取元组中单独的每一项,这叫 拆包​。这里我们对第二项不感兴趣,因此把它赋值给虚拟变量 _(习惯用法)

通常没必要为了给字段指定一个名称而创建一个类。如果不使用索引访问字段,而只使用拆包,那就更没有必要了。当然对于拆包,内容不局限于元组,也涉及序列和一般意义上的可迭代对象。

Python 解释器和标准库经常把元组当作不可变列表使用,你也可以。这么做主要有两个好处:

  • 意图清晰:只要在源码中见到元组,你就知道它的长度永不可变
  • 性能优越:长度相同的元组和列表,元组占用的内存更少,而且 Python 可对元组做些优化

元组的不可变性仅针对元组中的引用而言。元组中的引用不可删除、不可替换。倘若引用的是可变对象,改动对象之后,元组的值也会随之变化

元组的内容自身是不可变的,但是这仅仅表明元组中存放的引用始终指向同一批对象。倘若引用的是可变对象,例如一个列表,那么元组的值就可能发生变化:

1
2
3
4
>>> a = (10, "alpha", [1, 2])
>>> a[-1].append(3)
>>> a
(10, 'alpha', [1, 2, 3])

存放可变项的元组可能导致 bug。后面会介绍,只有值永不可变的对象才是可哈希的。不可哈希的元组不能作为字典的键,也不能作为集合的元素。如果你想显式判断一个元组(或其他对象)的值是否固定,则可以使用内置函数 hash 定义如下所示的 fixed 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"""
>>> tf = (10, 'alpha', (1, 2))
>>> tm = (10, 'alpha', [1, 2])
>>> fixed(tf)
True
>>> fixed(tm)
False
"""

def fixed(o):
try:
hash(o)
except TypeError:
return False
return True

尽管如此,元组依然广泛用作不可变列表。相比于 list,元组的性能更好一些。把元组当作列表的不可变变体使用时,有必要了解二者 API 之间的异同。元组支持所有不涉及增删项的列表方法。

另外说一下,列表可以包含不同类型的对象,但是在实践中,这样做没有什么用。我们把一些项放在列表中,是为了后续处理,这就隐含了一层意思,即所有项至少都应该支持同一种操作。与列表不同,元组通常包含不同类型的项。这也符合常理:如果元组中的每一项都是一个字段,那么每个字段就可以具有不同的类型。

序列和可迭代对象拆包

拆包的特点是不用我们自己动手通过索引从序列中提取元素,这样就减少了出错的可能。拆包的目标可以是任何可迭代对象,包括不支持索引表示法([])的迭代器。最明显的拆包形式是并行赋值(parallel assignment),即把可迭代对象中的项赋值给变量元组:

1
2
3
4
5
>>> x, y = (1, 2)
>>> x
1
>>> y
2

利用拆包还可以轻松对调两个变量的值,省掉中间的临时变量。

1
2
3
>>> x, y = y, x
>>> x, y
(2, 1)

调用函数时在参数前面加上一个,利用的也是拆包*。

1
2
3
4
5
>>> divmod(20, 8)
(2, 4)
>>> t = (20, 8)
>>> divmod(*t)
(2, 4)

拆包的另一个用途:为函数返回多个值提供一种便于调用方使用的方式。

1
2
3
4
>>> import os
>>> _, filename = os.path.split("/root/1.py")
>>> filename
'1.py'

如果只需要拆包得到的部分项,那么可以使用 * 语法。我们知道,定义函数时可以使用 *args 捕获余下的任意数量的参数。Python 3 把这一思想延伸到了并行赋值上。并行赋值时,* 前缀只能应用到一个变量上,不过可以是任何位置上的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> a, b, *rest = range(5)
>>> a
0
>>> b
1
>>> rest
[2, 3, 4]

>>> a, b, *rest = range(2)
>>> a, b, rest
(0, 1, [])

>>> a, b, c, d
(0, 1, [2, 3], 4)

在函数调用中,可以使用多次 *,定义列表、元组或集合字面量时,也可以使用 *

1
2
>>> a, b, c, d
(0, 1, [2, 3], 4)
1
2
3
4
5
>>> *range(3), 4
(0, 1, 2, 4)

>>> {*range(3), 4, *[5, 6, 7]}
{0, 1, 2, 4, 5, 6, 7}

最后,元组拆包的一个强大功能是可以处理嵌套结构。拆包的对象可以嵌套,例如(a, b, (c, d))。如果值的嵌套结构是相同的,则 Python 能正确处理。

1
2
3
>>> (a, b, (c, *d)) = (1, 2, (3, 4, 5))
>>> a, b, c, d
(1, 2, 3, [4, 5])

拆包赋值的对象也可以是一个列表,不过用途不大。只有一种情况用得到,返回的结果是只包含一个元素的的序列,通过拆包确保只返回一个结果:

1
2
3
4
5
>>> a
[1]
>>> [a] = [1]
>>> a
1

当然也可以使用元组,但是句法有点怪异,元组中唯一的项后面要加一个逗号。如果忘记末尾的逗号,将埋下不易察觉的 bug:

1
2
3
4
5
6
>>> (a) = [1]
>>> a
[1]
>>> (a,) = [1]
>>> a
1

序列模式匹配

Python3.10 最引人注目的新功能之一是 match/case 语句实现的模式匹配。如下是一个例子,假设你在设计一个机器人,它接受以文字和数值序列形式发送的命令,例如 BEEPER 440 3。经过拆分和解析之后,得到消息 ['BEEPER', 440, 3]​,使用如下代码,可以处理这样的消息:

1
2
3
4
5
6
7
8
9
10
11
12
def handle_command(self, message):
match message:
case ['BEEPER', frequency, times]:
self.beep(times, frequency)
case ['NECK', angle]:
self.rotate_neck(angle)
case ['LED', ident, intensity]:
self.leds[ident].set_brightness(ident, intensity)
case ['LED', ident, red, green, blue]:
self.leds[ident].set_color(ident, red, green, blue)
case _:
raise InvalidCommand(message)

match 关键字后面的表达式是匹配对象(subject),即各个 case 子句中的模式尝试匹配的数据。当执行匹配时,会从第一个 case 开始,依次检查模式是否匹配(值的匹配、项数的匹配)。

表面上看,match/case 与 C 语言中的 switch/case 语句很像,但这只是表象。与 switch 相比,match 的一大改进是支持析构,这是一种高级拆包形式

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:
case [name, _, _, (lat, lon)] if lon <= 0:
print(f'{name:15} | {lat:9.4f} | {lon:9.4f}')

这个 match 的匹配对象是 record,即 metro_areas 中的各个元组。但是 case 子句一部分是模式,另一部分是使用 if 关键字指定的卫语句(guard clause,可选)。在这个例子中,嵌套块中的 print 语句仅当匹配模式且卫语句为真时才运行。

一般来说,匹配对象同时满足以下条件方能匹配序列模式:

  • 匹配对象是序列
  • 匹配对象和模式的项数相等
  • 对应的项相互匹配,包括嵌套的项

序列模式可以写成元组或列表,或者任意形式的嵌套元组和列表,使用哪种句法都没有区别,因为在序列模式中,方括号和圆括号的意思是一样的。序列模式可以匹配 collections.abc.Sequence 的多数实际子类或虚拟子类的实例,但 strbytesbytearray 除外。因为在 match/case 上下文中,str、bytes 和 bytearray 实例不作为序列处理,match 把这些类型视为 原子 值,即一个整体值,而不是序列。如果想把这些类型的对象视为序列,则要在 match 子句中转换,例如使用 match tuple(phone)

标准库中的以下类型与序列模式兼容:

1
2
list     memoryview    array.array
tuple range collections.deque

有一些注意事项:

  • 与拆包不同,模式不析构序列以外的可迭代对象(例如迭代器)​
  • _ 符号在模式中有特殊意义:匹配相应位置上的任何一项,但不绑定匹配项的值
  • 另外,_ 是唯一可在模式中多次出现的变量
  • 模式中的任何一部分均可使用as关键字绑定到变量上,例如 case [name, _, _, (lat, lon) as coord]:
  • 添加类型信息可以让模式更具体。例如,下面的模式与前面的示例匹配相同的嵌套序列结构,不过第一项必须是str 实例,而且二元组中的两项必须是 float 实例。str(name) 这种语法在模式上下文中,其作用是在运行时检查类型
1
case [str(name), _, _, (float(lat), float(lon))]:
  • 如果想要匹配任何以字符串开头、以嵌套两个浮点数的序列结尾的序列,则可以使用如下模式。*_ 匹配任意数量的项,而且不绑定变量,如果把 *_ 换成 *extra,匹配的零项或多项将作为列表绑定到 extra 变量上。
1
case [str(name), *_, (float(lat), float(lon))]:
  • 以 if 开头的卫语句是可选的,仅当匹配模式时才运行,卫语句可以引用模式中绑定的变量
  • 在序列模式中,一个序列中只能有一个 *。如下匹配对象是合法的,因为外层一个,内层一个:
1
2
case ['lambda', [*parms], *body] if body:
return Procedure(parms, body, env)
  • 如下模式中,表示第二项必须是 Symbol 类的实例
1
case ['define', Symbol() as name, value_exp]

通过序列模式匹配,你可以编写出更简洁、更易读的代码。模式匹配是一种声明式编程风格,即描述你想匹配什么,而不是如何匹配,这样写出的代码结构与数据结构是一致的。

切片

在 Python 中,列表、元组、字符串等所有序列类型都支持切片操作。切片比多数人认为的要强大很多。在切片、区间操作中排除最后一项是一种 Python 风格约定,这与很多其他语言中从零开始的索引相匹配。排除最后一项可以带来以下好处:

  • 在仅指定停止位置时,容易判断切片或区间的长度
  • 同时指定起始和停止位置时,容易计算切片或区间的长度,做个减法即可
  • 方便在索引 x 处把一个序列拆分成两部分而不产生重叠,直接使用 my_list[:x] 和my_list [x:] 即可

切片操作 s[start:stop:step] 中 step 指定步距,让切片操作跳过部分项。步距也可以是负数,反向返回项:

1
2
3
4
5
6
7
>>> s = "abcdefg"
>>> s[::3]
'adg'
>>> s[::-1]
'gfedcba'
>>> s[::-2]
'geca'

a:b:c 表示法只在 [​] 内部有效,表示索引或下标运算符,得到的结果是一个切片对象。为了求解表达式seq[start:stop:step]​,Python调用 seq.__getitem__(slice(start, stop, step))。我们可以给切片命名,以提供更好的代码可读性:

1
2
3
4
>>> text="jack 15 master"
>>> name=slice(0,4)
>>> text[name]
'jack'

[​] 运算符还可以接受多个索引或切片,以逗号分隔。负责处理 [​] 运算符的特殊方法 __getitem____setitem__ 把接收到的 a[i, j] 中的索引当作元组。也就是说,为了求解 a[i, j]​,Python调用 a.__getitem__((i, j))。例如 NumPy 包中,numpy.ndarray 表示的二维数组可以使用 a[i, j] 句法获取数组中的元素,还可以使用表达式 a[m:n, k:l] 获取二维切片。除了 memoryview 之外,Python 内置的序列类型都是一维的,因此只支持一个索引或切片,不支持索引或切片元组。

省略号写作 3 个句点 ...,Python 解析器把它识别为一个记号。省略号是 Ellipsis 对象的别名,而 Ellipsis 对象是 ellipsis 类的单例。因此,你可以把省略号作为参数传给函数,也可以写在切片规范中。存在这种句法是为了给用户定义的类型和扩展(例如 NumPy)提供支持,例如,对 Numpy 四维数组 x,x[i, ...]x[i, :, :, :,] 的快捷句法。

切片不仅可从序列中提取信息,还可以就地更改可变序列,即不重新构建序列。在赋值语句的左侧使用切片表示法,或者作为 del 语句的目标,可以就地移植、切除或以其他方式修改可变序列。需要注意,如果赋值目标是一个切片,则右边必须是一个可迭代对象,即使只有一项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
>>> l = [i for i in range(10)]
>>> l
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

>>> l[2:5]=[20,30,40]
>>> l
[0, 1, 20, 30, 40, 5, 6, 7, 8, 9]

>>> del l[8:]
>>> l
[0, 1, 20, 30, 40, 5, 6, 7]

>>> l[5::2]=[-1,-2]
>>> l
[0, 1, 20, 30, 40, -1, 6, -2]

>>> l[5::2] = 100
l[5::2] = 100
~^^^^^^
TypeError: must assign iterable to extended slice

>>> l[0:2]=[100]
>>> l
[100, 20, 30, 40, -1, 6, -2]

使用 +* 处理序列

序列支持 +*。通常,+ 的两个运算对象必须是同一种序列,而且都不可修改,拼接的结果是一个同类型的新序列。如果想多次拼接同一个序列,可以乘以一个整数。同样,结果是一个新创建的序列。

1
2
3
4
5
6
7
8
9
10
11
12
>>> [1, 2, 3] + [1, 2, 3]
[1, 2, 3, 1, 2, 3]

>>> [1, 2, 3] * 2
[1, 2, 3, 1, 2, 3]

>>> [1, 2, 3] + (1, 2, 3)
Traceback (most recent call last):
File "<python-input-25>", line 1, in <module>
[1, 2, 3] + (1, 2, 3)
~~~~~~~~~~^~~~~~~~~~~
TypeError: can only concatenate list (not "tuple") to list

+* 始终创建一个新对象,绝不更改操作数。注意 a * n 这种表达式,如果序列 a 中包含可变项,则结果可能出乎意料。例如,使用 my_list = [​[​]​] * 3 初始化一个嵌套列表,得到的结果是一个列表没错,但是嵌套的 3 个引用指向同一个列表,而你或许并不希望如此

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> x = [['_'] * 3 for i in range(3)]
>>> x
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> x[1][1] = '*'
>>> x
[['_', '_', '_'], ['_', '*', '_'], ['_', '_', '_']]

>>> x = [['_'] * 3] * 3
>>> x
[['_', '_', '_'], ['_', '_', '_'], ['_', '_', '_']]
>>> x[1][1] = '*'
>>> x
[['_', '*', '_'], ['_', '*', '_'], ['_', '*', '_']]

第二种用法中,外层列表内部的 3 个引用指向同一个列表,因此修改了其中一个元素也会影响到其他元素。这种问题的原因如同如下代码,它将同一个 row 向 board 中追加 3 次:

1
2
3
4
row = ['_'] * 3
board = []
for i in range(3):
board.append(row)

也可以使用增量赋值运算符处理序列,根据第一个操作数而定,增量赋值运算符 += 的行为差异较大。背后支持 += 运算符的是特殊方法 __iadd__(就地相加)​。对于 a += b

  • 如果 a 实现了 __iadd__,那就调用它
  • 如果 a 是可变序列(例如,list、bytearray、array.array)​,则就地修改 a
  • 倘若 a 没有实现 __iadd__ 方法,表达式 a += b 的作用等同于 a = a + b,先求解表达式 a + b,再把得到的新对象绑定到 a 上

a 绑定的对象身份可能变了,也可能没变,这取决于有没有实现 __iadd__ 方法。通常,对于可变序列,最好实现 __iadd__ 方法,而且 += 运算符就地修改。对于不可变序列,显然不能就地修改。如下以 *= 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> l = [1, 2, 3]
>>> id(l)
4318986432
>>> l *= 2
>>> id(l)
4318986432

>>> t = (1, 2, 3)
>>> id(t)
4318987328
>>> t *= 2
>>> t
(1, 2, 3, 1, 2, 3)
>>> id(t)
4316327328

再来看一个例子:

1
2
3
4
5
6
7
8
9
>>> t = (1, 2, [3, 4])
>>> t[2] += [5, 6]
Traceback (most recent call last):
File "<python-input-10>", line 1, in <module>
t[2] += [5, 6]
~^^^
TypeError: 'tuple' object does not support item assignment
>>> t
(1, 2, [3, 4, 5, 6])

这个例子 t[2] 被修改是容易理解的,但为什么又抛出错误呢,因为执行 t[2] += [5, 6] 就地修改列表对象后,还是要把列表对象重新赋值到 t[2],由于元组是不可变对象,因此操作失败。从这个例子,吸取了 3 个教训:

  • 不要在元组中存放可变的项
  • 增量赋值不是原子操作,这一点刚刚见识到,部分操作执行完毕后又抛出了异常。
  • 检查 Python 字节码并不太难,从中可以看出 Python 在背后做了什么
1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> dis.dis('s[a] += b')
0 RESUME 0

1 LOAD_NAME 0 (s)
LOAD_NAME 1 (a)
COPY 2
COPY 2
BINARY_SUBSCR
LOAD_NAME 2 (b)
BINARY_OP 13 (+=)
SWAP 3
SWAP 2
STORE_SUBSCR
RETURN_CONST 0 (None)

list.sort 与内置函数 sorted

list.sort 方法就地排序列表,即不创建副本,返回值为 None,目的就是提醒我们,它更改了接收者,没有创建新列表。这是 Python API 的一个重要约定:就地更改对象的函数或方法应该返回 None,让调用方清楚地知道接收者已被更改。但是这种方式也有缺点,这种方法不能级联调用。相反,返回新对象的方法(例如,str的所有方法)可以在流式接口(fluent interface)风格中级联调用。

与之相反,内置函数 sorted 返回创建的新列表。该函数接受任何可迭代对象作为参数,包括不可变序列和生成器。无论传入什么类型的可迭代对象,sorted 函数始终返回新创建的列表。

而且 Python 的排序算法是稳定的(即能够保留比较时相等的两项的相对顺序)​。这两个函数都接收可选的关键字参数:

  • reverse:值为 True 时,降序返回项
  • key:一个只接受一个参数的函数,应用到每一项上,作为排序依据

当列表不适用时

list 类型简单灵活,不过,针对具体的需求,或许还有更好的选择。例如,使用数组处理上百万个浮点值可以节省大量内存。另外,如果经常需要在列表的两端添加和删除项,使用 deque(double-ended queue,双端队列)更合适,这是一种更高效的 FIFO 数据结构。如果你在代码中经常检查容器中是否存在某一项,应考虑使用 set 类型存储。

数组

如果一个列表只包含数值,那么使用 array.array 会更高效。数组支持所有可变序列操作(包括 .pop.insert.extend,此外还有快速加载项和保存项的方法,例如 .frombytes.tofile

Python 数组像 C 语言数组一样精简。一个由 float 值构成的数组,存放的并不是完整的 float 对象,而是表示相应机器值的压缩字节,,与 C 语言中由 double 值构成的数组如出一辙。创建 array 对象时要提供类型代码,它是一个字母,用来确定底层使用什么 C 类型存储数组中各项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
>>> from array import array
>>> from random import random
>>> floats = array('d', (random() for i in range(10**7)))
>>> 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)
>>> fp.close()
>>> floats2[-1]
0.07802343889111107
>>> floats2 == floats
True

如果想使用数值数组表示二进制数据,Python有专门的类型:bytes 和 bytearray。

memoryview

内置的 memoryview 类是一种共享内存的序列类型,可在不复制字节的情况下处理数组的切片。memoryview 在数据结构之间共享内存,而不是事先复制。这对大型数据集来说非常重要。

memoryview.cast 方法使用的表示法与 array 模块类似,作用是改变读写多字节单元的方式,无须移动位。memoryview.cast 方法返回另一个 memoryview 对象,而且始终共享内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> from array import array
>>> octets = array('B', range(6))
>>> m1 = memoryview(octets)
>>> m1.tolist()
[0, 1, 2, 3, 4, 5]
>>> m2 = m1.cast('B', [2, 3])
>>> m2.tolist()
[[0, 1, 2], [3, 4, 5]]
>>> m3 = m1.cast('B', [3, 2])
>>> m3.tolist()
[[0, 1], [2, 3], [4, 5]]
>>> m2[1,1] = 22
>>> m3[1,1] = 33
>>> octets
array('B', [0, 1, 2, 33, 22, 5])

Numpy

如果你想对数组做一些高级数值处理,应该使用 NumPy 库。科学计算经常需要做一些高级数组和矩阵运算,得益于 NumPy,Python 成为这一领域的主流语言。NumPy 实现了多维同构数组和矩阵类型,除了存放数值之外,还可以存放用户定义的记录,而且提供了高效的元素层面操作。

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
>>> a = np.arange(12)
>>> a
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
>>> type(a)
<class 'numpy.ndarray'>
>>> a.shape
(12,)
>>> a.shape = 3, 4
>>> a
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
>>> a[2]
array([ 8, 9, 10, 11])
>>> a[2, 1]
9
>>> a[:, 1]
array([1, 5, 9])
>>> a.transpose()
array([[ 0, 4, 8],
[ 1, 5, 9],
[ 2, 6, 10],
[ 3, 7, 11]])

NumPy 还支持一些高级操作,例如加载、保存和操作 numpy.ndarray 对象的所有元素。在 NumPy 基础之上编写的 SciPy 库提供了许多科学计算算法,NumPy 和 SciPy 这两个库的功能异常强大,为很多优秀的工具提供了坚实的基础,例如 Pandas 和 scikit-learn。

至此我们已经讲了两种扁平序列:标准数组NumPy 数组

双端队列和其他队列

借助 .append.pop 方法,列表可以当作栈或队列使用(.append.pop(0) 实现的是先进先出行为)​。但是,在列表头部(索引位为 0)插入和删除项有一定开销,因为整个列表必须在内存中移动。collections.deque 类实现一种线程安全的双端队列,旨在快速在两端插入和删除项。如果需要保留 最后几项​,或者实现类似的行为,则双端队列是唯一选择,因为 deque 对象可以有界,即长度固定。有界的 deque 对象填满之后,从一端添加新项,将从另一端丢弃一项

1
2
3
4
5
6
7
8
9
10
>>> from collections import deque
>>> dq = deque(range(10), maxlen=10)
>>> dq
deque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
>>> dq.appendleft(-1)
>>> dq
deque([-1, 0, 1, 2, 3, 4, 5, 6, 7, 8], maxlen=10)
>>> dq.extend([10, 11, 12])
>>> dq
deque([2, 3, 4, 5, 6, 7, 8, 10, 11, 12], maxlen=10)

deque 实现了多数 list 方法,另外增加了一些专用方法,例如 popleft 和 rotate。不过,这里隐藏一个不太高效的操作:从 deque 对象中部删除项的速度不快。双端队列优化的是在两端增减项的操作。append 和popleft 是原子操作,因此你可以放心地在多线程应用中把 deque 作为先进先出队列使用,无须加锁。

除了 deque 之外,Python 标准库中的其他包还实现了以下队列:

  • queue:提供几个同步(即线程安全)队列类:SimpleQueue、Queue、LifoQueue和 PriorityQueue
  • multiprocessing:单独实现了无界的 SimpleQueue 和有界的 Queue。这与 queue 包中的队列类非常相似,只不过专门针对进程间通信
  • asyncio:提供了 Queue、LifoQueue、PriorityQueue 和 JoinableQueue,为管理异步编程任务而做了修改
  • heapq:提供了 heappush 和 heappop 等函数,可把可变序列当作堆队列或优先级队列使用

小结

若想写出简洁、有效和地道的 Python 代码,势必要掌握标准库中的各种序列类型。