1. 程式人生 > >Python字典物件實現原理

Python字典物件實現原理

字典型別是Python中最常用的資料型別之一,它是一個鍵值對的集合,字典通過鍵來索引,關聯到相對的值,理論上它的查詢複雜度是 O(1) :

>>> d = {'a': 1, 'b': 2}
>>> d['c'] = 3
>>> d
{'a': 1, 'b': 2, 'c': 3}

字串的實現原理文章中,曾經出現過字典物件用於intern操作,那麼字典的內部結構是怎樣的呢?PyDictObject物件就是dict的內部實現。

雜湊表 (hash tables)

雜湊表(也叫散列表),根據關鍵值對(Key-value)而直接進行訪問的資料結構。它通過把key和value對映到表中一個位置來訪問記錄,這種查詢速度非常快,更新也快。而這個對映函式叫做雜湊函式,存放值的陣列叫做雜湊表。 雜湊函式的實現方式決定了雜湊表的搜尋效率。具體操作過程是:

  1. 資料新增:把key通過雜湊函式轉換成一個整型數字,然後就將該數字對陣列長度進行取餘,取餘結果就當作陣列的下標,將value儲存在以該數字為下標的陣列空間裡。
  2. 資料查詢:再次使用雜湊函式將key轉換為對應的陣列下標,並定位到陣列的位置獲取value。

但是,對key進行hash的時候,不同的key可能hash出來的結果是一樣的,尤其是資料量增多的時候,這個問題叫做雜湊衝突。如果解決這種衝突情況呢?通常的做法有兩種,一種是連結法,另一種是開放定址法,Python選擇後者。

開放定址法(open addressing)

開放定址法中,所有的元素都存放在散列表裡,當產生雜湊衝突時,通過一個探測函式計算出下一個候選位置,如果下一個獲選位置還是有衝突,那麼不斷通過探測函式往下找,直到找個一個空槽來存放待插入元素。

PyDictEntry

字典中的一個key-value鍵值對元素稱為entry(也叫做slots),對應到Python內部是PyDictEntry,PyDictObject就是PyDictEntry的集合。PyDictEntry的定義是:

typedef struct {
    /* Cached hash code of me_key.  Note that hash codes are C longs.
     * We have to use Py_ssize_t instead because dict_popitem() abuses
     * me_hash to hold a search finger.
     */
    Py_ssize_t me_hash;
    PyObject *me_key;
    PyObject *me_value;
} PyDictEntry;

me_hash用於快取me_key的雜湊值,防止每次查詢時都要計算雜湊值,entry有三種狀態。

  1. Unused: me_key == me_value == NULL

    Unused是entry的初始狀態,key和value都為NULL。插入元素時,Unused狀態轉換成Active狀態。這是me_key為NULL的唯一情況。 2. Active: me_key != NULL and me_key != dummy 且 me_value != NULL

    插入元素後,entry就成了Active狀態,這是me_value唯一不為NULL的情況,刪除元素時Active狀態刻轉換成Dummy狀態。 3. Dummy: me_key == dummy 且 me_value == NULL

    此處的dummy物件實際上一個PyStringObject物件,僅作為指示標誌。Dummy狀態的元素可以在插入元素的時候將它變成Active狀態,但它不可能再變成Unused狀態。

為什麼entry有Dummy狀態呢?這是因為採用開放定址法中,遇到雜湊衝突時會找到下一個合適的位置,例如某元素經過雜湊計算應該插入到A處,但是此時A處有元素的,通過探測函式計算得到下一個位置B,仍然有元素,直到找到位置C為止,此時ABC構成了探測鏈,查詢元素時如果hash值相同,那麼也是順著這條探測鏈不斷往後找,當刪除探測鏈中的某個元素時,比如B,如果直接把B從雜湊表中移除,即變成Unused狀態,那麼C就不可能再找到了,因為AC之間出現了斷裂的現象,正是如此才出現了第三種狀態---Dummy,Dummy是一種類似的偽刪除方式,保證探測鏈的連續性。
python_entry_status

PyDictObject

PyDictObject就是PyDictEntry物件的集合,PyDictObject的結構是:

typedef struct _dictobject PyDictObject;
struct _dictobject {
    PyObject_HEAD
    Py_ssize_t ma_fill;  /* # Active + # Dummy */
    Py_ssize_t ma_used;  /* # Active */

    /* The table contains ma_mask + 1 slots, and that's a power of 2.
     * We store the mask instead of the size because the mask is more
     * frequently needed.
     */
    Py_ssize_t ma_mask;

    /* ma_table points to ma_smalltable for small tables, else to
     * additional malloc'ed memory.  ma_table is never NULL!  This rule
     * saves repeated runtime null-tests in the workhorse getitem and
     * setitem calls.
     */
    PyDictEntry *ma_table;
    PyDictEntry *(*ma_lookup)(PyDictObject *mp, PyObject *key, long hash);
    PyDictEntry ma_smalltable[PyDict_MINSIZE];
};
  • ma_fill :所有處於Active以及Dummy的元素個數
  • ma_used :所有處於Active狀態的元素個數
  • ma_mask :所有entry的元素個數(Active+Dummy+Unused)
  • ma_smalltable:建立字典物件時,一定會建立一個大小為PyDict_MINSIZE==8的PyDictEntry陣列。
  • 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欄位。

PyDictObject的建立過程

