Python源码之dict字典底层解析

本文深入探讨了Python字典dict的底层实现,包括PyDictObject、PyDictKeyEntry、PyDictKeysObject的结构,以及dict的创建、entry的三种状态、元素的插入与删除操作。文章详细解释了Python如何利用哈希表和探测序列处理冲突,以及对象缓冲池技术,旨在揭示dict高效运作的机制。
摘要由CSDN通过智能技术生成


  python中的dict可能我们最常用的容器之一了,它是一种用来存储某种关系映射的数据对的集合。在其他语言中例如Java也有相应的容器类型,例如map。它们底层实现的方式也不尽相同,而我们Python中的dict底层怎么实现的呢?实际上它就是一个Hash Table,由于其查找效率非常高所以在实际开发中,我们经常使用这个中数据容器。关于hash table我们在这里就不展开取讲述了,如果不清楚的可以去看数据结构的Hash Table原理。
  关于Hash Table我们这里只提一点,我们知道在将键通过hash function散列的时候,不同的对象可能会生成相同的hash值,也就是“哈希冲突”,显然这是我们所不允许的。因此我们需要对这种冲突进行处理,在Python中选择的处理方式是 “开放定址法” 所采用的策略是 “二次探测再散列” 。也就是说当出现哈希冲突的时候,会通过一个 “二次数列” 的地址偏量再次进行探测直到找到一个可以放下元素的位置。在这个再次探测的过程中就会形成一个探测序列,可以试想一下这个问题,假如探测序列上的某个元素被删除了会出现什么问题?没错,这个探测序列就被中断了,假如我们需要查找的元素在这个被删除的元素之后,那么我们就search不到这个元素了。这显然是不行的,因此在采用开放定址发的策略中必须解决这个问题,而Python的解决方式就是使用dummy的删除方式。这个我们后面再讲。

1、PyDictObject

  PyDictObject对象包含很多子结构,整个结构相对比较复杂,因此我们有必要先了解清楚整个PyDictObject的内存构造以及子结构。

  • 1.2 PyDictKeyEntry

  我们知道dict中实际上存储的是键值对,那么这个键值对是以什么样的形式存在的呢?接下来就看一看键值对在底层是如何定义的

// dict-common.h
typedef struct {
   
    /* Cached hash code of me_key. */
    Py_hash_t me_hash;
    PyObject *me_key;
    PyObject *me_value; /* This field is only meaningful for combined tables */
} PyDictKeyEntry;

  可以看到这里面有三个变量,me_hash是用来缓存键的哈希值,这样可以避免每次查询的时候重复计算。me_key和me_value这两个域用来存储键和值,可以看见它们都是PyObject *类型,因此dict可以存储各种类型对象。

  • 1.3PyDictKeysObject

  从命名我们就知道它是和字典中的key相关的一个结构体,我们看看它的定义

// dict-common.h
/* dict_lookup_func() returns index of entry which can be used like DK_ENTRIES(dk)[index].
 * -1 when no entry found, -3 when compare raises error.
 */
typedef Py_ssize_t (*dict_lookup_func)
(PyDictObject *mp, PyObject *key, Py_hash_t hash, PyObject ***value_addr,
 Py_ssize_t *hashpos);
 
struct _dictkeysobject {
   
    Py_ssize_t dk_refcnt;
    Py_ssize_t dk_size;
    dict_lookup_func dk_lookup;
    Py_ssize_t dk_usable;
    Py_ssize_t dk_nentries;
    /* Actual hash table of dk_size entries. It holds indices in dk_entries,
       or DKIX_EMPTY(-1) or DKIX_DUMMY(-2).
       Indices must be: 0 <= indice < USABLE_FRACTION(dk_size).
       The size in bytes of an indice depends on dk_size:
       - 1 byte if dk_size <= 0xff (char*)
       - 2 bytes if dk_size <= 0xffff (int16_t*)
       - 4 bytes if dk_size <= 0xffffffff (int32_t*)
       - 8 bytes otherwise (int64_t*)
       Dynamically sized, 8 is minimum. */
    union {
   
        int8_t as_1[8];
        int16_t as_2[4];
        int32_t as_4[2];
#if SIZEOF_VOID_P > 4
        int64_t as_8[1];
#endif
    } dk_indices;
};

  是不是看着有点懵圈?别急我们慢慢解读。
