Python字典实现原理

Python字典实现原理

引言

Python中的字典是基于哈希表实现,关于哈希表这种结构的详细的介绍,可以查看本博文章数据结构之哈希表。本文主要讲解哈希表在Python是如何具体被实现出来的,本博客的另一篇文章Redis数据类型:字典,则是讲解了哈希表在Redis上的具体实现,有时间的读者可以对比着来看。

正如数据结构之哈希表这篇文章后面提到的,要实现一个哈希表,我们应该关注三件事情:

  1. 设计一个合适的散列函数
  2. 选择合适的散列冲突解决策略
  3. 定义装载因子阈值,并且设计动态扩容策略

所以这篇文章,也会重点讲解这三点在Python是如何选择和实现出来的。除此之外,还会介绍字典创建、元素查找等操作的底层实现,以及Python惯用伎俩--字典对象缓冲池的实现。

字典实现

字典entry的定义

我们把字典的一个元素(即键值对),称为一个entry,它的定义如下:

  • me_hash属性

    存储的是针对键值me_key的散列值,存储起来避免每次查询都要重新计算。

  • me_key和me_value

    就是我们常说的键值对中的键和值,注意它是PyObject类型,这也是python字典可以存储任何对象的原因。

entry有三种状态,分别为:

  1. Unused态

    此时me_keyme_value都等于NULL,表明该entry没有存储任何东西。

  2. Active态

    此时me_keyme_value都不为空,表明该entry存储了一个键值对。

  3. Dummy态

    处于Active态的entry被删除时,会转到Dummy态。此时me_key指向一个dummy对象,me_value为NULl。为什么要Dummy态呢?这是由Python的冲突处理策略决定的,Python采用的开放地址冲突解决方法,不允许做真正的删除,所以采用打标记的"伪删除法"。关于冲突解决,后面会有更详细的说明。

 

状态转换图:

image-20190103183418954

字典对象的定义

  • ma_fill

    处于Active以及Dummy的元素个数

  • ma_used

    处于Active状态的元素个数

  • ma_mask

    entry的元素个数-1(Active+Dummy+Unused-1)

  • ma_smalltable

    创建字典对象时,一定会创建一个大小为PyDict_MINSIZE==8的PyDictEntry数组。当entry数量小于8时,使用ma_smalltable作为字典元素实际存储的地方。

  • ma_table

    当entry数量小于PyDict_MINSIZE,ma_table指向ma_smalltable的首地址,当entry数量大于8时,Python把它当做一个大字典来处理,此刻会申请额外的内存空间,同时将ma_table指向这块空间。

  • ma_lookup

    字典元素的搜索策略

需要注意的是,PyDictObject使用PyObject_HEAD而不是PyObject_Var_HEAD,虽然字典也是变长对象,但此处并不是通过ob_size来存储字典中元素的长度,而是通过ma_used字段。

散列函数

计算索引值(即元素在数组中的下标)的公式如下:

其中PyObject_Hash函数定义如下:

可以看出,实际调用的还是各个类型的*tp->tp_hash ,即各数据类型自己的hash函数。

冲突处理:开放寻址法

关于python所使用的开放寻址具体策略,采用的是二次探测序列(quadratic probing sequence), 源码文档说明如下:

字典相关操作的实现

字典对象创建

步骤

  1. 初始化dummy对象;从dummy的声明可以看出,它是个全局静态对象,只会初始化一次。
  2. 如果缓冲池有可用对象,则读取之。
  3. 如果缓冲池没有可用对象,则新建一个PyDictObject对象,并初始化它。
  4. ma_lookup赋值,添加字典元素时的探测函数,元素的搜索策略。

搜索元素函数

此函数用于获取到指定key对应的entry,它是获取元素、更新元素、添加删除元素的基础,因为做这些操作之前,都要先找到entry。从前面我们已经知道ma_lookup的默认值为lookdict_string, lookdict_string只是针对字符串类型的key的实现,更通用的实现是函数lookdict,lookdict_string是基于lookdict实现的。

字典在添加元素和查询元素时,都需要用到字典的搜索策略,搜索时,如果不存在该key,那么返回Unused状态的entry,如果存在该key,但是key是一个Dummy对象,那么返回Dummy状态的entry,其他情况就表示存在Active状态的entry,那么对于字典的插入操作,针对不同的情况进行操作也不一样。对于Active的entry,直接替换me_value值即可;对于Unused或Dummy的entry,需要同时设置me_key,me_hash和me_value。

动态扩容和收缩策略

装载因子定义

触发条件

当load_factory >= 2/3, 触发ma_table大小改变,这种改变可能是扩容也可能是收缩。

改变后的大小

改变ma_table大小的代码如下:

流程大概是,先申请一个新的table,然后将Active状态的entry搬到新的table上去,调整各个属性的值。一气呵成,跟Redis的渐进式hash还是有很大不同。

对象缓冲池

字典的缓冲池,就是下面的free_list

缓冲池的元素是处理字典销毁的地方放进去的,代码如下:

要特别注意的地方是,申请的系统堆内存释放掉之后,才会放进缓冲池里。

 

参考

  1. 书籍《Python源码剖析》
  2. Python2.7的源码
  3. Python字典实现原理 https://foofish.net/python_dict_implements.html