這裏先來補充一個之前沒有說的點,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編程學習圈,每日干貨分享