Skip to content

Commit

Permalink
元组的底层实现
Browse files Browse the repository at this point in the history
  • Loading branch information
satori1995 committed Dec 15, 2024
1 parent 94a3f3d commit d693e24
Show file tree
Hide file tree
Showing 4 changed files with 306 additions and 1 deletion.
304 changes: 304 additions & 0 deletions src/38.解密Python元组的实现原理.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
## 楔子

本篇文章来聊一聊元组,元组可以简单理解为<font color="blue">不支持元素添加、修改、删除等操作的列表</font>,也就是在列表的基础上移除了<font color="blue">增删改</font>操作。

所以从功能上来讲,元组只是列表的子集,那元组存在的意义是什么呢?首先元组可以作为字典的 key 以及集合的元素,因为字典和集合使用的数据结构是哈希表,它存储的元素一定是可哈希的,关于字典和集合我们后续章节会说。

而列表可以动态改变,所以列表不支持哈希。因此当我们希望字典的 key 是一个序列时,显然元组再适合不过了。比如要根据年龄和身高统计人数,那么就可以将<font color="blue">年龄和身高</font>组成元组作为字典的 key,人数作为字典的 value。所以元组可哈希,能够作为哈希表的 key,是元组存在的意义之一。当然元组还有其它作用,我们稍后再说。

> 元组如果可哈希,那么元组存储的元素必须都是可哈希的。只要有一个元素不可哈希,那么元组就会不可哈希。比如元组里面存储了一个列表,由于列表不可哈希,导致存储了列表的元组也会变得不可哈希。
## 元组的底层结构

根据我们使用元组的经验,可以得出元组是一个变长对象,但同时又是一个不可变对象。

~~~c
// Include/cpython/tupleobject.h
typedef struct {
PyObject_VAR_HEAD
PyObject *ob_item[1];
} PyTupleObject;
~~~

以上是元组在底层对应的结构体,包含引用计数、类型、ob_size、指针数组。然后数组声明的长度虽然是 1,但我们可以当成 n 来用。然后我们再通过结构体的定义,来对比一下它和列表的区别。

+ 元组没有 allocated、也就是容量的概念,这是因为它是不可变的,不支持 resize 操作。
+ 元组对应的指针数组是定义在结构体里面的,可以直接对数组进行操作。而列表对应的指针数组是定义在结构体外面的,两者通过二级指针进行关联,也就是通过二级指针来间接操作指针数组。

至于为什么要这么定义,我们在最开始介绍对象模型的时候也说得很详细了。可变对象的具体元素不会保存在结构体内部,而是会维护一个指针,指针指向的内存区域负责存储元素。当发生扩容时,只需改变指针指向即可,从而方便内存管理。

基于结构体的定义,我们也能分析出元组所占的内存大小,显然它等于 <font color="blue">24 + 8 * 元组长度</font>。

~~~python
>>> ().__sizeof__()
24
>>> (1,).__sizeof__()
32
>>> (1, 2).__sizeof__()
40
~~~

结果没有问题。

## 元组是怎么创建的?

元组支持的操作我们就不看了,因为它只支持查询操作,并且和列表是高度相似的。这里我们直接来看元组的创建过程。正如列表一样,解释器为创建 PyTupleObject 也提供了类似的初始化方法,即 PyTuple_New。

