跳转到内容

列表之外

list 好用,但不是所有场合都合适:

你的情况 换成
只装数字,而且是几百万个 array.array
要在同一块内存上换不同视角看 memoryview
频繁在两端增删 collections.deque
频繁判断「某个元素在不在里面」 set(第 3 章)
要做矩阵和逐元素运算 NumPy

array:省的是「不给每个数造对象」

Section titled “array:省的是「不给每个数造对象」”

array.array 和 list 一样支持所有可变序列操作(.pop、.insert、.extend), 另外多了 .frombytes、.tofile 这类快速读写方法。

关键区别在内存布局:array 不存完整的 float 对象,只存机器值的打包字节。

创建时要给一个类型码,决定底层用哪种 C 类型存:

>>> from array import array
>>> array('b') # 有符号 char,每项 1 字节,范围 -128..127
>>> array('d') # C double,每项 8 字节
>>> array('h') # 短整型,2 字节

我实测了同样 1000 个浮点数:

list 对象(头 + 指针数组) 8056 字节
list 里 1000 个 float 对象 24000 字节
list 总占用 32056 字节
array 总占用 8080 字节

约 4 倍差距。24000 这个数字不是随便来的:内存里一个 float 对象有三个字段 (ob_refcnt、ob_type、ob_fval),64 位下每个 8 字节,共 24 字节。

floats = array('d', (random() for i in range(10**7)))
with open('floats.bin', 'wb') as fp:
floats.tofile(fp)

书里给的数字(1000 万个 double):

二进制 文本
大小 80,000,000 字节 181,515,739 字节
读回来耗时 ~0.1 秒 约 60 倍慢

大小没什么好说的:8 字节一个 double,零额外开销。文本格式下每个数字要十几字节, 还要解析。

我在测试里验证了小规模的情况 —— 100 个 double 存出来正好 800 字节:

equal, file_size = array_roundtrip(path, n=100)
assert file_size == 100 * 8

memoryview 让你在同一块内存上建多个视图,不用复制字节。它受 NumPy 启发而来。

「不拷贝」这件事有多大影响:处理大图片、SQLite 数据库、NumPy 数组时, 复制一次可能意味着几百 MB 的内存和一段明显的延迟。

>>> octets = array('B', range(6))
>>> m1 = memoryview(octets)
>>> m1.tolist()
[0, 1, 2, 3, 4, 5]
>>> m2 = m1.cast('B', [2, 3]) # 同样 6 字节,看成 2×3
>>> m2.tolist()
[[0, 1, 2], [3, 4, 5]]
>>> m3 = m1.cast('B', [3, 2]) # 看成 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])

cast 不移动任何位,只是换一种解读方式。三个视图指向同一块内存, 所以通过 m2 和 m3 写入都能在 octets 上看到。

我一开始测试写错了,断言 m1 改之前的值,结果失败 —— 因为 m1 也指向同一块内存, 它反映的是当前状态。这正好证明了共享。

>>> numbers = array('h', [-2, -1, 0, 1, 2])
>>> memv = memoryview(numbers)
>>> memv_oct = memv.cast('B')
>>> memv_oct.tolist()
[254, 255, 255, 255, 0, 0, 1, 0, 2, 0]
>>> memv_oct[5] = 4
>>> numbers
array('h', [-2, -1, 1024, 1, 2])

-1 的补码是 0xFFFF,两个字节都是 255。改成 4 之后,高位字节变成 0x04, 0x0400 就是 1024。

NumPy:不在标准库里,但值得绕路看一眼

Section titled “NumPy:不在标准库里,但值得绕路看一眼”

书里对 NumPy 的态度很明确:它是 Python 进入科学计算主流的原因。它提供:

  • 多维同构数组
  • 逐元素运算(不用写 Python 循环)
  • 切片、转置、形状变换
>>> import numpy as np
>>> a = np.arange(12)
>>> a.shape = 3, 4
>>> a[:, 1] # 取第 1 列
array([1, 5, 9])
>>> a.transpose()

「NumPy 的一切都是关于向量化的」—— 这句来自 Nicolas Rougier 的书。 向量化运算把数学函数应用到数组所有元素上,不用写显式循环;执行时可以并行, 用上现代 CPU 的向量指令,甚至交给 GPU。

书里提到一个对比:把一个用生成器方法的 Python 类改写成几个 NumPy 向量函数, 快了 500 倍。

SciPy 建在 NumPy 之上,提供线性代数、数值微积分、统计等算法,底层复用 Netlib 的 C 和 Fortran 代码。

list 的 .append 和 .pop 能做栈;用 .pop(0) 能做队列,但很慢 —— 从头部删元素要把后面所有元素往前挪。

collections.deque 是双端队列,两端插入删除都快,而且是线程安全的。

>>> dq = deque(range(10), maxlen=10)
>>> dq.rotate(3) # 右边的挪到左边
>>> dq
deque([7, 8, 9, 0, 1, 2, 3, 4, 5, 6], maxlen=10)
>>> dq.appendleft(-1)
>>> dq
deque([-1, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)
>>> dq.extend([11, 22, 33]) # 满了,从另一端丢
>>> dq
deque([3, 4, 5, 6, 7, 8, 9, 11, 22, 33], maxlen=10)

maxlen 是 deque 最有用的特性:给一个固定上限,满了自动从另一端丢弃。 「最近 N 条记录」这类需求不用自己维护,也不会无限增长。

deque 的隐藏成本

deque 实现了大部分 list 方法,但从中间删元素不快。它只对两端做了优化。

模块 提供什么
queue SimpleQueue、Queue、LifoQueue、PriorityQueue,线程间通信安全
multiprocessing Queue,为进程间通信设计;JoinableQueue 用于任务管理
asyncio Queue、LifoQueue、PriorityQueue、JoinableQueue,为协程设计
heapq 不是类,是 heappush / heappop 等函数,把普通列表当堆用

queue 里的队列和 deque 有个重要区别:满了不是丢弃,是阻塞。 插入时等别的线程取走元素腾出空间。这个特性用来限制活跃线程数很合适。

  • test_array_is_much_more_compact_than_list —— 比较总占用而不是对象头大小
  • test_array_binary_roundtrip —— 文件大小正好 n * 8
  • test_memoryview_shares_memory —— 两个视图各写一个字节,原始数组跟着变
  • test_memoryview_can_poke_single_bytes —— 改一个字节把 -1 变成 1024
  • test_deque_maxlen_discards_from_the_other_end —— 满了自动丢
  • test_extendleft_reverses_order —— [1,2,3] 变成 [3,2,1]