PyObject *
PyDict_New(void)
{
    register PyDictObject *mp;
    if (dummy == NULL) { /* Auto-initialize dummy */
        dummy = PyString_FromString("<dummy key>");
        if (dummy == NULL)
            return NULL;
    }
    if (numfree) {
        mp = free_list[--numfree];
        assert (mp != NULL);
        assert (Py_TYPE(mp) == &PyDict_Type);
        _Py_NewReference((PyObject *)mp);
        if (mp->ma_fill) {
            EMPTY_TO_MINSIZE(mp);
        } else {
            /* At least set ma_table and ma_mask; these are wrong
               if an empty but presized dict is added to freelist */
            INIT_NONZERO_DICT_SLOTS(mp);
        }
        assert (mp->ma_used == 0);
        assert (mp->ma_table == mp->ma_smalltable);
        assert (mp->ma_mask == PyDict_MINSIZE - 1);
    } else {
        mp = PyObject_GC_New(PyDictObject, &PyDict_Type);
        if (mp == NULL)
            return NULL;
        EMPTY_TO_MINSIZE(mp);
    }
    mp->ma_lookup = lookdict_string;
    return (PyObject *)mp;
}
  1. 初始化dummy物件
  2. 如果緩衝池還有可用的物件,則從緩衝池中讀取,否則,執行步驟3
  3. 分配記憶體空間,建立PyDictObject物件,初始化物件
  4. 指定新增字典元素時的探測函式,元素的搜尋策略

字典搜尋策略

static PyDictEntry *
lookdict(PyDictObject *mp, PyObject *key, register long hash)
{
    register size_t i;
    register size_t perturb;
    register PyDictEntry *freeslot;
    register size_t mask = (size_t)mp->ma_mask;
    PyDictEntry *ep0 = mp->ma_table;
    register PyDictEntry *ep;
    register int cmp;
    PyObject *startkey;

    i = (size_t)hash & mask;
    ep = &ep0[i];
    if (ep->me_key == NULL || ep->me_key == key)
        return ep;

    if (ep->me_key == dummy)
        freeslot = ep;
    else {
        if (ep->me_hash == hash) {
            startkey = ep->me_key;
            Py_INCREF(startkey);
            cmp = PyObject_RichCompareBool(startkey, key, Py_EQ);
            Py_DECREF(startkey);
            if (cmp < 0)
                return NULL;
            if (ep0 == mp->ma_table && ep->me_key == startkey) {
                if (cmp > 0)
                    return ep;
            }
            else {
                /* The compare did major nasty stuff to the
                 * dict:  start over.
                 * XXX A clever adversary could prevent this
                 * XXX from terminating.
                 */
                return lookdict(mp, key, hash);
            }
        }
        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)
            return ep;
        if (ep->me_hash == hash && ep->me_key != dummy) {
            startkey = ep->me_key;
            Py_INCREF(startkey);
            cmp = PyObject_RichCompareBool(startkey, key, Py_EQ);
            Py_DECREF(startkey);
            if (cmp < 0)
                return NULL;
            if (ep0 == mp->ma_table && ep->me_key == startkey) {
                if (cmp > 0)
                    return ep;
            }
            else {
                /* The compare did major nasty stuff to the
                 * dict:  start over.
                 * XXX A clever adversary could prevent this
                 * XXX from terminating.
                 */
                return lookdict(mp, key, hash);
            }
        }
        else if (ep->me_key == dummy && freeslot == NULL)
            freeslot = ep;
    }
    assert(0);          /* NOT REACHED */
    return 0;
}

字典在新增元素和查詢元素時,都需要用到字典的搜尋策略,搜尋時,如果不存在該key,那麼返回Unused狀態的entry,如果存在該key,但是key是一個Dummy物件,那麼返回Dummy狀態的entry,其他情況就表示存在Active狀態的entry,那麼對於字典的插入操作,針對不同的情況進行操作也不一樣。對於Active的entry,直接替換me_value值即可;對於Unused或Dummy的entry,需要同時設定me_key,me_hash和me_value

PyDictObject物件緩衝池

PyDictObject物件緩衝池和PyListObject物件緩衝池的原理是類似的,都是在物件被銷燬的時候把該物件新增到緩衝池中去,而且值保留PyDictObject物件本身,如果ma_table維護的時從系統堆中申請的空間,那麼Python會釋放這塊記憶體,如果ma_table維護的是ma_smalltable,那麼只需把smalltable中的元素的引用計數減少即可。

static void
dict_dealloc(register PyDictObject *mp)
{
    register PyDictEntry *ep;
    Py_ssize_t fill = mp->ma_fill;
    PyObject_GC_UnTrack(mp);
    Py_TRASHCAN_SAFE_BEGIN(mp)
    for (ep = mp->ma_table; fill > 0; ep++) {
        if (ep->me_key) {
            --fill;
            Py_DECREF(ep->me_key);
            Py_XDECREF(ep->me_value);
        }
    }
    if (mp->ma_table != mp->ma_smalltable)
        PyMem_DEL(mp->ma_table);
    if (numfree < PyDict_MAXFREELIST && Py_TYPE(mp) == &PyDict_Type)
        free_list[numfree++] = mp;
    else
        Py_TYPE(mp)->tp_free((PyObject *)mp);
    Py_TRASHCAN_SAFE_END(mp)
}

關注公眾號「Python之禪」(id:vttalk)獲取最新文章 python之禪