~~~C
// Objects/tupleobject.c
PyObject *
PyTuple_New(Py_ssize_t size)
{
// 参数 size 表示元组的长度
// op 指向创建的元组
PyTupleObject *op;
// 循环变量
Py_ssize_t i;
// size 必须大于等于 0
if (size < 0) {
PyErr_BadInternalCall();
return NULL;
}
// PyTuple_MAXSAVESIZE 是一个宏,显然和缓存池相关
// 关于缓存池我们一会儿再说
#if PyTuple_MAXSAVESIZE > 0
if (size == 0 && free_list[0]) {
op = free_list[0];
Py_INCREF(op);
return (PyObject *) op;
}
if (size < PyTuple_MAXSAVESIZE && (op = free_list[size]) != NULL) {
free_list[size] = (PyTupleObject *) op->ob_item[0];
numfree[size]--;
_Py_NewReference((PyObject *)op);
}
else
#endif
// 当不使用缓存池时,要在系统堆上申请内存
{
// 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 PyErr_NoMemory();
}
// 为 PyTupleObject 和长度为 size 的指针数组申请内存
// 然后将它的类型设置为 &PyTuple_Type,将 ob_size 设置为 size
op = PyObject_GC_NewVar(PyTupleObject, &PyTuple_Type, size);
if (op == NULL)
return NULL;
}
// 将指针数组中所有元素设置为 NULL
for (i=0; i < size; i++)
op->ob_item[i] = NULL;
#if PyTuple_MAXSAVESIZE > 0
if (size == 0) {
free_list[0] = op;
++numfree[0];
Py_INCREF(op); /* extra INCREF so that this is never freed */
}
#endif
// 让 GC 进行跟踪
_PyObject_GC_TRACK(op);
// 转成泛型指针之后返回
return (PyObject *) op;
}
~~~
相信这种代码逻辑现在对你来说已经没有任何难度了,另外源码中还有几个宏,不过不重要,因此这里直接去掉了。
以上就是元组创建的过程,但里面隐藏了很多的细节没有说,下面我们来介绍元组的缓存池,然后将细节一一揭开。
## 元组的缓存池
元组的缓存池也是通过数组来实现的。
~~~C
// Objects/tupleobject.c
#define PyTuple_MAXSAVESIZE 20
#define PyTuple_MAXFREELIST 2000
static PyTupleObject *free_list[PyTuple_MAXSAVESIZE];
static int numfree[PyTuple_MAXSAVESIZE];
~~~

里面出现了两个宏:

+ PyTuple_MAXSAVESIZE:缓存池的大小,默认为 20;
+ PyTuple_MAXFREELIST:缓存池的每个元素都对应一条链表,该宏表示每条链表最多容纳多少个节点(稍后解释);

从定义中可以看到,元组的缓存池大小是 20,而我们之前介绍的列表的缓存池大小是 80。但这里的 20 和 80 还稍微有些不同,80 指的是列表缓存池的大小,除此之外没有别的含义。而 20 除了表示元组缓存池的大小之外,它还表示只有当元组的长度小于 20,回收时才会被放入缓存池。

当元组的长度为 n 时(其中 n < 20),那么在回收的时候该元组就会放在缓存池中索引为 <font color="blue">n</font> 的位置。假设回收的元组长度为 6,那么就会放在缓存池索引为 6 的位置。

但是问题来了,如果要回收两个长度为 6 的元组该怎么办?很简单,像链表一样串起来就好了。所以 free_list 里面虽然存储的是 PyTupleObject \*,但每个 `(PyTupleObject *)->ob_item[0]`都存储了下一个 PyTupleObject \*

因此你可以认为 free_list 存储了 20 条链表的头结点的指针,每条链表上面挂着具有相同 ob_size 的 PyTupleObject。比如 <font color="blue">free_list[n]</font> 便指向了长度为 n 的 PyTupleObject 组成的链表的头结点,至于每条链表的节点个数由 numfree 维护,并且最大不能超过 PyTuple_MAXFREELIST,默认是 2000。

![](./images/105.png)

这里再来重新捋一下,元组的缓存池是一个数组,并且索引为 <font color="blue">n</font> 的位置回收的是元素个数(ob_size)为 n 的元组,并且 n 不超过 20。但这样的话,具有相同长度的元组不就只能缓存一个了吗?比如我们有很多个长度为 2 的元组都要缓存怎么办呢?显然将它们以链表的形式串起来即可,正如图中显示的那样。至于长度为 n 的元组究竟缓存了多少个,则由 <font color="blue">numfree[n]</font> 负责维护。假设 free_list[2] 这条链表上挂了 1000 个 PyTupleObject,那么 numfree[2] 就等于 1000,即长度为 2 的元组被缓存了 1000 个。

