[绝对原创 转载请注明出处]
Python源码剖析
——字典对象PyDictObject(1)
本文作者: Robert Chen ([email protected])
元素和元素之间通常可能存在某种联系,这种神秘的联系使本来绝不相同的两个元素捆绑在一起,而别的元素则被排斥在外。比如对应于“2倍”,这样一种联系,6和3就是这样的两个元素,而4和2同样也是被这种联系关联起来的一对元素。
为了刻画某种对应关系,现代的编程语言通常都在语言级或标准库中提供某种关联式的容器。关联式的容器中存储着一对对符合该容器所代表的关联规则的元素对。关联式容器中的元素对通常是以(健key,值value)这样的形式存在。比如在一个表示“2倍”关系的关联容器中(3,6),(2,4)就是容器中的两个元素对。其中3就是一个“键”,当寻找到3之后,我们很轻松地就能获得与3有着“2倍”联系的另一元素。
关联容器的设计总会极大地关注搜索键的效率,因为通常我们使用关联容器,都是希望根据我们手中已有的某个元素来快速获得与之有某种联系的另一元素。一般而言,关联容器的实现都会基于设计良好的数据结构。比如C++的STL中的map就是一种关联容器,map的实现基于RB-tree(红黑树)。RB-tree是一种平衡二元树,能够提供良好的搜索效率,理论上,其搜索的复杂度为O(logN)。
Python中同样提供关联式容器,即PyDictObject对象。与map不同的是,PyDictObject对搜索的效率要求及其苛刻,这也是因为PyDictObject在Python本身的实现中被大量地采用,比如会通过PyDictObject来建立Python字节码的运行环境,其中会存放(变量名,变量值)的元素对,通过查找变量名获得变量值。因此,PyDictObject没有如map一样采用平衡二元树,而是采用了散列表(hash table),因为理论上,在最优情况下,散列表能提供O(1)复杂度的搜索效率。
散列表的基本思想是通过一定的函数将需搜索的键值映射为一个整数,将这个整数视为索引值去访问某片连续的内存区域。看一个简单的例子,如图1所示,有10个整数1,2,……,10。其依次对应a, b, ……, j。申请一块连续内存,并依次存储a, b, ……, j:
当需要寻找与2对应的字母时,只需通过一定的函数将其映射为整数,显然,2本身就是一个整数,我们可以使用2自身。然后访问这片连续内存的第2个位置,就能得到与2对应的字母b。
将元素映射为整数的过程对于散列表来说尤为关键,用于映射的函数成为散列函数(hash function),而映射后的值称为元素的散列值(hash value)。
在使用散列表的过程中,不同的对象经过散列函数的作用,可能被映射为相同的散列值。而且随着需要存储的数据的增多,这样的冲突就会发生得越来越频繁。散列冲突是散列技术与生俱来的问题。
有很多种方法可以用来解决产生的散列冲突,比如说开链法,这是SGI STL中的hash table所采用的方法;而Python中所采用的是另一种方法,即开放定址法。
当产生散列冲突时,Python会通过一个再次探测函数,计算下一个候选位置,如果这个位置可用,则可将待插入元素放到这个位置;如果这个位置不可用,则Python会再次通过探测函数,获得下一个候选位置,如此不断探测,总会找到一个可用的位置。
这样,沿着探测函数,从一个位置出发可以依次到达多个位置,这些位置形成了一个探测链。当需要删除某条探测链上的某个元素时,问题就产生了。假如这条链的首元素位置为a,尾元素的位置为c,现在需要删除中间的某个位置b上的元素。如果直接将位置b上的元素删除,则会导致探测链的断裂,引起严重的问题。想象一下,在下一次搜索c时,会从位置a开始,通过探测函数,沿着探测链,一步一步向位置c靠近,但是在到达位置b时,发现这个位置上的元素钚属于这个探测链,因此会以为探测链在这里结束,导致不能达到为止c,自然也不能搜索到位置c上的元素,所以结果是搜索失败。而实际上,待搜索的元素确实存在于散列表中。
所以,在采用开放定址的冲突解决策略的散列表中,删除某条探测链上的元素时不能真正地删除,而是进行一种“伪删除”操作,必须要让该元素还存在于探测链上,担当承前启后的重任。对于这种伪删除技术,我们在分析Python中的关联容器时会详细讨论。
在此后的描述中,我们将把关联容器中的一个(键,值)元素对称为一个entry或slot。在Python中,一个entry的定义如下:
[dictobject.h]
typedef struct {
long me_hash; /* cached hash code of me_key */
PyObject *me_key;
PyObject *me_value;
} PyDictEntry;
可以看到,在PyDictObject中其实存放的都是PyObject*,这也是Python中的dict什么都能装得下的原因,因为在Python中,不论什么都是一个PyObject对象。
me_hash域中存储的是me_key的散列值,利用一个域来记录这个散列值可以避免每次查询的时候都要重新计算一遍散列值。
在Python中,在一个PyDictObject对象生存变化的过程中,其中的entry还会在不同的状态间转换。PyDictObject中entry可以在三种状态间转换:Unused态,Active态,Dummy态。
1.当一个entry的me_key和me_value都是NULL时,entry处于Unused态。表明该entry中并没有存储(key, value)对,而且在此之前,也没有存储过。每一个entry在初始化的时候都会出于这种状态,而且只有在Unused态下,entry的me_key域才会为NULL。
2.当entry中存储了一个(key,value)对时,entry便转换到了Active态。在Active态下,me_key和me_value都不能为NULL。更进一步,me_key不能是dummy。
3.当entry中存储的(key,value)对被删除后,entry中的me_key将指向dummy,entry进入Dummy态。这是一种惰性删除技巧,是为了保证冲突的探测序列不会在这里被中断。
图2展示了三种状态以及它们之间的转换关系:
一个PyDictObject对象实际上是一大堆entry的集合,总控这些集合的结构如下:
[dictobject.h]
#define PyDict_MINSIZE 8
typedef struct _dictobject PyDictObject;
struct _dictobject {
PyObject_HEAD
int ma_fill; /* # Active + # Dummy */
int ma_used; /* # Active */
int ma_mask;
PyDictEntry *ma_table;
PyDictEntry *(*ma_lookup)(PyDictObject *mp, PyObject *key, long hash);
PyDictEntry ma_smalltable[PyDict_MINSIZE];
};
从注释中可以清楚地看到,ma_fill域中维护着从PyDictObject创建到现在曾经以及正处于Active态的entry个数,而ma_used则维护着当前正处于Active态的entry。
当创建一个PyDictObjec对象时,至少有PyDict_MINSIZE个entry被同时创建。在dictobject.h中,这个值被设定为8,这个值被认为是通过大量的实验得出的最佳值。既不会太浪费内存空间,又能很好地满足Python中大量使用PyDictObject的环境的需求,而不需要再次调用malloc申请内存空间。当然,我们可以自己调节这个值来调节Python的运行效率。
当一个PyDictObject是一个比较小的dict时,即entry数量少于8个,ma_table域将指向这与生俱来的8个entry。而当PyDictObject中的entry数量超过8个时,Python认为这家伙是一个大dict了,将会额外申请内存空间,并将ma_table指向这块空间。这样,无论何时,ma_table域总不会为NULL,这带来了一个好处,不用再运行时一次又一次不厌其烦地检查ma_table的有效性,因为ma_table总是有效的。
PyDictObject中的ma_mask实际上记录了一个PyDictObject对象中有entry的数量。至于这家伙为什么不叫ma_size这么好听的名字,偏偏要叫ma_mask这个莫名其妙的名字,此乃后话,这里先按下不表。同样被按下不表的还有ma_lookup :)