字典是如何擴容的?

這裏先來補充一個之前沒有說的點,PyDictObject裏面有一個ma_used字段,它維護的是鍵值對的數量,充當ob_size;而在PyDictKeysObject裏面有一個dk_nentries,它維護鍵值對數組中已使用的entry數量,而這個entry又可以理解爲鍵值對。那麼問題來了,這兩者有什麼區別呢?

如果不涉及元素的刪除,那麼兩者的值會是一樣的。而一旦刪除某個已存在的key,那麼ma_used會減1,而dk_nentries則保持不變。

首先ma_used減1表示鍵值對數量相比之前少了一個,這顯然符合我們在Python裏面使用字典時的表現;但我們知道元素的刪除其實是僞刪除,會將對應的entry從active態變成dummy態,然而entry的總數量並沒有改變。

也就是說,ma_used其實等於active態的entry總數;如果將dk_nentries減去dummy態的entry總數,那麼得到的就是ma_used。

所以這就是兩者的區別,我們對一個字典使用len函數,獲取的也是ma_used,而不是dk_nentries。

static Py_ssize_t
dict_length(PyDictObject *mp)
{
    return mp->ma_used;
}

這算是一個遺漏的點,這裏補充一下,然後來看看字典是如何擴容的。

字典的擴容

當已使用entry的數量達到了總容量的2/3時,會發生擴容。但解釋器要怎麼判斷entry數量是否達到了總容量的2/3呢?

我們說Python在早期只有一個鍵值對數組,這個鍵值對數組不僅要存儲具體的entry,還要完成哈希索引數組的功能。本來這個方式很簡單,但是內存浪費嚴重,於是後面Python官方就將一個數組拆成兩個數組來實現。

不是說只能用2/3嗎?那我給鍵值對數組就申請容量的2/3,並且只負責存儲鍵值對。至於索引,則由哈希索引數組來體現。通過將key映射成索引,可以找到哈希索引數組中指定的槽,再根據槽裏面存儲的值,可以在鍵值對數組中找到指定entry。

因此減少內存開銷的核心就在於,減少鍵值對數組的浪費。

所以哈希索引數組的長度就可以看成是哈希表的容量,而鍵值對數組的長度本身就是哈希索引數組的2/3、或者說容量的2/3。那麼很明顯,當鍵值對數組滿了,就說明當前的哈希表要擴容了。

//增長率
#define GROWTH_RATE(d) ((d)->ma_used*3)

並且擴容的時候,新哈希表的容量爲大於等於ma_used3的最小2的冪次方。假設當前ma_used3等於63,那麼擴容之後的容量就是64,也就是2的8次方。

總而言之新哈希表的容量不能小於ma_used*3,並且等於2的冪次方,基於這兩個限制條件,去取最小值。

並且注意是ma_used*3,不是dk_nentries。因爲dk_nentries還包含了dummy態的entry,但是哈希表在擴容的時候會將其丟棄,只保留active態的entry。所以擴容時,新哈希表的長度取決於ma_used。

然後我們來看看擴容對應的具體邏輯。

static int
insertion_resize(PyDictObject *mp)
{  
    //本質上調用了dictresize
    //傳入PyDictObject * 和增長率
    return dictresize(mp, GROWTH_RATE(mp));
}

所以核心藏在dictresize函數裏面。