dk_refcnt是指引用计数;
dk_size是指hash table的大小,也就是dk_indices的大小,它的值必须为2的幂;
dk_lookup是哈希表的操作相关的函数,它被定义为一个函数指针,内部间接调用了search相关操作以及处理冲突的策略;
dk_usable指dk_entries中的可用的键值对数量;
dk_nentries指dk_entries中已经使用的键值对数量;
dk_indices是一个共用体,其内部成员变量共享一片内存,其成员变量是一个数组,用于存储dk_entries的哈希索引,它具体结构是什么可先不用管,后面我们会详细解析。需要注意的是数组中的元素类型会随着hash table的大小变化,代码注释中也显示了当哈希表大小 <= 128 时,索引数组的元素类型为 int_8_t,这是C语言中的一种结构标注,并非新的数据类型,它使用typedef定义的,它代表了一个有符号的char类型占用一个字节。int_16_t表示当哈希表大小<=0xffff时用两个字节,也就是short,以此类推。这样做可以节省内存使用;
我们看到的dk_entries是个什么东西?为什么一直说到它?它实际上也是一个数组,数组的元素类型就是PyDictKeysEntry,一个键值对就是一个entry。

  • 1.4 PyDictObject

PyDictObject就是dict的底层实现啦,我们也来看看它的定义

// dictobject.h
typedef struct _dictkeysobject PyDictKeysObject;
/* The ma_values pointer is NULL for a combined table
 * or points to an array of PyObject* for a split table
 */
typedef struct {
   
    PyObject_HEAD
    /* Number of items in the dictionary */
    Py_ssize_t ma_used;
    /* Dictionary version: globally unique, value change each time
       the dictionary is modified */
    uint64_t ma_version_tag;
    PyDictKeysObject *ma_keys;
    /* If ma_values is NULL, the table is "combined": keys and values
       are stored in ma_keys.
       If ma_values is not NULL, the table is splitted:
       keys are stored in ma_keys and values are stored in ma_values */
    PyObject **ma_values;
} PyDictObject;

  ma_used字段用以存储字典中元素的个数;ma_version_tag字段代表字典的版本,全局唯一,每一次字典改变它的值都会改变;如果ma_values的值为NULL,这张表为combained,此时key和value的值都存储在ma_keys中,如果ma_values的值不为NULL,这张表为split,key都存在ma_keys中,而value存储在ma_values这个数组中。我们用一张图来增加我们的理解
在这里插入图片描述
是不是突然就恍然大悟了,一张图胜过千言万语,哈哈哈。

2、探究entry三种状态

  在操作系统中我们知道进程有三种存活的状态:运行状态,阻塞状态,就绪状态。在Python中,当PyDictObject发生变化时,entry会在三种状态中切换,那么这三种entry的状态究竟是怎么样一回事呢?我们来看看。

  • unused态:当entry中的me_key字段和me_value字段都为NULL时,此时的entry处于unsed态,处于此状态的entry表明这个entry没有存储任何键值对,并且之前也没有存储过键值对。任何entry在初始化的时候都处于这个状态,而且me_key只有在这个状态下才为NULL,而me_value都可能为0,这取决于表的方式是combined还是split。
  • active态:当entry中存储了键值对时,entry的状态就从unused态切换为active态,此状态下me_key和me_value都不能为NULL
  • dummy态:当dict中的entry被删除后,此entry的状态就从active态变为了dummy态,它并不是在被删除后就直接变为了unused态,因为在开篇我们提到过,当发生哈希冲突时,Python会沿着探测序列继续探测下一个位置,如果此时的entry变为unused态则探测序列就中断了。当Python沿着一条探测序列search时,如果探测到某个entry处于dummy态,就说明此entry是一个无效的entry但是后面可能还存在着有效的entry,因此就保证了探测的连续性而不会导致中断。当search到某个处于unused态的entry时,证明确实不存在这样的一个key.
    我们用一个图示来简单表示三种状态以及它们之间的转换关系。
    在这里插入图片描述

3、PyDictObject的创建与操作

  • 3.1 PyDictObject的创建

  在内部,Python通过PyDict_New()函数来创建一个新的PyDictObject对象,其函数原型如下。

