本篇文章来聊一聊元组,元组可以简单理解为不支持元素添加、修改、删除等操作的列表,也就是在列表的基础上移除了增删改操作。
所以从功能上来讲,元组只是列表的子集,那元组存在的意义是什么呢?首先元组可以作为字典的 key 以及集合的元素,因为字典和集合使用的数据结构是哈希表,它存储的元素一定是可哈希的,关于字典和集合我们后续章节会说。
而列表可以动态改变,所以列表不支持哈希。因此当我们希望字典的 key 是一个序列时,显然元组再适合不过了。比如要根据年龄和身高统计人数,那么就可以将年龄和身高组成元组作为字典的 key,人数作为字典的 value。所以元组可哈希,能够作为哈希表的 key,是元组存在的意义之一。当然元组还有其它作用,我们稍后再说。
元组如果可哈希,那么元组存储的元素必须都是可哈希的。只要有一个元素不可哈希,那么元组就会不可哈希。比如元组里面存储了一个列表,由于列表不可哈希,导致存储了列表的元组也会变得不可哈希。
元组的底层结构
根据我们使用元组的经验,可以得出元组是一个变长对象,但同时又是一个不可变对象。
// Include/cpython/tupleobject.h typedef struct { PyObject_VAR_HEAD PyObject *ob_item[1]; } PyTupleObject;
以上是元组在底层对应的结构体,包含引用计数、类型、ob_size、指针数组。然后数组声明的长度虽然是 1,但我们可以当成 n 来用。
然后我们再通过结构体的定义,来对比一下它和列表的区别。首先元组没有 allocated、也就是容量的概念,这是因为它是不可变的,不支持 resize 操作。
另一个区别就是元组对应的指针数组是定义在结构体里面的,可以直接对数组进行操作。而列表对应的指针数组是定义在结构体外面的,两者通过二级指针进行关联,也就是通过二级指针来间接操作指针数组。
至于为什么要这么定义,我们在最开始介绍对象模型的时候也说得很详细了。可变对象的具体元素不会保存在结构体内部,而是会维护一个指针,指针指向的内存区域负责存储元素。当发生扩容时,只需改变指针指向即可,从而方便内存管理。
基于结构体的定义,我们也能分析出元组所占的内存大小,显然它等于 24 + 8 * 元组长度。
结果没有问题。
元组是怎么创建的?
元组支持的操作我们就不看了,因为它只支持查询操作,并且和列表是高度相似的。这里我们直接来看元组的创建过程。
正如列表一样,解释器为创建 PyTupleObject 也提供了类似的初始化方法,即 PyTuple_New。
// Objects/tupleobject.c PyObject * PyTuple_New(Py_ssize_t size) { // 参数 size 表示元组的长度 PyTupleObject *op; // 如果 size 为 0,返回空数组 if (size == 0) { return tuple_get_empty(); } // 调用 tuple_alloc 为元组申请内存 op = tuple_alloc(size); // 如果返回的指针为 NULL,表示申请失败 if (op == NULL) { return NULL; } // 将指针数组中所有元素设置为 NULL for (Py_ssize_t i = 0; i < size; i++) { op->ob_item[i] = NULL; } // 让 GC 进行跟踪 _PyObject_GC_TRACK(op); // 转成泛型指针之后返回 return (PyObject *) op; }
相信这种代码逻辑现在对你来说已经没有任何难度了,然后我们看到里面调用了 tuple_alloc,该函数实际负责元组的内存申请过程,来看看它的内部实现。
// Objects/tupleobject.c static PyTupleObject * tuple_alloc(Py_ssize_t size) { // size 必须大于等于 0 if (size < 0) { PyErr_BadInternalCall(); return NULL; } // 优先从缓存池中获取,所以元组也有缓存池 // 关于元组的缓存池稍后再聊 PyTupleObject *op = maybe_freelist_pop(size); // 如果 op 为 NULL,说明缓存池无可用元素 if (op == NULL) { // size * sizeof(PyObject *) + sizeof(PyTupleObject) 便是元组大小 // 该值不能超过 PY_SSIZE_T_MAX,否则报错 if ((size_t)size > ((size_t)PY_SSIZE_T_MAX - (sizeof(PyTupleObject) - sizeof(PyObject *))) / sizeof(PyObject *)) { return (PyTupleObject *)PyErr_NoMemory(); } // 为 PyTupleObject 和长度为 size 的指针数组申请内存 // 然后将它的类型设置为 &PyTuple_Type,将 ob_size 设置为 size op = PyObject_GC_NewVar(PyTupleObject, &PyTuple_Type, size); if (op == NULL) return NULL; } // 返回 op return op; }
tuple_alloc 负责申请内存,当内存申请完毕之后,PyTuple_New 再将它的 ob_item 里面的元素设置为 NULL,即初始化。
以上就是元组创建的过程,但里面隐藏了很多的细节没有说,下面我们来介绍元组的缓存池,然后将细节一一揭开。
元组的缓存池
元组的缓存池也是通过数组来实现的。
// Include/internal/pycore_tuple.h #define PyTuple_MAXSAVESIZE 20 #define PyTuple_NFREELISTS PyTuple_MAXSAVESIZE #define PyTuple_MAXFREELIST 2000 struct _Py_tuple_state { PyTupleObject *free_list[PyTuple_NFREELISTS]; int numfree[PyTuple_NFREELISTS]; };
里面出现了三个宏:
- PyTuple_MAXSAVESIZE:缓存池的大小,默认为 20;
- PyTuple_NFREELISTS:缓存池的每个元素都对应一条链表(稍后解释),该宏表示链表的数量,因此它和 PyTuple_MAXSAVESIZE 是等价的,也表示缓存池的大小;
- PyTuple_MAXFREELIST:每个链表最多容纳多少个节点(稍后解释);
从定义中可以看到,元组的缓存池大小是 20,而我们之前介绍的列表的缓存池大小是 80。但这里的 20 和 80 还稍微有些不同,80 指的是列表缓存池的大小,除此之外没有别的含义。而 20 除了表示元组缓存池的大小之外,它还表示只有当元组的长度不超过 20,回收时才会被放入缓存池。
当元组的长度为 n 时(其中 n <= 20),那么在回收的时候该元组就会放在缓存池中索引为 n - 1 的位置。假设回收的元组长度为 6,那么就会放在缓存池索引为 5 的位置。
但是问题来了,如果要回收两个长度为 6 的元组该怎么办?很简单,像链表一样串起来就好了。所以 free_list 里面虽然存储的是 PyTupleObject *,但每个 (PyTupleObject *)->ob_item[0] 都存储了下一个 PyTupleObject *。
因此你可以认为 free_list 存储了 20 条链表的头结点的指针,每条链表上面挂着具有相同 ob_size 的 PyTupleObject。比如 free_list[n - 1] 便指向了长度为 n 的 PyTupleObject 组成的链表的头结点。
至于每条链表的节点个数由 numfree 维护,并且最大不能超过 PyTuple_MAXFREELIST,默认是 2000。
这里再来重新捋一下,元组的缓存池是一个数组,并且索引为 n - 1 的位置回收的是元素个数(ob_size)为 n 的元组,并且 n 不超过 20。但这样的话,具有相同长度的元组不就只能缓存一个了吗?比如我们有很多个长度为 2 的元组都要缓存怎么办呢?
显然将它们以链表的形式串起来即可,正如图中显示的那样。至于长度为 n 的元组究竟缓存了多少个,则由 numfree[n-1] 负责维护。假设 free_list[2] 这条链表上挂了 1000 个 PyTupleObject,那么 numfree[2] 就等于 1000,即长度为 3 的元组被缓存了 1000 个。
当再回收一个长度为 3 的元组时,那么会让该元组的 ob_item[0] 等于 free_list[2],然后 free_list[2] 等于该元组、numfree[2]++。所以这里的每一条链表和浮点数缓存池是类似的,也是采用的头插法。
我们看一下放入缓存池的具体过程,显然这一步发生在元组销毁的时候。
// Objects/tupleobject.c static void tupledealloc(PyTupleObject *op) { // 缓存池存放的是长度为 1 ~ 20 的元组 // 如果是空元组,那么它是单例的永恒对象,会永远存在 // 因此这里会直接返回,不做任何额外操作 if (Py_SIZE(op) == 0) { if (op == &_Py_SINGLETON(tuple_empty)) { return; } // 让 GC 不再跟踪 PyObject_GC_UnTrack(op); // 延迟释放,和列表是类似的 Py_TRASHCAN_BEGIN(op, tupledealloc) // 获取销毁的元组的长度 Py_ssize_t i = Py_SIZE(op); // 减少内部元素指向对象的引用计数,因为元组不再持有对它们的引用 while (--i >= 0) { Py_XDECREF(op->ob_item[i]); } // 尝试放入缓存池 if (!maybe_freelist_push(op)) { // 如果元组长度大于 20,或者缓存池已满 // 那么释放内存 Py_TYPE(op)->tp_free((PyObject *)op); } Py_TRASHCAN_END } /* 在前面的章节中我们说了,在 3.12 之前 对象的缓存池是以静态全局变量的形式定义在文件中,以 3.8 的元组缓存池为例 static PyTupleObject *free_list[PyTuple_MAXSAVESIZE]; static int numfree[PyTuple_MAXSAVESIZE]; 但在 3.12 的时候则被封装在了一个结构体(_Py_tuple_state)中 在解释器启动之后会静态初始化好,并绑定在进程状态对象上面 */ // 所以这里的 STATE 便负责获取元组的缓存池,即 _Py_tuple_state 结构体实例 // 它里面包含了 numfree 和 free_list #define STATE (interp->tuple) // 将元组放入缓存池 static inline int maybe_freelist_push(PyTupleObject *op) { #if PyTuple_NFREELISTS > 0 // 获取进程状态对象,interp->tuple 便是元组的缓存池 PyInterpreterState *interp = _PyInterpreterState_GET(); // 如果元组长度为 0,不做处理 if (Py_SIZE(op) == 0) { return0; } // free_list 里面的每个元素都指向了一个链表的头结点 // 每条链表存放的元组都具有相同的长度,如果元组长度为 n // 那么它会放在 free_list[n - 1] 对应的链表中 Py_ssize_t index = Py_SIZE(op) - 1; // index 必须小于 20,即元组长度不超过 20 // STATE.numfree[index] 必须小于 2000,即每条链表最多缓存 2000 个元组 if (index < PyTuple_NFREELISTS && STATE.numfree[index] < PyTuple_MAXFREELIST && Py_IS_TYPE(op, &PyTuple_Type)) { // ob_item[0] 充当了链表的 next 指针 // 这里让 op->ob_item[0] 等于 free_list[index] // 然后让 free_list[index] 等于 op // 这样元组就缓存起来了,并成为链表新的头结点,即 free_list[index] op->ob_item[0] = (PyObject *) STATE.free_list[index]; STATE.free_list[index] = op; // 然后维护一下链表的节点个数 STATE.numfree[index]++; OBJECT_STAT_INC(to_freelist); return1; } #endif return0; }
tupledealloc 函数在销毁元组时,会调用 maybe_freelist_push 函数,尝试放入缓存池中。那么同理,tuple_alloc 函数在创建元组时,也会调用 maybe_freelist_pop 函数,尝试从缓存池中获取。
// Objects/tupleobject.c static inline PyTupleObject * maybe_freelist_pop(Py_ssize_t size) { #if PyTuple_NFREELISTS > 0 // 获取进程状态对象 PyInterpreterState *interp = _PyInterpreterState_GET(); // size 不可能为 0 // 如果 size 为 0,那么在 PyTuple_New 中会直接返回空元组 if (size == 0) { return NULL; } assert(size > 0); // 缓存池中每个元素都指向一个链表的头结点 // PyTuple_NFREELISTS 表示链表的个数,PyTuple_MAXSAVESIZE 表示缓存池的大小 // 所以这两个宏是等价的,默认都是 20 // 只有元组的长度不超过 20 的时候,才会被缓存 if (size <= PyTuple_MAXSAVESIZE) { Py_ssize_t index = size - 1; // 获取链表的头节点 PyTupleObject *op = STATE.free_list[index]; if (op != NULL) { // 获取之后,它的下一个节点要成为新的头结点 STATE.free_list[index] = (PyTupleObject *) op->ob_item[0]; // 链表节点个数减 1 STATE.numfree[index]--; // 增加引用计数之后返回 _Py_NewReference((PyObject *)op); OBJECT_STAT_INC(from_freelist); return op; } } #endif return NULL; }
到此,相信你已经明白元组的缓存池到底是怎么一回事了,说白了就是有 20 条链表,分别负责缓存长度为 1 ~ 20 的元组,它们的头结点指针会保存在缓存池中。
然后每条链表的长度不超过 2000,也就是具有相同长度的元组最多回收 2000 个。至于链表的 next 指针,则由元组的 ob_item[0] 来充当,通过 ob_item[0] 来获取下一个节点。
我们看到打印的地址是一样的,因为第一次创建的元组被重复利用了。
那么问题来了,为什么元组缓存池可以缓存的元组个数会这么多,每个链表缓存 2000 个,有 20 条链表,总共可以缓存 40000 个。这么做的原因就是,元组的使用频率远比我们想象的广泛,主要是它大量使用在我们看不到的地方。比如多元赋值:
a, b, c, d = 1, 2, 3, 4
在编译时,上面的 1, 2, 3, 4 实际上是作为元组被加载的,整个赋值相当于元组的解包。再比如函数、方法的返回值,如果是多返回值,本质上也是包装成一个元组之后再返回。
所以元组缓存池能缓存的对象个数,要远大于其它对象的缓存池。可以想象一个大型项目,里面的函数、方法不计其数,只要是多返回值,就会涉及到元组的创建,因此每种长度的元组缓存 2000 个是很合理的。当然如果长度超过 20,就不会缓存了,这种元组的使用频率没有那么高。
然后再回顾一下元组的回收过程,会发现它和列表有一个很大的不同。列表在被回收时,它的指针数组会被释放;但元组不同,它在被回收时,底层的指针数组会保留,并且还巧妙地通过索引来记录了回收的元组的大小规格。
元组的这项技术也被称为静态资源缓存,因为元组在执行析构函数时,不仅对象本身没有被回收,连底层的指针数组也被缓存起来了。那么当再次分配时,速度就会快一些。
from timeit import timeit t1 = timeit(stmt="x1 = [1, 2, 3, 4, 5]", number=1000000) t2 = timeit(stmt="x2 = (1, 2, 3, 4, 5)", number=1000000) print(round(t1, 2)) # 0.05 print(round(t2, 2)) # 0.01
可以看到耗时,元组只是列表的五分之一。这便是元组的另一个优势,可以将资源缓存起来。而缓存的原因还是如上面所说,因为涉及大量的创建和销毁,所以这一切都是为了加快内存分配。
由于对象都在堆区,为了效率,Python 不得不大量使用缓存的技术。
最后再回答一个遗漏的部分,就是当元组长度为 0 的情况。我们说如果元组长度为 1 到 20,在回收时会被缓存起来,各自对应一条链表,链表上面能容纳的元素个数不超过 2000。
但如果长度为 0 呢?
从源码中可以看到,如果长度为 0,会调用 tuple_get_empty。
// Objects/tupleobject.c static inline PyObject * tuple_get_empty(void) { return Py_NewRef(&_Py_SINGLETON(tuple_empty)); } // Include/internal/pycore_global_objects.h struct _Py_static_objects { struct { // ... PyTupleObject tuple_empty; // ... } singletons; }; // Include/internal/pycore_runtime_init.h #define _PyRuntimeState_INIT(runtime) \ { \ .static_objects = { \ .singletons = { \ /* ... */ \ .tuple_empty = { \ .ob_base = _PyVarObject_HEAD_INIT(&PyTuple_Type, 0) \ }, \ /* ... */ \ }, \ }, \ }
所以长度为 0 的元组是一个静态单例对象,解释器启动之后就已经初始化好了,并且它也是一个永恒对象。
永恒对象的引用计数为 2 ** 32 - 1,并且不会发生变化。
小结
以上就是元组相关的内容,因为有了列表相关的经验,再来看元组就会快很多。当然啦,元组的一些操作我们没有说,因为和对应的列表操作是类似的。
最后再补充一下,列表是有 __init__ 方法的,而元组没有。
元组的 __init__ 直接继承 object.__init__。
对啦,再分享一个有趣的事情,就是元组的缓存池之前是有 Bug 的,我碰巧发现并修复了。具体细节可以阅读这篇文章:我帮 CPython 修复了一个 bug。