Jiacheng

aha

野比大雄

Python 内部的优化技巧: list 和 tuple

Jiacheng / 2019-03-23


Python 有两种类似的有序集合类型,list 和 tuple。 众所周知,它们之间最大的区别: tuple 是不可变的,即不能改变 tuple 的大小以及 tuple 内不可变元素。

你不能改变 tuple 中的元素:

>>> a = (1,2,3)
>>> a[0] = 10
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment

但是 tuple 里面的可变元素是可以更改的:

>>> b = (1,[1,2,3],3)
>>> b[1]
[1, 2, 3]
>>> b[1].append(4)
>>> b
(1, [1, 2, 3, 4], 3)

在内部,list 和 tuple 都是用来存放 Python 对象指针的列表。 从列表中删除项时,对象的引用将被销毁。 但是如果程序中有其他引用,那么被删除的项目可以继续存在。

Tuple

尽管实际上 tuple 没有 list 那么常用,但它是一种基本的数据类型,在内部经常使用。 在一些没有注意到的情况下是使用 tuple 的,比如:

通常,一个正在运行的程序有数千个已分配的 tuple 。

import gc                                                               
def type_stats(type_obj):  
    count = 0 
    for obj in gc.get_objects():  
        if type(obj) == type_obj: 
            count += 1 
    return count 
                                                                       
type_stats(tuple)                                                       
12083
type_stats(list)                                                        
4034

空 list 与 空 tuple

空 tuple 类似于一个单例,也即,总是只存在一个长度为零的 tuple。 创建新的空 tuple 时,会指向已经预先分配的空 tuple,这样任何空 tuple 在内存中都有相同的地址。 这是因为 tuple 是不可变的,有时候这样可以节省大量内存。

In [1]: a = ()                                                                  
In [2]: b = ()                                                                  
In [3]: a is b                                                                  
Out[3]: True
In [4]: id(a)                                                                   
Out[4]: 4445229128
In [5]: id(b)                                                                   
Out[5]: 4445229128

但 list 却不是这样的,因为 list 可以被修改。

In [1]: a = []                                                                  
In [2]: b = []                                                                  
In [3]: a is b                                                                  
Out[3]: False
In [4]: id(a)                                                                   
Out[4]: 4454703304
In [5]: id(b)                                                                   
Out[5]: 4454687240

tuple 的内存分配优化

为减少内存碎片和加速分配,Python 会重用旧的 tuple。 如果一个 tuple 不再需要并且只有少于20个元素,Python 不会将其永久删除,而是将其移动到一个空闲列表(free list)中。 一个空闲列表被分成20个组,每个组代表一个长度在 0 到 20 之间的 tuple 列表。 每个组最多可以存储 2000 个tuple。 第一个组只包含 1 个空的 tuple。

>>> a = (1,2,3)
>>> id(a)
4412372024
>>> del a 
>>> b = (1,2,4)
>>> id(b)
4412372024
这段代码在 ipython 中没有复现。

在上面的例子中,我们可以看到 a 和 b 有相同的 id。 这是因为我们立即占用一个被销毁的 tuple,这个 tuple 销毁后被放在空闲列表中。

list 的内存分配优化

由于 list 是可变的,Python 不会使用与 tuple 的相同优化方式。 然而,list 也有一个空闲列表,但它只用于空对象。 如果一个空列表被删除或 GC 回收,它可以在以后重用。

>>> a = []
>>> id(a)
4412088136
>>> del a
>>> b = []
>>> id(b)
4412088136

调整 list 的大小(resize)

为避免调整大小的开销,Python 不会在每次需要添加或删除某个项时都调整 list 的大小。 因为每个 list 都有一些空的位置,只不过这些空位置用户看不到,但可以用来添加新的元素。 如果空位置已用完,Python 就会分配额外的空间增加新的空位置。 新增空位置的数量由 list 当前的大小决定。

Python 开发者文档描述如下: 这种额外分配与 list 大小成正比,为后续增长留出空间。 这种额外分配是轻量的,足以在性能不佳的系统 realloc 存在的情况下,保证长序列 append 时间线性增长。

list 长度的增长模式为: 0, 4, 8, 16, 25, 35, 46, 58, 72, 88,…

注意: 新分配的值不会溢出,因为最大的可能值为 PY_SSIZE_T_MAX * (9 / 8) + 6 1,它总是小于 size_t2的值。

例如,如果要将一个元素 append 到一个长度为 8 的 list 中,Python 会将其长度调整为 16 ,并将此元素添加第 9 个位置。 其余的位置将被隐藏并保留给新的元素。

resizing 增长因子如下:

>>> def get_new_size(n_items):
...     new_size = (size_t)n_items + (n_items >> 3) + (n_items < 9 ? 3 : 6);
...     return new_size
...
>>> get_new_size(9)
16

这是一篇译文,阅读原文

References

sys.maxsize in Python

resize implemented in CPython


  1. PY_SSIZE_T_MAX 在 32 位系统为 231 -1 ,在 64 位系统为 263 -1

  2. 在 32 位系统上为 unsigned int ,在 64 位系统上为 unsigned long int