[绝对原创 转载请注明出处]
Python源码剖析
——字典对象PyDictObject(2)
本文作者: Robert Chen ([email protected])
[dictobject.c]
typedef PyDictEntry dictentry;
typedef PyDictObject dictobject;
#define INIT_NONZERO_DICT_SLOTS(mp) do { /
(mp)->ma_table = (mp)->ma_smalltable; /
(mp)->ma_mask = PyDict_MINSIZE - 1; /
} while(0)
#define EMPTY_TO_MINSIZE(mp) do { /
memset((mp)->ma_smalltable, 0, sizeof((mp)->ma_smalltable)); /
(mp)->ma_used = (mp)->ma_fill = 0; /
INIT_NONZERO_DICT_SLOTS(mp); /
} while(0)
PyObject* PyDict_New(void)
{
register dictobject *mp;
if (dummy == NULL) { /* Auto-initialize dummy */
dummy = PyString_FromString("<dummy key>");
if (dummy == NULL)
return NULL;
}
if (num_free_dicts)
{
…… //使用缓冲池
}
else
{
mp = PyObject_GC_New(dictobject, &PyDict_Type);
if (mp == NULL)
return NULL;
EMPTY_TO_MINSIZE(mp);
}
mp->ma_lookup = lookdict_string;
_PyObject_GC_TRACK(mp);
return (PyObject *)mp;
}
值得注意的是,在第一次调用PyDict_New时,会创建在前面提到的那个dummy对象。显而易见,dummy对象仅仅是一个PyStringObject对象,它作为一种指示标志,表明该entry曾被使用过,且探测序列下一个位置的entry有可能是有效的,从而防止探测序列中断。
从num_free_dicts可以看出,Python中dict的实现同样使用了缓冲池。我们把将缓冲池的讨论放到后边。
创建的过程首先申请合适的内存空间,然后在EMPTY_TO_MINSIZE中,会将ma_smalltable清零,同时设置ma_size和ma_fill,当然,在一个PyDictObject对象刚被创建的时候,这两个变量都应该是0。然后会将ma_table指向ma_smalltable,并设置ma_mask,可以看到,ma_mask确实与一个PyDictObject对象中entry的数量有关。在创建过程的最后,将lookdict_string赋给了ma_lookup。正是ma_lookup指定了PyDictObject在entry集合中搜索某一特定entry时要进行的动作,它是PyDictObject的搜索策略,万众瞩目。
PyDictObject引入了两个不同的搜索策略,lookdict和lookdict_string。实际上,这两个策略使用的是相同的算法,lookdict_string只是lookdict的一种针对PyStringObject对象的特化形式。以PyStringObject对象作为PyDictObject对象中entry的键在Python中是如此地广泛和重要,所以lookdict_string也就成为了PyDictObject创建时所默认采用的搜索策略:
[dictobject.c]
static dictentry* lookdict_string(dictobject *mp, PyObject *key, register long hash)
{
register int i;
register unsigned int perturb;
register dictentry *freeslot;
register unsigned int mask = mp->ma_mask;
dictentry *ep0 = mp->ma_table;
register dictentry *ep;
if (!PyString_CheckExact(key)) {
mp->ma_lookup = lookdict;
return lookdict(mp, key, hash);
}
//[1]
i = hash & mask;
ep = &ep0[i];
//[2]
//if NULL or interned
if (ep->me_key == NULL || ep->me_key == key)
return ep;
//[3]
if (ep->me_key == dummy)
freeslot = ep;
else
{
//[4]
if (ep->me_hash == hash && _PyString_Eq(ep->me_key, key))
{
return ep;
}
freeslot = NULL;
}
/* In the loop, me_key == dummy is by far (factor of 100s) the
least likely outcome, so test for that last. */
for (perturb = hash; ; perturb >>= PERTURB_SHIFT)
{
i = (i << 2) + i + perturb + 1;
ep = &ep0[i & mask];
if (ep->me_key == NULL)
return freeslot == NULL ? ep : freeslot;
if (ep->me_key == key
|| (ep->me_hash == hash
&& ep->me_key != dummy
&& _PyString_Eq(ep->me_key, key)))
return ep;
if (ep->me_key == dummy && freeslot == NULL)
freeslot = ep;
}
}
其中的[1],[2],[3],[4]标注出了搜索过程中的关键步骤,这些步骤会在后面讲述PyDictObject对象的一般搜索策略时详细讨论。
lookdict_string并不是PyDictObject中最一般的搜索策略,它是一种有条件限制的搜索策略。lookdict_string背后有一个假设,即PyDictObject对象中每一个entry的key都是PyStringObject*。只有在这种假设成立的情况下,lookdict_string才会被使用。可以看到,lookdict_string首先会检查需要搜索的key是否严格对应一个PyStringObject对象,只有在检查通过后,才会进行下面的动作;如果检查不通过,那么就会转向PyDictObject中的通用搜索策略lookdict:
[dictobject.c]
static dictentry* lookdict(dictobject *mp, PyObject *key, register long hash)
{
register int i;
register unsigned int perturb;
register dictentry *freeslot;
register unsigned int mask = mp->ma_mask;
dictentry *ep0 = mp->ma_table;
register dictentry *ep;
register int restore_error;
register int checked_error;
register int cmp;
PyObject *err_type, *err_value, *err_tb;
PyObject *startkey;
//[1]
i = hash & mask;
ep = &ep0[i];
//[2]
if (ep->me_key == NULL || ep->me_key == key)
return ep;
//[3]
if (ep->me_key == dummy)
freeslot = ep;
else
{
//[4]
if (ep->me_hash == hash)
{
startkey = ep->me_key;
cmp = PyObject_RichCompareBool(startkey, key, Py_EQ);
if (cmp < 0)
PyErr_Clear();
if (ep0 == mp->ma_table && ep->me_key == startkey)
{
if (cmp > 0)
goto Done;
}
else
{
ep = lookdict(mp, key, hash);
goto Done;
}
}
freeslot = NULL;
}
。。。。。。
Done:
return ep;
}
PyDictObject中维护的entry的数量是有限的,比如100个或1000个。而传入lookdict中的key的hash值却不一定会在这个范围内,所以这就要求lookdict将hash值映射到某个entry上去。Lookdict采取的策略非常简单,直接将hash值与entry的数量做一个与操作,结果自然落在entry的数量之下。由于ma_mask会被用来进行大量的与操作,所以这个与entry数量相关的变量被命名为ma_mask,而不是ma_size。
我们注意到,lookdict永远都不会返回NULL,如果在PyDictObject中搜索不到待查找的元素,同样会返回一个entry,这个entry的me_value为NULL。
在搜索的过程中freeslot是一个重要的变量。如果在探测序列中的某个位置上,entry处于Dummy态,那么如果在这个序列中搜索不成功,就会返回这个处于Dummy态的entry。我们知道,处于Dummy态的entry其me_value是为NULL的,所以这个返回结果指示了搜索失败;同时,返回的entry也是一个可以立即被使用的entry,因为Dummy态的entry并没有维护一个有效的(key,value)对。这个freeslot正是用来指向探测序列中第一个处于Dummy态的entry,如果搜索失败,freeslot就会挺身而出,提供一个能指示失败并立即可用的entry。当然,如果探测序列中并没有Dummy态entry,搜索失败时,一定是在一个处于Unused态的entry上结束搜索过程的,这时会返回这个处于Unused态的entry,同样是一个能指示失败且立即可用的entry。
下面是lookdict中进行第一次检查时需要注意的动作:
[1]:根据hash值获得entry的序号。
[2]:如果ep->me_key为NULL,且与key相同,搜索失败。
[3]:若当前entry处于Dummy态,设置freeslot。
[4]:检查当前Active的entry中的key与待查找的key是否相同,如果相同,则立即返回,搜索成功。
在[4]中,需要注意那个PyObject_RichCompareBool,它的函数原形为:
int PyObject_RichCompareBool(PyObject *v, PyObject *w, int op)
当(v op w)成立时,返回1;当(v op w)不成立时,返回0;如果在比较中发生错误,则返回-1。
现在我们考察的是根据hash值获得的第一个entry与待查找的元素的比较。实际上,由于对应于某一个散列值,几乎都有一个探测序列与之对应,所以我们现在只是考察了探测序列中第一个位置的entry。万里长征仅仅迈出了第一步。
如果第一个entry与待查找的key不匹配,那么很自然地,lookdict会沿着探测序列,顺藤摸瓜,依次比较探测序列上的entry与待查找的key:
[dictobject.c]
static dictentry* lookdict(dictobject *mp, PyObject *key, register long hash)
{
register int i;
register unsigned int perturb;
register dictentry *freeslot;
register unsigned int mask = mp->ma_mask;
dictentry *ep0 = mp->ma_table;
register dictentry *ep;
register int restore_error;
register int checked_error;
register int cmp;
PyObject *err_type, *err_value, *err_tb;
PyObject *startkey;
。。。。。。
for (perturb = hash; ; perturb >>= PERTURB_SHIFT)
{
//[5]
i = (i << 2) + i + perturb + 1;
ep = &ep0[i & mask];
//[6]
if (ep->me_key == NULL)
{
if (freeslot != NULL)
ep = freeslot;
break;
}
if (ep->me_key == key)//[7]
break;
if (ep->me_hash == hash && ep->me_key != dummy)
{
startkey = ep->me_key;
cmp = PyObject_RichCompareBool(startkey, key, Py_EQ);
if (cmp < 0)
PyErr_Clear();
if (ep0 == mp->ma_table && ep->me_key == startkey) {
if (cmp > 0)
break;
}
else {
ep = lookdict(mp, key, hash);
break;
}
}
//[8]
else if (ep->me_key == dummy && freeslot == NULL)
freeslot = ep;
}
Done:
return ep;
}
[5]:获得探测序列中的下一个待探测的entry。
[6]:ep到达一个Unused态entry,表明搜索结束。这是如果freeslot不为空,则返回freeslot所指entry。
[7]:entry与待查找的key匹配,搜索成功。
[8]:在探测序列中发现Dummy态entry,设置freeslot。
到这里,我们已经清晰地了解了PyDictObject中的搜索策略。再回过头去看看那个lookdict_string,可以很清晰地看到,lookdict_string实际上就是一个lookdict对于PyStringDict对象的优化版本。在这里展示的lookdict代码经过了删节,实际上,在lookdict中有许多捕捉错误并处理错误的代码,因为lookdict面对的是PyObject*,所以会出现很多意外情况。而在lookdict_string中,完全没有了这些处理错误的代码。而另一方面,在lookdict中,使用的是非常通用的PyObject_RichCompareBool,而lookdict_string使用的是_PyString_Eq,比要简单很多,这些因素使得lookdict_string的搜索效率要比lookdict高很多。
此外,Python自身也大量使用了PyDictObject对象,用来维护一个作用域中变量名和变量值之间的对应关系,或是用来在为函数传递参数时维护参数名与参数值的对应关系。这些对象几乎都是用PyStringObject对象作为entry中的key,所以lookdict_string的意义就显得非常重要了,它对Python整体的运行效率都有着重要的影响。
PyDictObject对象中元素的插入动作建立在搜索的基础之上:
[dictobject.c]
static void
insertdict(register dictobject *mp, PyObject *key, long hash, PyObject *value)
{
PyObject *old_value;
register dictentry *ep;
ep = mp->ma_lookup(mp, key, hash);
//[1]
if (ep->me_value != NULL) {
old_value = ep->me_value;
ep->me_value = value;
Py_DECREF(old_value); /* which **CAN** re-enter */
Py_DECREF(key);
}
//[2]
else {
if (ep->me_key == NULL)
mp->ma_fill++;
else
Py_DECREF(ep->me_key);
ep->me_key = key;
ep->me_hash = hash;
ep->me_value = value;
mp->ma_used++;
}
}
前面我们提到了,搜索操作在成功时,返回相应的处于Active态的entry,而在搜索失败时会返回两种不同的结果:一是处于Unused态的entry;二是处于Dummy态的entry。那么插入操作对应不同的entry,所需要进行的动作显然也是不一样的。对于Active的entry,只需要简单地替换me_value值就可以了;而对于Unused或Dummy的entry,则需要完整地设置me_key,me_hash和me_value。
在insertdict中,正是根据搜索的结果采取了不同的动作:
[1] :搜索成功,返回处于Active的entry,直接替换me_value。
[2] :搜索失败,返回Unused或Dummy的entry,完整设置me_key,me_hash和me_value。
在Python中,对PyDictObject中的元素进行插入或设置有两种方式:
[python code]
d = {}
d[1] = 1
d[1] = 2
第二行Python代码是在PyDictObject对象中没有这个entry的情况下插入元素,第三行是在PyDictObject对象中已经有这个entry的情况下重新设置元素。可以看到,insertdict完全可以适应这两种情况,在insertdict中,[2]处代码处理第二行Python代码,[1]处代码处理第三行Python代码。实际上,这两行Python代码也确实都调用了insertdict。
当这两行设置PyDictObject对象元素的Python代码被执行时,并不是直接就调用insertdict,因为观察代码可以看到,insertdict需要一个hash值作为调用参数,那么这个hash值在什么地方获得的呢?实际上,在调用insertdict之前,还会调用PyDict_SetItem:
[dictobject.c]
int PyDict_SetItem(register PyObject *op, PyObject *key, PyObject *value)
{
register dictobject *mp;
register long hash;
register int n_used;
mp = (dictobject *)op;
//计算hash值
if (PyString_CheckExact(key)) {
hash = ((PyStringObject *)key)->ob_shash;
if (hash == -1)
hash = PyObject_Hash(key);
}
else {
hash = PyObject_Hash(key);
if (hash == -1)
return -1;
}
n_used = mp->ma_used;
Py_INCREF(value);
Py_INCREF(key);
insertdict(mp, key, hash, value);
if (!(mp->ma_used > n_used && mp->ma_fill*3 >= (mp->ma_mask+1)*2))
return 0;
return dictresize(mp, mp->ma_used*(mp->ma_used>50000 ? 2 : 4));
}
在PyDict_SetItem中,会首先获得key的hash值,在上面的例子中,也就是一个PyIntObject对象1的hash值。
然后再调用insertdict进行元素的插入或设置。
PyDict_SetItem在插入或设置元素的动作结束之后,并不会草草返回了事。接下来,它会检查是否需要改变PyDictObject内部ma_table所值的内存区域的大小,在以后的叙述中,我们将这块内存称为“table”。那么什么时候需要改变table的大小呢。在前面我们说过,如果table的装载率大于2/3时,后续的插入动作遭遇到冲突的可能性会非常大。所以装载率是否大于或等于2/3就是判断是否需要改变table大小的准则。在PyDict_SetItem中,有如下的代码:
if (!(mp->ma_used > n_used && mp->ma_fill*3 >= (mp->ma_mask+1)*2))
return 0;
经过转换,实际上可以得到:
(mp->ma_fill)/(mp->ma_mask+1) >= 2/3
这个等式左边的表达式正是装载率。
然而装载率只是判定是否需要改变table大小的一个标准,还有另一个标准是在insertdict的过程中,是否使用了一个处于Unused态的entry。前面我们说过,在搜索过程失败并且探测序列中没有Dummy态的entry时,就会返回一个Unused态的entry,insertdict会对这个entry进行填充。只有当这种情况发生并且装载率超标时,才会进行改变table大小的动作。而判断在insertdict的过程中是否填充了Unused态的entry,是通过
mp->ma_used > n_used
来判断的,其中的n_used就是进行insertdict操作之前的mp->ma_used。通过观察mp->ma_used是否改变,就可以知道是否有Unused态的entry被填充。
在改变table时,并不一定是增加table的大小,同样也可能是减小table的大小。更改table的大小时,新的table的空间为:
mp->ma_used*(mp->ma_used>50000 ? 2 : 4)
如果一个PyDictObject对象的table中只有几个entry处于Active态,而大多数entry都处于Dummy态,那么改变table大小的结果显然就是减小了table的空间大小。
在确定新的table的大小时,通常选用的策略是时新的table中entry的数量是现在table中Active态entry数量的4倍,选用4倍是为了使table中处于Active态的entry的分布更加稀疏,减少插入元素时的冲突概率。当然,这是以内存空间为代价的。由于机器的内存总是有限的,Python总不能没心没肺地在任何时候都要求4倍空间,这样搞,别的程序会有意见的:)所以当table中Active态的entry数量非常巨大时,Python只会要求2倍的空间,这次又是以执行速度来交换内存空间。Python2.4.1将这个“非常巨大”的标准划定在50000。如此一来,上帝的归上帝,撒旦的归撒旦,万事大吉 :)
至于具体的改变table大小的重任,则交到了dictresize一人的肩上:
[dictobject.c]
static int dictresize(dictobject *mp, int minused)
{
int newsize;
dictentry *oldtable, *newtable, *ep;
int i;
int is_oldtable_malloced;
dictentry small_copy[PyDict_MINSIZE];
//[1]
for(newsize = PyDict_MINSIZE; newsize <= minused && newsize > 0; newsize <<= 1)
;
oldtable = mp->ma_table;
assert(oldtable != NULL);
is_oldtable_malloced = oldtable != mp->ma_smalltable;
//[2]
if (newsize == PyDict_MINSIZE) {
newtable = mp->ma_smalltable;
if (newtable == oldtable) {
if (mp->ma_fill == mp->ma_used) {
//没有任何Dummy态entry,直接返回
return 0;
}
//将oldtable拷贝,进行备份
assert(mp->ma_fill > mp->ma_used);
memcpy(small_copy, oldtable, sizeof(small_copy));
oldtable = small_copy;
}
}
else {
newtable = PyMem_NEW(dictentry, newsize);
}
//[3]
assert(newtable != oldtable);
mp->ma_table = newtable;
mp->ma_mask = newsize - 1;
memset(newtable, 0, sizeof(dictentry) * newsize);
mp->ma_used = 0;
i = mp->ma_fill;
mp->ma_fill = 0;
//[4]
for (ep = oldtable; i > 0; ep++) {
if (ep->me_value != NULL) { /* active entry */
--i;
insertdict(mp, ep->me_key, ep->me_hash, ep->me_value);
}
else if (ep->me_key != NULL) { /* dummy entry */
--i;
assert(ep->me_key == dummy);
Py_DECREF(ep->me_key);
}
}
if (is_oldtable_malloced)
PyMem_DEL(oldtable);
return 0;
}
[1] :dictresize首先会确定新的table的大小,很显然,这个大小一定要大于传入的参数minused,这也是在原来的table中处于Active态的entry的数量。dictresize从8开始,以指数方式增加大小,直到超过了minused为止。所以实际上新的table的大小在大多数情况下至少是原来table中Active态entry数量的4倍。
[2] :如果在[1]中获得的新的table大小为8,则不需要在堆上分配空间,直接使用ma_smalltable就可以了;否则,则需要在堆上分配空间。
[3] :对新的table进行初始化,并调整原来PyDictObject对象中用于维护table使用情况的变量。
[4] :对原来table中的非Unused态entry进行处理。对于Active态entry,显然需要将其插入到新的table中,这个动作由前面考察过的insertdict完成;而对于Dummy态的entry,则略过,不做任何处理,因为我们知道Dummy态entry存在的唯一理由就是为了不使搜索时的探测序列中断。现在所有Active态的entry都重新依次插入新的table中,它们会形成一条新的探测序列,不再需要这些Dummy态的entry了。
现在,利用我们对PyDictObject的认识,想象一下从table中删除一个元素应该怎样操作呢?
[dictobject.c]
int PyDict_DelItem(PyObject *op, PyObject *key)
{
register dictobject *mp;
register long hash;
register dictentry *ep;
PyObject *old_value, *old_key;
//获得hash值
if (!PyString_CheckExact(key) ||
(hash = ((PyStringObject *) key)->ob_shash) == -1) {
hash = PyObject_Hash(key);
if (hash == -1)
return -1;
}
//搜索entry
mp = (dictobject *)op;
ep = (mp->ma_lookup)(mp, key, hash);
//删除entry所维护的元素
old_key = ep->me_key;
Py_INCREF(dummy);
ep->me_key = dummy;
old_value = ep->me_value;
ep->me_value = NULL;
mp->ma_used--;
Py_DECREF(old_value);
Py_DECREF(old_key);
return 0;
}
流程非常清晰,先计算hash值,然后搜索相应的entry,最后删除entry中维护的元素,并将entry从Active态变换为Dummy态,同时还将调整PyDictObject对象中维护table使用情况的变量。
下面我们用一个简单的例子来动态地展示对PyDictObject中table的维护过程,需要提醒的是,这里采用的散列函数和探测函数都与Python中PyDictObject实际采用的策略不同,这里只是想要从观念上展示对table的维护过程。在下面的图中,蓝色代表Unused态entry,绿色为Active态,黄色为Dummy态。
假如table中有10个entry,散列函数为HASH(x) = x mod 10,冲突解决方案采用线性探测,且探测函数为x = x + 1。假设向table中依次加入了以下元素对:(4,4),(14,14),(24,24),(34,34),则加入元素后的entry为:
现在删除元素对(14,14),然后向table中插入新的元素对(104,104)。则在搜索的过程中,由于在原来维护14的entry#4处于现在Dummy态,所以freeslots会指向这个可用的entry:
搜索完成后,填充freeslot所指向的entry:
然后,再向table中插入元素对(14,14),这时由于探测序列上已经没有Dummy态entry了,所以最后返回的ep会指向一个处于Unused态的entry:
最后插入元素对(14,14),结果为: