解密 Python 元组的实现原理

文摘   2024-08-02 11:18   巴西  

楔子



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

所以从功能上来讲,元组只是列表的子集,那元组存在的意义是什么呢?首先元组可以作为字典的 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] 便指向了长度为 的 PyTupleObject 组成的链表的头结点。

至于每条链表的节点个数由 numfree 维护,并且最大不能超过 PyTuple_MAXFREELIST,默认是 2000

这里再来重新捋一下,元组的缓存池是一个数组,并且索引为 n - 1 的位置回收的是元素个数(ob_size)为 的元组,并且 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 = 1234

在编译时,上面的 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

古明地觉的编程教室
Python、Rust 程序猿,你感兴趣的内容我都会写,点个关注吧(#^.^#)
 最新文章