天天看点

Python源码剖析[13] —— 字典对象PyDictObject(2)3         PyDictObject的创建和维护

[绝对原创 转载请注明出处]

Python源码剖析

——字典对象PyDictObject(2)

本文作者 : Robert Chen ([email protected])

3         PyDictObject的创建和维护

3.1.1    PyDictObject对象创建

[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)

{

        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的搜索策略,万众瞩目。

3.1.2    元素搜索

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;

} 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整体的运行效率都有着重要的影响。

3.1.3    插入与删除

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);

        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)

{

            --i;

            insertdict(mp, ep->me_key, ep->me_hash, ep->me_value);

} else if

(ep->me_key != NULL)

{

            --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为:

Python源码剖析[13] —— 字典对象PyDictObject(2)3         PyDictObject的创建和维护
Python源码剖析[13] —— 字典对象PyDictObject(2)3         PyDictObject的创建和维护

现在删除元素对(14,14),然后向table中插入新的元素对(104,104)。则在搜索的过程中,由于在原来维护14的entry#4处于现在Dummy态,所以freeslots会指向这个可用的entry:

Python源码剖析[13] —— 字典对象PyDictObject(2)3         PyDictObject的创建和维护

搜索完成后,填充freeslot所指向的entry:

Python源码剖析[13] —— 字典对象PyDictObject(2)3         PyDictObject的创建和维护

然后,再向table中插入元素对(14,14),这时由于探测序列上已经没有Dummy态entry了,所以最后返回的ep会指向一个处于Unused态的entry:

Python源码剖析[13] —— 字典对象PyDictObject(2)3         PyDictObject的创建和维护

最后插入元素对(14,14),结果为:

Python源码剖析[13] —— 字典对象PyDictObject(2)3         PyDictObject的创建和维护