列表之外
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 字节。
二进制存取的实测
Section titled “二进制存取的实测”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 * 8memoryview:不拷贝,换视角
Section titled “memoryview:不拷贝,换视角”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 也指向同一块内存,
它反映的是当前状态。这正好证明了共享。
它也能用来「破坏」数据
Section titled “它也能用来「破坏」数据”>>> 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>>> numbersarray('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 代码。
deque:两端都快
Section titled “deque:两端都快”list 的 .append 和 .pop 能做栈;用 .pop(0) 能做队列,但很慢 ——
从头部删元素要把后面所有元素往前挪。
collections.deque 是双端队列,两端插入删除都快,而且是线程安全的。
>>> dq = deque(range(10), maxlen=10)>>> dq.rotate(3) # 右边的挪到左边>>> dqdeque([7, 8, 9, 0, 1, 2, 3, 4, 5, 6], maxlen=10)>>> dq.appendleft(-1)>>> dqdeque([-1, 1, 2, 3, 4, 5, 6, 7, 8, 9], maxlen=10)>>> dq.extend([11, 22, 33]) # 满了,从另一端丢>>> dqdeque([3, 4, 5, 6, 7, 8, 9, 11, 22, 33], maxlen=10)maxlen 是 deque 最有用的特性:给一个固定上限,满了自动从另一端丢弃。
「最近 N 条记录」这类需求不用自己维护,也不会无限增长。
deque 的隐藏成本
deque 实现了大部分 list 方法,但从中间删元素不快。它只对两端做了优化。
其他队列实现
Section titled “其他队列实现”| 模块 | 提供什么 |
|---|---|
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 * 8test_memoryview_shares_memory—— 两个视图各写一个字节,原始数组跟着变test_memoryview_can_poke_single_bytes—— 改一个字节把-1变成1024test_deque_maxlen_discards_from_the_other_end—— 满了自动丢test_extendleft_reverses_order——[1,2,3]变成[3,2,1]