本篇文章来说一说列表的扩容,我们知道列表在添加元素时,如果发现底层的指针数组已经满了,那么会进行扩容,申请一个更大的数组。
下面就来看看底层是怎么实现的,相关操作都位于 Objects/listobject.c 中。
static int
list_resize(PyListObject *self, Py_ssize_t newsize)
{
// 参数 self 就是列表,newsize 指的是元素在添加之后的 ob_size
// 比如列表的 ob_size 和容量都是 4,append 的时候发现容量不够
// 所以会扩容,那么这里的 newsize 就是 5
// 如果是 extend 添加 3 个元素,那么这里的 newsize 就是 7
// 当然 list_resize 这个函数不仅可以扩容,也可以缩容
// 假设列表原来有 1000 个元素,这个时候将列表清空了,那么容量肯定缩小,不然会浪费内存
// 如果清空了列表,那么这里的 newsize 显然就是 0
// 二级指针,指向指针数组的首元素
PyObject **items;
// 新的容量,以及新的指针数组的内存大小
size_t new_allocated, num_allocated_bytes;
// 获取原来的容量
Py_ssize_t allocated = self->allocated;
// 如果 newsize 达到了容量的一半,但还没有超过容量
// 那么意味着 newsize、或者新的 ob_size 和容量是匹配的
// 所以容量不会变化,直接将列表的 ob_size 设置为 newsize 即可
if (allocated >= newsize && newsize >= (allocated >> 1)) {
assert(self->ob_item != NULL || newsize == 0);
Py_SIZE(self) = newsize;
return 0;
}
// 走到这里说明容量和 newsize 不匹配了,所以要进行扩容或者缩容
// 因此要申请新的底层数组,那么长度是多少呢?
// 这里给出了公式,一会儿我们可以通过 Python 进行测试
new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6);
// 容量也是有范围的,乘上 8 字节不能超过 PY_SSIZE_T_MAX
if (new_allocated > (size_t)PY_SSIZE_T_MAX / sizeof(PyObject *)) {
PyErr_NoMemory();
return -1;
}
// 如果 newsize 为 0,那么容量也为 0
if (newsize == 0)
new_allocated = 0;
// 分配内存
num_allocated_bytes = new_allocated * sizeof(PyObject *);
items = (PyObject **)PyMem_Realloc(self->ob_item, num_allocated_bytes);
if (items == NULL) {
PyErr_NoMemory();
return -1;
}
// 将 ob_items 字段设置为 items
self->ob_item = items;
// 将 ob_size 字段设置为 newsize
Py_SIZE(self) = newsize;
// 将 allocated 字段设置为 new_allocated
self->allocated = new_allocated;
return 0;
}
我们看到还是很简单的,没有什么黑科技。然后是列表扩容的时候,容量和元素个数之间的规律。其实在 list_resize 函数中是有注释的,其中有这么一行。
说明我们往一个空列表中不断 append 元素的时候,容量会按照上面的规律进行变化,我们来试一下。
lst = []
allocated = 0
print("此时容量是: 0")
for item in range(100):
lst.append(item) # 添加元素
# 计算 ob_size
ob_size = len(lst)
# 判断 ob_size 和当前的容量
if ob_size > allocated:
# 列表的大小减去空列表的大小,再除以 8 显然就是容量
allocated = (lst.__sizeof__() - [].__sizeof__()) // 8
print(f"列表扩容啦, 新的容量是: {allocated}")
"""
此时容量是: 0
列表扩容啦, 新的容量是: 4
列表扩容啦, 新的容量是: 8
列表扩容啦, 新的容量是: 16
列表扩容啦, 新的容量是: 25
列表扩容啦, 新的容量是: 35
列表扩容啦, 新的容量是: 46
列表扩容啦, 新的容量是: 58
列表扩容啦, 新的容量是: 72
列表扩容啦, 新的容量是: 88
列表扩容啦, 新的容量是: 106
"""
我们看到和官方给的结果是一样的,显然这是毫无疑问的,根据底层的公式也能算出来。
ob_size = 0
allocated = 0
print(allocated, end=" ")
for item in range(100):
newsize = ob_size + 1
if newsize > allocated:
allocated = (newsize + (newsize >> 3) + 6) & ~3
print(allocated, end=" ")
ob_size = newsize
"""
0 4 8 16 24 32 40 52 64 76 92 108
"""
注:扩容是在添加元素的时候发现容量不够发生的,也就是底层数组存储的实际元素的个数(列表长度)等于数组长度,没办法再容纳新的元素了,所以要扩容。
如果我们直接通过 lst = [] 这种形式创建列表的话,那么其长度和容量是一样的。
lst = [0] * 1000
# 长度和容量一致
print(
len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8
) # 1000 1000
# 但再添加一个元素的话, 那么 ob_size 会变成 1001,大于容量 1000
# 所以此时列表就要扩容了, 执行 list_resize,里面的 new_size 就是 1001
# 然后是怎么分配容量来着,new_allocated = (size_t)newsize + (newsize >> 3) + (newsize < 9 ? 3 : 6)
print(
"新容量:", 1001 + (1001 >> 3) + (3 if 1001 < 9 else 6)
) # 新容量: 1132
# append 一个元素,列表扩容
lst.append(123)
# 计算容量
print((lst.__sizeof__() - [].__sizeof__()) // 8) # 1132
结果是一样的,因为底层就是这么实现的,所以结果必须一样。只不过我们通过这种测试的方式证明了这一点,也加深了对列表的认识。
需要注意的是,会影响列表元素个数的操作(append、extend、insert、pop 等等),在执行前都会先执行一下 list_resize 进行容量检测。如果计算之后的 newsize 和 allocated 之间的关系是匹配的,即 allocated//2 <= newsize <= allocated,那么只需要将 ob_size 的大小更新为 newsize 即可。如果不匹配,那么还要进行扩容,此时是一个 O(n) 的操作。
介绍完扩容,再来介绍缩容,因为列表元素个数要是减少到和容量不匹配的话,也要进行缩容。
举个生活中的例子,假设你租了 10 间屋子用于办公,显然你要付 10 间屋子的房租,不管你有没有用,一旦租了肯定是要付钱的。同理底层数组也是一样,只要你申请了,不管有没有元素,内存已经占用了。但有一天你用不到 10 间屋子了,假设要用 8 间或者 9 间,那么会让剩余的屋子闲下来。但由于退租比较麻烦,并且只闲下来一两间屋子,所以干脆就不退了,还是会付 10 间屋子的钱,这样没准哪天又要用的时候就不用重新租了。
对于列表也是如此,在删除元素(相当于屋子不用了)的时候,如果发现长度还没有低于容量的一半,那么也不会缩容。但反之就要缩容了,比如屋子闲了 8 间,也就是只需要两间屋子就足够了,那么此时肯定要退租了,闲了 8 间,可能会退掉 6 间。
lst = [0] * 1000
print(
len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8
) # 1000 1000
# 删除 500 个元素, 此时长度或者说 ob_size 就为 500
lst[500:] = []
# 但 ob_size 还是达到了容量的一半, 所以不会缩容
print(
len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8
) # 500 1000
# 如果再删除一个元素的话, 那么不好意思, 显然就要进行缩容了
# 因为 ob_size 变成了 499, 小于 1000 // 2
# 缩容之后容量怎么算呢? 还是之前那个公式
print(499 + (499 >> 3) + (3 if 499 < 9 else 6)) # 567
# 测试一下, 删除一个元素, 看看会不会按照我们期待的规则进行缩容
lst.pop()
print(
len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8
) # 499 567
一切都和我们想的是一样的,另外在代码中我们还看到一个 if 语句,就是如果 newsize 是 0,那么容量也是 0,来测试一下。
lst = [0] * 1000
print(
len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8
) # 1000 1000
lst[:] = []
print(
len(lst), (lst.__sizeof__() - [].__sizeof__()) // 8
) # 0 0
# 如果按照之前的容量变化公式的话, 会发现结果应该是 3
# 但实际结果是 0, 就是因为多了 if 判断
# 如果 newsize 是 0, 就把容量也设置为 0
print(0 + (0 >> 3) + (3 if 0 < 9 else 6)) # 3
为什么要这么做呢?因为 Python 认为,列表长度为 0 的话,说明你不想用这个列表了,所以多余的 3 个也没有必要申请了。
还以租房为栗,如果你一间屋子都不用了,说明你可能不用这里的屋子办公了,因此直接全部退掉。
以上就是列表在改变容量时所采用的策略,我们从头到尾全部分析了一遍。下一篇文章来看一下列表的创建,以及缓存池。
欢迎大家关注我的公众号:古明地觉的编程教室。
如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。