python源碼分析:dict對象的實現
源代碼選用 最常見的 cpython
首先來看看構建dict的基礎設施:
typedef struct {
Py_ssize_t me_hash;
PyObject *me_key;
PyObject *me_value;
} PyDictEntry;
這個結構體為dict中key-value,其中的me_hash為me_key的hash值,[空間換時間]。除此之外,我們發現me_key與me_value都是PyObject指針類型,這也說明了為什麽dict中的key與value可以為python中的任何類型數據。
struct _dictobject {
PyObject_HEAD
Py_ssize_t ma_fill;
Py_ssize_t ma_used;
Py_ssize_t ma_mask;
PyDictEntry *ma_table;
PyDictEntry *(*ma_lookup)(PyDictObject *mp, PyObject *key, long hash);
PyDictEntry ma_smalltable[PyDict_MINSIZE];
};
這個結構體便是dict了。按照我們通常的理解,dict應該是可變長對象啊!為什麽這裏還有PyObject_HEAD,而不是PyObject_VAR_HEAD。仔細一看,dict的可變長與 string,list,tuple 仍有不同之外,後者可以通過PyObject_VAR_HEAD中的ob_size來指明其內部有效元素的個數。但dict不能這樣做,所以dict幹脆繞開PyObject_VAR_HEAD,而且除了有ma_used這個字段來交代出其有效元素的個數,還需要ma_fill來交代清楚曾經有效元素的個數(用來計算加載率)。
ma_mask,則牽扯到hash中的散列函數;
ma_smalltable,python一向的有限空間換時間,一個小池子來應付大多數的小dict(不超過PyDict_MINSIZE);
ma_lookup,則是一次探測與二次探測函數的實現。
在展開dict實現細節前,先把dict使用的解決沖突的開放定址法介紹一下。我們知道哈希,就是將一個無限集合映射到一個有限集,如果選擇理想的hash函數,能夠將預期處理到的元素均勻分布到有限集中即可在O(1)時間內完成元素查找。但理想的hash函數是不存在的,且由於映射的本質(無限到有限)必然出出現一個位置有多個元素要‘占據’,這就需要解決沖突。現有的解決沖突的方法:
- 開放定址法
- 鏈地址法
- 多哈希函數法
- 建域法
其中建域法基本思想為假設哈希函數的值域為[0,m-1],則設向量HashTable[0..m-1]為基本表,另外設立存儲空間向量OverTable[0..v]用以存儲發生沖突的記錄。
其中前兩種方法實現最為簡單高效,下面回顧下開放定址與鏈地址法。
開放定址法:形成hash表時,某元素在第一次探測其應該占有的位置時,如果發現此處(記為A)已經被別人占了,那就在從A開始,再次探測(當然這次探測使用的hash函數與第一次已經不一樣了),如果發現還是被別人占了,那麽繼續探測,至到找到一個可用位置(也有可能在當下條件下永遠找不到)。開放地址法有一個至關重要的問題需要解決,那就是在一個元素離開hash表時,如何處理離開後的位置狀態。如果設置為原始空狀態,那麽後續的有效元素就無法識別了,因為在查找時同樣是依據上面的探測規則進行查找,所以必須告訴探測函數某個位置雖然無有效元素了,但後續的探測可能會出現有效元素。我們可以發現,開放定址法很容易發生沖突(主要是一次探測以上成功的元素占取其它元素應該在第一次探測成功的位置),所以就需要加大hash有效空間。
鏈地址法:鏈地址法的思想很簡單,你不是可能會出現多個元素對應同一個位置,那麽我就在這個位置拉出一個鏈表來存放所以hash到這個位置的元素。很簡單吧,還節約內存呢!很遺憾,python的設計者沒有選它。
那為什麽python發明者選擇了開放定址而不是鏈地址法,在看python源碼時看到這麽一段話:
Open addressing is preferred over chaining since the link overhead(開銷) for chaining would be substantial(大量) (100% with typical malloc overhead).
由於鏈地址法需要動態的生成鏈表結點(malloc),所以時間效率不如開放定址法(但開放定址法的裝載率不能高於2/3,相對於鏈地址法的空間開銷也是毋庸置疑的),由此可以看出python的設計時代已經不是那個內存只有512k可供使用的時代了,對內存的苛刻已經讓步於效率。當然這需要考慮到python由於實現動態而必須靠自身的設計將損失的時間效率盡可能地補回來。
好了,交待完開放定址法與為什麽python設計者選擇它後,我們來看看dict如何實現這個算法的。前面已經看到每個key-value由一個Entry結構體實現,python就是利用entry自身的信息來指明每個位置的狀態:原始空狀態、有效元素離去狀態、有效元素占據狀態。
- 原始空:me_key:Null ;me_value:Null
- 有效元素離去:me_key:dummy; me_value:Null
- 有效元素占據:me_key:not Null and not dummy ;me_value:not Null
其中dict的hash方法與沖突解決方法的思路如下:
lookdict(k,v)
- index <- hash1(k),freeslot<-Null,根據me_key與me_value選擇2、3、4一個執行;
- 查看index處的值處於’有效元素占據‘狀態,判斷data[index]與v是否一致(地址或內容),一致,則返回查找成功;否則轉5
- index所指向的位置處於’原始空‘狀態,查找失敗,若freeslot==Null返回index;否則返回freeslot;轉5
- index所指向的位置處於’有效元素離去‘狀態,freeslot<-index, 轉5
- index <- hash2(index),,轉2
dict的lookdict方法實現充分體現了python對內存的利用率與空間換時間提高效率上,表現為如下方面:
- 內存利用率:當找到原始空狀態時,如果前面已經找到dummy態的entry,則會將其返回。
- 提高效率:ma_table始終指向有效散列空間的開始位置,在開辟新空間後,small_table就棄之不用了,ma_table改指向新開辟空間的首位置。
python源碼分析:dict對象的實現