本篇文章来聊一聊元组,元组可以简单理解为不支持元素添加、修改、删除等操作的列表,也就是在列表的基础上移除了增删改操作。
所以从功能上来讲,元组只是列表的子集,那元组存在的意义是什么呢?首先元组可以作为字典的 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
}
// 所以这里的 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。