static int
dictresize(PyDictObject *mp, Py_ssize_t minsize)
{  
    //新的哈希表容量,以及當前老哈希表的鍵值對個數
    Py_ssize_t newsize, numentries;
    //老哈希表的ma_keys
    PyDictKeysObject *oldkeys;
    //老哈希表的ma_values
    PyObject **oldvalues;
    //老哈希表的dk_entries,新哈希表的dk_entries
    PyDictKeyEntry *oldentries, *newentries;

    /* 確定哈希表的大小*/
    //PyDict_MINSIZE等於8,所以哈希表的容量最少是8
    //然後不斷左移一位,也就是乘上2
    //因爲哈希表的容量必須是2的冪次方
    for (newsize = PyDict_MINSIZE;
    //直到newsize大於等於minsize爲止
    //這個minsize就是我們傳遞的參數,等於ma_used*3
         newsize < minsize && newsize > 0;
         newsize <<= 1)
        ;
    if (newsize <= 0) {
        PyErr_NoMemory();
        return -1;
    }
  
    //獲取老哈希表的ma_keys
    oldkeys = mp->ma_keys;

    /* 創建能夠容納newsize個entry的內存空間 */
    mp->ma_keys = new_keys_object(newsize);
    if (mp->ma_keys == NULL) {
        //把老哈希表的key拷貝過去
        mp->ma_keys = oldkeys;
        return -1;
    }
    assert(mp->ma_keys->dk_usable >= mp->ma_used);
    //如果之前設置了探測函數
    //那麼也作爲新哈希表的探測函數
    if (oldkeys->dk_lookup == lookdict)
        mp->ma_keys->dk_lookup = lookdict;
  
    //獲取當前鍵值對的個數
    numentries = mp->ma_used;
    //獲取老哈希表的dk_entries
    oldentries = DK_ENTRIES(oldkeys);
    //獲取新哈希表的dk_entries
    newentries = DK_ENTRIES(mp->ma_keys);
    //獲取新哈希表的ma_values
    oldvalues = mp->ma_values;
    //如果oldvalues不爲NULL,說明是一個split table
    //分離表的特點是key是字符串
    //並且分離表不支持擴容,如果想擴容
    //那麼需要把split table轉換成combined table
    if (oldvalues != NULL) {
        for (Py_ssize_t i = 0; i < numentries; i++) {
            assert(oldvalues[i] != NULL);
        //獲取ma_values數組裏面的元素
        //依次設置到PyDictKeyEntry對象裏面去
            PyDictKeyEntry *ep = &oldentries[i];
            PyObject *key = ep->me_key;
            Py_INCREF(key);
            newentries[i].me_key = key;
            newentries[i].me_hash = ep->me_hash;
            newentries[i].me_value = oldvalues[i];
        }
    
        //減少原來對oldkeys的引用計數
        DK_DECREF(oldkeys);
        //將ma_values設置爲NULL
        //因爲所有的value都存在了PyDictKeyEntry對象的me_value裏面
        mp->ma_values = NULL;
        if (oldvalues != empty_values) {
            free_values(oldvalues);
        }
    }
    // 否則的話說明這本身就是一個combined table
    else { 
      //numentries等於mp->ma_used,也就是鍵值對的個數
      //如果等於oldkeys->dk_nentries
      //證明沒有dummy態的entry
        if (oldkeys->dk_nentries == numentries) {
        //那麼直接將舊的entries拷貝到新的entries裏面去
            memcpy(newentries, oldentries, numentries * sizeof(PyDictKeyEntry));
        }
      //否則說明存在dummy態的entry
        else {
        //active態的entry搬到新table中
        //dummy態的entry則被丟棄
            PyDictKeyEntry *ep = oldentries;
            for (Py_ssize_t i = 0; i < numentries; i++) {
                while (ep->me_value == NULL)
                    ep++;
                newentries[i] = *ep++;
            }
        }
    
        //字典的緩存池操作,後面介紹
        assert(oldkeys->dk_lookup != lookdict_split);
        assert(oldkeys->dk_refcnt == 1);
        if (oldkeys->dk_size == PyDict_MINSIZE &&
            numfreekeys < PyDict_MAXFREELIST) {
            DK_DEBUG_DECREF keys_free_list[numfreekeys++] = oldkeys;
        }
        else {
            DK_DEBUG_DECREF PyObject_FREE(oldkeys);
        }
    }
  
    //建立哈希表索引
    build_indices(mp->ma_keys, newentries, numentries);
    mp->ma_keys->dk_usable -= numentries;
    mp->ma_keys->dk_nentries = numentries;
    return 0;
}

代碼雖然雖然有點長,但是邏輯很好理解:

  • 首先要確定哈希表的大小,很顯然這個大小一定要大於minsize。這個minsize我們已經看到了,是通過宏定義的,等於ma_used的3倍;
  • 根據新的table,重新申請內存;
  • 將原來的處於active態的entry拷貝到新的內存當中,而對於處於dummy態的entry則直接丟棄。可以丟棄的原因我們前面也說過了。因爲哈希表擴容會申請的一個新的數組,直接將原來的active態的entry組成一條新的探測鏈即可,因此也就不需要這些dummy態的entry了。

以上就是本次分享的所有內容,想要了解更多歡迎前往公衆號:Python編程學習圈,每日干貨分享

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章