当再回收一个长度为 2 的元组时,那么会让该元组的 ob_item[0] 等于 free_list[2],然后 free_list[2] 等于该元组、numfree[2]++。所以这里的每一条链表和浮点数缓存池是类似的,也是采用的头插法。

我们看一下放入缓存池的具体过程,显然这一步发生在元组销毁的时候。

~~~C
// Objects/tupleobject.c
static void
tupledealloc(PyTupleObject *op)
{
// 循环变量
Py_ssize_t i;
// 回收的元组的长度
Py_ssize_t len = Py_SIZE(op);
// 让 GC 不再跟踪
PyObject_GC_UnTrack(op);
// 延迟释放,和列表是类似的
Py_TRASHCAN_BEGIN(op, tupledealloc)

if (len > 0) {
i = len;
// 减少内部元素指向对象的引用计数,因为元组不再持有对它们的引用
while (--i >= 0)
Py_XDECREF(op->ob_item[i]);
#if PyTuple_MAXSAVESIZE > 0
// 回收的元组的长度必须小于 20,即元组长度不超过 20
// 并且 numfree[index] 必须小于 2000,即每条链表最多缓存 2000 个元组
if (len < PyTuple_MAXSAVESIZE &&
numfree[len] < PyTuple_MAXFREELIST &&
Py_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 *) free_list[len];
// 然后维护一下链表的节点个数
numfree[len]++;
free_list[len] = op;
goto done; /* return */
}
#endif
}
// 如果元组长度大于等于 20,或者缓存池已满,那么释放内存
Py_TYPE(op)->tp_free((PyObject *)op);
done:
Py_TRASHCAN_END
}
~~~
tupledealloc 函数在销毁元组时,会尝试放入缓存池中。那么同理,在创建元组时,也会尝试从缓存池中获取。我们再回过头看一下 PyTuple_New 这个函数,重新解释一下里面的细节。
~~~C
// Objects/tupleobject.c
PyObject *
PyTuple_New(Py_ssize_t size)
{
// ...
#if PyTuple_MAXSAVESIZE > 0
// 回收的元组的长度为 0 时比较特殊,一会单独说
if (size == 0 && free_list[0]) {
op = free_list[0];
Py_INCREF(op);
return (PyObject *) op;
}
// 当 0 < size < 20 时,直接通过 op = free_list[size] 从缓存池获取
if (size < PyTuple_MAXSAVESIZE && (op = free_list[size]) != NULL) {
// 元组取走后,别忘记让 free_list[size] 指向下一个元素
// 也就是 (PyTupleObject *) op->ob_item[0]
free_list[size] = (PyTupleObject *) op->ob_item[0];
// 维护对应的链表长度
numfree[size]--;
// 引用计数初始化为 1
_Py_NewReference((PyObject *)op);
}
else
#endif
// ...
#if PyTuple_MAXSAVESIZE > 0
if (size == 0) {
free_list[0] = op;
++numfree[0];
Py_INCREF(op);
}
#endif
_PyObject_GC_TRACK(op);
return (PyObject *) op;
}
~~~

到此,相信你已经明白元组的缓存池到底是怎么一回事了,说白了就是有 20 条链表,索引为 n 的链表存放长度为 n 的元组,因此可回收的元组的最大长度是 19。然后每条链表的长度小于 2000,也就是具有相同长度的元组最多回收 2000 个。至于链表的 next 指针,则由元组的 ob_item[0] 来充当,通过 ob_item[0] 来获取下一个元素。