// dictobject.c
PyObject *
PyDict_New(void)
{
   
    PyDictKeysObject *keys = new_keys_object(PyDict_MINSIZE);
    if (keys == NULL)
        return NULL;
    return new_dict(keys, NULL);
}

  从代码中可看出,在创建PyDictObject对象时会先通过new_keys_object()函数创建一个PyDictKeysObject对象,并通过宏传入一个大小,它表示dict的初始大小也就是哈希表的大小,指定为8. 然后再通过new_dict()函数创建一个Dict对象。我们再来看看new_keys_object()的原型。

// dictobject.c
static PyDictKeysObject *new_keys_object(Py_ssize_t size)
{
   
    PyDictKeysObject *dk;
    Py_ssize_t es, usable;
    assert(size >= PyDict_MINSIZE);
    assert(IS_POWER_OF_2(size));
    // 装载因子,研究显示哈希表中的数量应当不超过表长的2/3,这样产生的冲突概率比较低
    usable = USABLE_FRACTION(size);
    if (size <= 0xff) {
   
        es = 1;
    }
    else if (size <= 0xffff) {
   
        es = 2;
    }
#if SIZEOF_VOID_P > 4
    else if (size <= 0xffffffff) {
   
        es = 4;
    }
#endif
    else {
   
        es = sizeof(Py_ssize_t);
    }
    // 使用缓冲池
    if (size == PyDict_MINSIZE && numfreekeys > 0) {
   
        dk = keys_free_list[--numfreekeys];
    }
    // 不使用缓冲池
    else {
   
        dk = PyObject_MALLOC(sizeof(PyDictKeysObject)
                             - Py_MEMBER_SIZE(PyDictKeysObject, dk_indices)
                             + es * size
                             + sizeof(PyDictKeyEntry) * usable);
        if (dk == NULL) {
   
            PyErr_NoMemory();
            return NULL;
        }
    }
    DK_DEBUG_INCREF dk->dk_refcnt = 1;
    // 哈希表的大小
    dk->dk_size = size;
    // dk_entries中可用的数量
    dk->dk_usable = usable
    // 使用默认的以PyStringObject对象的搜索策略
    dk->dk_lookup = lookdict_unicode_nodummy;
    // dk_entries中使用的数量
    dk->dk_nentries = 0;
    memset(&dk->dk_indices.as_1[0], 0xff, es * size);
    memset(DK_ENTRIES(dk), 0, sizeof(PyDictKeyEntry) * usable);
    return dk;
}

  我们可以看见在代码中插入了一些断言语句,主要用于做一些检查,首先会检查传入的size也就是entry的容量是否大于等于8,并且检查size的大小是否为2的幂。es变量主要是用来确定hash table的索引占用多少字节,可以看到当size的值小于等于0xff也就是十进制的255时,es为一个字节,以此类推。USABLE_FRACTION()函数用于指定哈希表的可用容量大小,其内部做了这样一个运算:(((n) << 1)/3),实际上就是将size乘以一个2/3,为什么要乘2/3呢?前面我们也提到过,因为通过研究表明当hash table中的元素数量达到总容量的三分之二时,就很容易出现hash冲突。
  接下来开始创建对象,可以看到dict也使用了缓冲池的技术,当缓冲池中有可用的对象时直接取出即可,如果没有可用的对象则调用malloc()函数在堆内存中分配空间用以创建新的对象。创建完对象后开始调整引用计数,并设置hash table的容量。dk_lookup中包含了hash function以及当出现hash conflict时二次探测函数的具体实现,其默认的search方式为Unicode,它实际上只是通用搜索的一个特例,我们后面会详细讲解。最后调用万能函数 memset() 来初始化内存,第一个memset()函数调用是指将dk->dk_indices.as_1[0]所在的内存初始化为0xff,由于这个函数是以字节为单位copy,所以第三个参数是总的字节数,这点很重要。它实际上完成的工作就是将哈希表中的dk_indices索引数组初始化,而第二个函数调用是将hash table中真正存储键值对的数组初始化。这与这一点您先记住,看不懂也没关系,后面我们会讲解这个东西,我可以告诉你的是它是在Python3.6之后对hash table进行了优化方式。
  回到PyDict_New()函数中,当创建完PyDictKeysObject对象后接着调用new_dict()函数,其原型如下。

// dictobject.c
static PyObject *
new_dict(PyDictKeysObject *keys, PyObject **values)
{
   
    PyDictObject *mp;
    assert(keys != NULL);
    if (numfree) {
   
        mp = free_list[--numfree
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值