~~~Python
>>> tpl = (1, 2, 3)
>>> print(id(tpl))
2279295395264
>>>
>>> del tpl # 放入缓存池
>>>
>>> tpl = ("古明地觉", "古明地恋", "芙兰朵露")
>>> print(id(tpl))
2279295395264
~~~

可以看到打印的地址是一样的,因为第一次创建的元组被重复利用了。

另外我们说缓存池的长度为 20,会缓存长度为 0 ~ 19 的元组,每种规格的元组最多缓存 2000 个。其实这个说法不太严谨,应该说长度为 1 ~ 19 的元组会缓存 2000 个。如果元组长度为 0,那么它对应的链表只会容纳一个元素,这也说明了不管我们创建多少个空元组,最终在内存中只会存在一个。

~~~python
tpl1 = ()
tpl2 = ()
tpl3 = ()

print(id(tpl1) == id(tpl2) == id(tpl3)) # True
~~~

再来看看 PyTuple_New 这个函数:

![](./images/106.png)

从缓存池中获取之后只是增加了引用计数,因为长度为 0 的元组只会缓存一个。所以空元组可以认为是单例的,只有一份。

那么问题来了,为什么元组缓存池可以缓存的元组个数会这么多,每个链表缓存 2000 个,有 20 条链表,总共可以缓存将近 40000 个。这么做的原因就是,元组的使用频率远比我们想象的广泛,主要是它大量使用在我们看不到的地方。比如多元赋值:

~~~Python
a, b, c, d = 1, 2, 3, 4
~~~

在编译时,上面的 <font color="blue">1, 2, 3, 4</font> 实际上是作为元组被加载的,整个赋值相当于元组的解包。再比如函数、方法的返回值,如果是多返回值,本质上也是包装成一个元组之后再返回。

所以元组缓存池能缓存的对象个数,要远大于其它对象的缓存池。可以想象一个大型项目,里面的函数、方法不计其数,只要是多返回值,就会涉及到元组的创建,因此每种长度的元组缓存 2000 个是很合理的。当然如果长度达到了 20,就不会缓存了,这种元组的使用频率没有那么高。

然后再回顾一下元组的回收过程,会发现它和列表有一个很大的不同。列表在被回收时,它的指针数组会被释放;但元组不同,它在被回收时,底层的指针数组会保留,并且还巧妙地通过索引来记录了回收的元组的大小规格。元组的这项技术也被称为<font color="blue">静态资源缓存</font>,因为元组在执行析构函数时,<font color="blue">不仅对象本身没有被回收,连底层的指针数组也被缓存起来了</font>。那么当再次分配时,速度就会快一些。

~~~Python
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 不得不大量使用缓存的技术。
## 小结

以上就是元组相关的内容,因为有了列表相关的经验,再来看元组就会快很多。当然啦,元组的一些操作我们没有说,因为和对应的列表操作是类似的。

-----

&nbsp;

**欢迎大家关注我的公众号:古明地觉的编程教室。**

![](./images/qrcode_for_gh.jpg)

**如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。**

![](./images/supports.png)
3 changes: 2 additions & 1 deletion src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@
+ [34. 列表是怎么扩容的?](34.列表是怎么扩容的?.md)
+ [35. 解密列表的创建与销毁,以及缓存池长什么样子?](35.解密列表的创建与销毁,以及缓存池长什么样子?.md)
+ [36. 列表作为序列型对象都支持哪些操作,它们在底层是怎么实现的?](36.列表作为序列型对象都支持哪些操作,它们在底层是怎么实现的?.md)
+ [37. 列表都有哪些自定义方法,它们是怎么实现的?](37.列表都有哪些自定义方法,它们是怎么实现的?.md)
+ [37. 列表都有哪些自定义方法,它们是怎么实现的?](37.列表都有哪些自定义方法,它们是怎么实现的?.md)
+ [38. 解密 Python 元组的实现原理](38.解密Python元组的实现原理.md)
Binary file added src/images/105.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/images/106.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit d693e24

Please sign in to comment.