楔子
Python的字典是一種映射型容器對象,保存了鍵(key)到值(value)的映射關系。通過字典,我們可以快速的實現值的查找,json這種數據結構也是借鑒了Python中的字典。而且字典在Python中是經過高度優化的,因為Python底層也在大量的使用字典這種數據結構。
那么這次我們就來全面分析一下Python中的字典。
基本使用
我們先來回顧一下字典的基本使用,然后再來分析它的一些特性以及底層實現。
創建一個字典:
# 創建一個字典
d = {"a": 1, "b": 2}
print(d) # {'a': 1, 'b': 2}
# 或者我們還可以通過dict, 傳入關鍵字參數即可
d = dict(a=1, b=2, c=3, d=4)
print(d) # {'a': 1, 'b': 2, 'c': 3, 'd': 4}
# 當然dict里面還可以接收位置參數, 但是最多接收一個
d1 = dict({"a": 1, "b": 2}, c=3, d=4)
d2 = dict([("a", 1), ("b", 2)], c=3, d=4)
print(d1) # {'a': 1, 'b': 2, 'c': 3, 'd': 4}
print(d2) # {'a': 1, 'b': 2, 'c': 3, 'd': 4}
# 還可以根據已有字典創建新的字典
d = {**{"a": 1, "b": 2}, "c": 3, **{"d": 4}}
print(d) # {'a': 1, 'b': 2, 'c': 3, 'd': 4}
# 當然通過dict也是可以的, 但是注意: 通過**這種方式本質上是把字典變成多個關鍵字參數
# 所以里面的key一定要符合Python的變量規范
d = dict(**{"a": 1, "b": 2}, c=3, **{"d": 4})
print(d) # {'a': 1, 'b': 2, 'c': 3, 'd': 4}
try:
# 這種是不合法的, 因為**{1: 1}等價於1=1
d = dict(**{1: 1})
except Exception as e:
print(e) # keywords must be strings
# 但是這種是合法的
d = {**{1: 1, 2: 2}, **{(1, 2, 3): "嘿嘿"}}
print(d) # {1: 1, 2: 2, (1, 2, 3): '嘿嘿'}
字典支持的操作:
# 創建一個空字典
d = {}
# 可以設置鍵值對
d["name"] = "古明地覺"
d["where"] = "東方地靈殿"
print(d) # {'name': '古明地覺', 'where': '東方地靈殿'}
# 獲取值
print(d["name"]) # 古明地覺
# 更新值, 字典里面的key是不重復的, 所以不會出現一個字典中有多個key的情況出現
d["name"] = "古明地戀"
print(d) # {'name': '古明地戀', 'where': '東方地靈殿'}
# 刪除一個值, 可以使用del或者pop
del d["name"]
d.pop("where")
print(d) # {}
當然字典支持的操作遠不止上面那些,但是這些Python層面上的東西想必所有人都了如指掌了,因為字典支持的操作,僅僅相當於是一些API的調用罷了,對着文檔查一遍、操作一波就完事了。我們重點是要分析字典這種數據結構在底層的實現方式,以及它背后的一些原理,這才是我們需要關注的。
首先字典的底層是借助哈希表實現的,什么是哈希表我們后面會詳細說,總之字典的添加元素、刪除元素、查找元素等操作的平均時間復雜度是O(1)。當然了,在哈希不均勻的情況下,最壞時間復雜度是O(n),但是這種情況很少發生。
我們來測試一下字典的執行效率,看看它和列表之間的區別。一個有1千萬個鍵值對的字典。然后對兩者使用in來查詢某個元素是否存在,
我們測試的方式是,使用if ... in ...來查詢一個元素是否存在,看看它們的耗時如何。
import time
import numpy as np
def test(count: int, value: int):
"""
:param count: 循環次數
:param value: 查詢的元素
:return:
"""
# 有一千萬個隨機數的列表
lst = list(np.random.randint(0, 2 ** 30, size=1000))
# 根據這個列表構造出含有一千萬個鍵值對的字典
d = dict.fromkeys(lst)
# 查詢元素value是否在列表中, 循環count次, 並統計時間
t1 = time.perf_counter()
for _ in range(count):
value in lst
t2 = time.perf_counter()
print("列表查詢耗時:", round(t2 - t1, 2))
# 查詢元素value是否在字典中, 循環count次, 並統計時間
t1 = time.perf_counter()
for _ in range(count):
value in d
t2 = time.perf_counter()
print("字典查詢耗時:", round(t2 - t1, 2))
# 分別查詢一千次、一萬次、十萬次、二十萬次
test(10 ** 3, 22333)
"""
列表查詢耗時: 0.13
字典查詢耗時: 0.0
"""
test(10 ** 4, 22333)
"""
列表查詢耗時: 1.22
字典查詢耗時: 0.0
"""
test(10 ** 5, 22333)
"""
列表查詢耗時: 12.68
字典查詢耗時: 0.01
"""
test(10 ** 5 * 2, 22333)
"""
列表查詢耗時: 25.72
字典查詢耗時: 0.01
"""
我們看到字典的查詢速度非常快,從測試中我們看到,隨着循環次數越來越多,列表所花費的總時間越來越長。但是字典由於查詢所花費的時間極少,查詢速度非常快,所以即便循環50萬次,花費的總時間也不過才0.01秒左右。
此外字典還有一個特點,就是它的"快"不會受到數據量的影響,你從含有一萬個鍵值對的字典中查找,和你從含有一千萬個鍵值對的字典中查找,兩者花費的時間幾乎是沒有區別的。
那么字典到底是使用了什么黑科技,才能達到這么快的效果呢?想要知道答案的話,那就從字典在底層的內部結構中尋找吧。
初識哈希表
由於映射型容器的使用場景非常廣泛,幾乎所有現代語言都支持映射型容器,而且特別關注"鍵"的搜索效率。例如:C++標准模板庫中的 map 就是一種關聯式容器,內部基於紅黑樹實現。紅黑樹是一種平衡二叉樹,能夠提供良好的操作效率,插入、刪除、搜索等關鍵操作的時間復雜度均為\(O(log_{2}n)\),Linux的epoll也是使用了紅黑樹。
而對於Python來講,映射型容器指的就是字典,我們說字典在Python內部是被高度優化的。因為不光我們在用,Python虛擬機在運行時也重度依賴字典,比如:自定義類、以及其實例對象都有自己的屬性字典,還有名字空間本質上也是一個字典,因此Python對字典的要求會更加苛刻。所以Python在實現字典時采用的數據結構肯定是要優於紅黑樹的(至少在添加、刪除、查詢元素等方面)
,也就是說它的時間復雜度是優於紅黑樹的。時間復雜度優於\(O(log_{2}n)\)的數據結構有哪些呢?沒錯,你應該已經猜到了,就是散列表、又稱哈希表。
所以在介紹字典之前,我們需要介紹一下哈希表。當然這里只是先大致介紹一下,能夠一個宏觀的認識,為了在理解字典時能夠方便一些。至於更詳細的內容,我們會在本文的后面介紹。
我們在介紹元組的時候,說元組可以作為字典的key,但是列表不可以,就是因為列表是不可哈希的。哈希表的原理是將key通過哈希函數進行運算轉換為一個數值,用這個數值來充當索引,因此這就有一個前提,就是你的值不可以變。而列表是個可變對象,因此它不可以作為字典的key。
直接這么說的話,可能會感到很迷,我們畫一張圖。
我們發現除了key、value之外,還有一個index。其實哈希表本質上也是使用了索引的思想,我們知道雖然列表在遍歷的時候是個時間復雜度為O(n)的操作,但是通過索引定位元素則是一個時間復雜度為O(1)的操作,不管你列表有多長,通過索引總是能瞬間定位到指定元素。所以哈希表實際上也是使用了數組(列表)
的思想,會把這個key通過哈希函數映射成一個數值,作為索引。至於它是怎么映射的,我們后面再談,現在我們就假設是按照我們接下來說的方法映射的。
比如我們這里有一個能容納10個元素的字典(這里假設容量為10其實是不准確的,容量應該是2的n次方,但是這里只是介紹哈希表,所以不管了)
,我們先設置d["satori"]=82,那么會對"satori"這個字符串進行一個哈希運算,然后再和9、也就是和當前的總容量減一(最大索引)
進行按位與,這樣的是不是能夠得到一個小於等於9的數呢?假設是5,那么就存在索引為5地方。然后又進行d["koishi"]=83,那么按照同樣的規則運算得到8,那么就存在索引為8的位置,同理第三次設置d["mashiro"]=80,對mashiro進行哈希、取模,得到2,那么存儲在索引為2的地方。
同理當我們根據鍵來獲取值的時候,比如:d["satori"],那么同樣會對字符串"satori"進行哈希、取模,得到索引發現是5,直接把索引為5的value給取出來。
當然這種方式肯定存在缺陷,比如:
不同的值進行哈希、取模運算之后得到的結果一定是不同的嗎?
在運算之后得到索引的時候,發現這個位置已經有人占了怎么辦?
取值的時候,索引為5,可如果索引為5對應的key和我們指定獲取的key不一致怎么辦?
所以哈希值是有沖突的,如果一旦沖突,那么Python底層會改變策略重新映射,直到映射出來的索引沒有人用。比如我們設置一個新的key、value,d["tomoyo"]=88,可是我們對"tomoyo"這個key進行映射之后得到的結果也是5,而索引為5的地方已經被key為"satori"的鍵給占了,那么Python就會改變規則來對"tomoyo"重新進行運算,找到一個空位置進行添加。但如果我們再次設置d["satori"]=100,那么對satori進行映射得到的結果也是5,而key是一致的,那么就會把對應的值進行修改。
同理,當我們獲取值的時候,d["tomoyo"],對key進行映射,得到索引。但是發現key不是"tomoyo"而是"satori",於是改變規則
(這個規則跟設置key沖突時,采用的規則是一樣的)
,重新映射,得到索引,然后發現key是一致的,於是將值取出來。但如果我們指定了一個不存在的key,那么哈希映射,找到對應索引,發現沒有key,證明我們指定的key是不存在的。但如果有的話,發現key和我們指定的key不相等,說明哈希運算得到索引只是碰巧一樣,但由於key不一樣,因此會改變規則重新運算,得到新的索引。然而發現沒有對應的key,於是報錯:指定的key不存在。
所以從這里就已經能說明問題了,就是把key轉換成類似列表的索引。可能有人問,這些值貌似不是連續的啊,對的,肯定不是連續的。並不是說你先存,你的索引就小、就在前面,這是由key進行哈希運算之后的結果決定的。而且容量有10個,目前我們只存了4個元素,那么哈希表、或者說字典會不會擴容呢?當然,既然是可變對象,當然會擴容。並且它還不是像列表那樣,容量不夠才擴容,而當元素個數達到容量的三分之二的時候就會擴容。
我們可以認為字典底層還是使用了索引的思想,字典不可能會像列表那樣,元素之間是連續的,一個一個挨在一起的。既然是哈希運算,得到的值肯定是隨機的。容量為10,盡管有6個是空着的,但是沒關系,我們只要保證設置的元素整體上是有序的即可。就好比有10張桌椅,小紅坐在第3張,小明坐在第8張,盡管有空着的,但是沒關系,就讓它空着。只要我到第3張桌椅能夠找到小紅、第8張可以找到小明即可。這些桌椅的位置就可以看成是索引,只要我通過索引能夠找到對應的元素即可。但是容量為10,為什么不能全部占滿之后再擴容呢?試想一下,既然是隨機的,那么肯定會出現哈希值碰撞,並且當元素個數到達三分之二之后,這種碰撞的概率非常大。因此當容量到達三分之二的時候,就會申請一份更大的空間,以便來容納新的元素。
所以我們發現哈希表實際上就是一種空間換時間的方法,如果容量為100,那么就相當於有100個位置,每個元素都進行哈希映射,找到自己的位置。各自的位置都是不固定的,也許會空出來很多元素,但是無所謂,只要保證這些元素在100個位置上是相對有序、通過哈希運算得到索引之后,可以在相應的位置找到它即可。
所以相信應該所有人都能明白為什么哈希表的時間復雜度是O(1)了,就是因為使用了索引的思想,每一個索引都是連續的,只不過一部分索引沒有相應的key、value罷了。但這無所謂,因為索引和key、value是一一對應的,通過索引我們能瞬間定位到指定的key,再來檢測key是否存在以及和我們指定的key是否一致。如果不存在,那么不好意思,證明這個地方根本沒有key、value,說明我們指定了一個不存在的key。而且由於元素個數達到容量的三分之二的時候,碰撞的概率非常大,因此幾乎不可能出現容量正好都排滿的情況,否則那要改變規則、重復映射多少次啊。
一句話總結:哈希表就是一種空間換時間的方法
設置鍵值對如下圖所示:
根據鍵獲取值,如下圖所示:
字典的底層結構--PyDictObject
下面我們來看看字典在底層對應的結構體PyDictObject,位於Include/dictobject.h中,它的實現還是很復雜的。
typedef struct {
//注意這里是PyObject_HEAD,不是PyObject_VAR_HEAD
//PyObject_HEAD只有引用計數和類型,沒有ob_size
//但字典顯然是一個變長對象,因此肯定有別的成員來維護字典的長度, 當然字典也有容量
PyObject_HEAD
//字典里面鍵值對的個數
Py_ssize_t ma_used;
//字典版本:全局唯一,每一次value的變動,都會導致其改變
uint64_t ma_version_tag;
//ma_keys從定義上來看,它是一個指針, 指向了一個PyDictKeysObject對象
//事實上在底層哈希表分為兩種,分別是:combined table(結合表)和split table(分離表)
//如果是結合表,那么鍵值對存在ma_keys里面,此時下面的ma_values為NULL
PyDictKeysObject *ma_keys;
//如果是分離表,那么"鍵"存在ma_keys里,"value"存在ma_values里
PyObject **ma_values;
} PyDictObject;
//我們下面介紹的是常用的結合表
所以名字起得很形象,結合表的話,鍵和值就存在一起;分離表的話,鍵和值就存在不同的地方。至於為什么這么做,后面會解釋。
整個結構體實際上是看不出來啥的,主要的原因就在那個PyDictKeysObject,我們需要再來看看它長什么樣子。
//我們看到這是給struct _dictkeysobject起了一個別名,所以真正要看的是_dictkeysobject
//它位於Objects/dict-common.h中
typedef struct _dictkeysobject PyDictKeysObject;
struct _dictkeysobject {
//引用計數,跟映射視圖的實現有關,類似於對象的引用計數
Py_ssize_t dk_refcnt;
//哈希表大小,比如是2的n次方,這樣可將模運算優化成按位與運算
//所以我們在上面介紹哈希表的時候,假設字典能容納10個元素,這個假設是不准確的,不過無所謂啦
Py_ssize_t dk_size;
/* Function to lookup in the hash table (dk_indices):
- lookdict(): general-purpose, and may return DKIX_ERROR if (and
only if) a comparison raises an exception.
- lookdict_unicode(): specialized to Unicode string keys, comparison of
which can never raise an exception; that function can never return
DKIX_ERROR.
- lookdict_unicode_nodummy(): similar to lookdict_unicode() but further
specialized for Unicode string keys that cannot be the <dummy> value.
- lookdict_split(): Version of lookdict() for split tables. */
//哈希查找函數的指針,從注釋上我們看到有好幾種,會根據字典的當前狀態選用最優的版本
dict_lookup_func dk_lookup;
//哈希表中可用的entry數量,這個entry你可以理解為鍵值對,一個entry就是一個鍵值對
//哈希表是有容量的,所以這個dk_usable就表示當前的容量還能容納多個entry
Py_ssize_t dk_usable;
//哈希表中已經使用的entry數量
Py_ssize_t dk_nentries;
/* Actual hash table of dk_size entries. It holds indices in dk_entries,
or DKIX_EMPTY(-1) or DKIX_DUMMY(-2).
Indices must be: 0 <= indice < USABLE_FRACTION(dk_size).
The size in bytes of an indice depends on dk_size:
- 1 byte if dk_size <= 0xff (char*)
- 2 bytes if dk_size <= 0xffff (int16_t*)
- 4 bytes if dk_size <= 0xffffffff (int32_t*)
- 8 bytes otherwise (int64_t*)
Dynamically sized, SIZEOF_VOID_P is minimum. */
//哈希表 起始地址 ,哈希表后緊接着 鍵值對數組 dk_entries 。
char dk_indices[]; /* char is required to avoid strict aliasing. */
/* "PyDictKeyEntry dk_entries[dk_usable];" array follows:
see the DK_ENTRIES() macro */
};
而我們說一個鍵值對在底層對應一個entry,而這個entry指的就是PyDictKeyEntry對象,我們看看這個結構體長什么樣子。
typedef struct {
/* Cached hash code of me_key. */
Py_hash_t me_hash;
PyObject *me_key;
PyObject *me_value; /* This field is only meaningful for combined tables */
} PyDictKeyEntry;
顯然ma_key和ma_value就是鍵和值,我們之前說Python中變量、以及容器內部的元素都是泛型指針PyObject *,其中也包括字典,這里也得到了證明。但是我們看到entry除了有鍵和值之外,還有一個me_hash,它表示鍵對應的哈希值,這樣可以避免重復計算。
至此,字典的整個底層結構就非常清晰了。
字典的真正實現藏在PyDictKeysObject中,它的內部包含兩個關鍵數組:一個是哈希索引數組dk_indices,另一個是鍵值對數組dk_entries。字典所維護的鍵值對(entry)
按照先來后到的順序保存在鍵值對數組中,而哈希索引數組則保存"鍵值對"在"鍵值對數組"中的索引。另外,哈希索引數組中的一個位置我們稱之為一個"槽",比如圖中的哈希索引數組便有8個槽,它字典的數量是相等的。
比如我們往空字典(但是容量已經有了, 初始是8個, 不過可用的數量為5個)
中插入鍵值對"夏色祭": "お娘"
的時候,Python會執行以下步驟:
1. 將鍵值對保存在dk_entries中,由於初始字典是空的,所以會保存在dk_entries數組中索引為0的位置
2. 通過哈希函數將鍵"夏色祭"映射成一個數值,作為索引,假設是5
3. 將插入的鍵值對在數組中的索引0,保存在哈希索引數組中索引為5的槽中
然后當我們在查找鍵"夏色祭"對應的值的時候,便可瞬間定位。過程如下:
1. 通過哈希函數將鍵"夏色祭"映射成數值,也就是索引。因為在設置的時候索引是5,所以在獲取的時候映射出來的索引肯定也是5
2. 找到哈希索引數組中索引為5的槽,得到其保存的0,這里的0對應鍵值對數組的索引
3. 找到鍵值對數組中索引為0的位置,取出PyDictKeyEntry中的me_value,也就是值(當然肯定要先比較key、也就是me_key是否一致, 不一致則重新映射。當然如果該位置為NULL, 那么直接報出KeyError)
由於哈希值計算以及數組定位均是O(1)的時間復雜度,所以字典的查詢速度才會這么快。當然我們上面沒有涉及到哈希沖突,關於哈希沖突我們會在后面詳細說,但是就字典在存儲和獲取的時候就是上面那個流程。
當然我們在上面的"初識哈希表"這一部分,為了避免牽扯太多,所以說的相對簡化了。比如:"mashiro": 80,我們說"mashiro"映射出來的索引是2,那么鍵值對就直接存在索引為2的地方。這實際上是簡化了,因為這相當於把"哈希索引數組"和"鍵值對數組"合在一塊了。而在早期的Python中,它也確實是這么做的。
但是從上面字典的結構圖中我們看到,實際上是先將"鍵值對"按照先來后到的順序存在一個數組
(鍵值對數組)
中,然后再把其索引存放在另一個數組(哈希索引數組)
中索引為2("mashiro"映射出來的索引是2)
的地方。所以在查找的時候,映射出來的索引2其實是哈希索引數組對應的索引。然后對應的槽也存儲了一個索引,這個索引是鍵值對數組對應的索引,假設是4,所以會再根據索引4從鍵值對數組中獲取指定的PyDictKeyEntry對象,再根據該對象獲取指定的value。所以可以看出兩者整體思想是基本類似的,理解起來沒有什么區別,甚至第一種方式實現起來還會更簡單一些。但為什么采用后者這種實現方式,以及這兩者之間的區別,我們在后面還會專門分析,之所以采用后者主要是基於內存的考量。
容量策略
根據字典的行為我們斷定,字典肯定和列表一樣有着"預分配機制"。因為可以擴容,那么為了避免頻繁申請內存,所以在擴容的是時候會將容量申請的比鍵值對個數要多一些。那么字典的容量策略是怎么樣的呢?
在Object/dictobject.c源文件中我們可以看到一個宏定義:
#define PyDict_MINSIZE 8
從這個宏定義中我們可以得知,一個字典的最小容量是8,或者說內部哈希表的長度最小是8。
哈希表越密集,哈希沖突則越頻繁,性能也就越差。因此,哈希表必須是一種 稀疏 的表結構,越稀疏則性能越好。但由於 "內存開銷" 的制約,哈希表不可能無限地稀疏,所以需要在時間和空間上進行權衡。實踐經驗表明,一個1/2到2/3滿的哈希表,性能較為理想——以相對合理的 "內存" 換取相對高效的 "執行性能"。
為保證哈希表的稀疏程度,進而控制哈希沖突頻率, Python 通過 宏USABLE_FRACTION 將哈希表內元素控制在2/3以內。宏USABLE_FRACTION 根據哈希表規模n,計算哈希表可存儲元素的個數,也就是 鍵值對數組 的長度。以長度為 8 的哈希表為例,最多可以保持 5 個鍵值對,超出則需要擴容。
而USABLE_FRACTION 是一個非常重要的宏定義,位於源文件 Objects/dictobject.c 中:
#define USABLE_FRACTION(n) (((n) << 1)/3)
哈希表規模一定是2的n次方,也就是說 Python 采用"翻倍擴容"的策略。例如,長度為 8 的哈希表擴容后,長度變為 16 。
最后,我們來考察一個空字典所占用的內存空間。Python 為空字典分配了一個長度為 8 的哈希表,因而也要占用相當多的內存,主要有以下幾個部分組成:
PyDictObject中有6個成員,一個8字節,加起來共48字節
PyDictKeysObject中有7個成員,除了兩個數組之外,剩余的每個成員也是一個8字節,所以加起來40字節
而剩余的兩個數組,一個是char類型的數組dk_indices,里面1個元素占1字節;還有一個PyDictKeyEntry類型的數組dk_entries,里面一個元素占24字節,因為PyDictKeyEntry里面有三個成員,一個8字節。但是注意:字典容量為8,說明哈希索引數組長度為8,但是鍵值對數組dk_entries長度是5,至於原因我們上面分析的很透徹了。因此這兩個數組加起來總共是 8 + 24 * 5 = 128字節
所以一個空字典占用的內存是:48 + 40 + 128 = 216字節,我們來測試一下:
>>> d = dict()
>>> d.__sizeof__()
216
>>>
但是注意:我們說空字典容量為8,但前提它不是通過Python/C API創建的,如果是d = {}這種方式,那么初始容量就是0,顯然此時只有48字節,因為ma_keys此時是NULL。
>>> d = {}
>>> d.__sizeof__()
48
>>>
另外,我們看到在計算內存的時候使用的不是sys.getsizeof,而是對象的__sizeof__方法,這兩者有什么區別呢?答案是使用sys.getsizeof計算出來內存大小會比調用對象的__sizeof__方法計算出來的內存大小多出16個字節。
>>> import sys
>>>
>>> sys.getsizeof(dict()), dict().__sizeof__()
(232, 216)
>>>
>>> sys.getsizeof({}), {}.__sizeof__()
(64, 48)
>>>
之所以會出現這種情況,是因為sys.getsizeof將垃圾回收器的開銷也考慮進去了。
我們說Python底層是通過引用計數來判斷對象是否被回收,但是引用計數有一個致命缺陷就是它無法解決循環引用的問題,所以Python內部的gc就是專門用來解決循環引用的。如果創建了一個可能會發生循環引用的對象,那么Python會將該對象掛在鏈表上,當然鏈表總共有三條,分別是零代鏈表、一代鏈表、二代鏈表。
先將對象掛在零代鏈表上,Python的gc一旦發動,那么會采用三色標記模型來對零代鏈表上的對象進行標記--清除,將那些發生了循環引用的對象的引用計數減一。
而這樣的鏈表為什么有三條呢?試想一下,gc發動的成本也是很高的,如果在gc的洗禮下還能活下來的對象,說明其暫時是較穩的,沒有必要每次都對其進行檢測。所以會將零代鏈表中比較穩定的對象移動到一代鏈表中,同理二代鏈表也是同理。當清理零代鏈表達到10次的時候,會清理一次一代鏈表,清理一代鏈表達到10次的時候會清理一次二代鏈表。這樣的技術在Python中也被成為分代技術。
而移動到鏈表中的對象,除了 PyObject 之外還會有一個額外的 PyGC_Head,所以 sys.getsizeof 計算結果多出的16字節,就是這個 PyGC_Head 所占的大小(在后續介紹GC的時候會說)。
但是整型、浮點型、字符串等等,它們使用sys.getsizeof和調用__sizeof__計算出來的結果是一樣的。
>>> sys.getsizeof(123), (123).__sizeof__()
(28, 28)
>>>
>>> sys.getsizeof("matsuri"), "matsuri".__sizeof__()
(56, 56)
至於為什么一樣,想必你已經猜到了,因為整型、字符串這種對象是不可能發生循環引用的,只有容器對象才會有可能發生循環引用。我們說Python中的gc是專門針對可能發生循環引用的對象的,對於不會發生循環引用的對象來說,不會參與gc,一個引用計數足夠了,所以它們使用兩種方式計算出的結果是一樣的。
關於垃圾回收,是一門很復雜的學問,我們這里簡單提一下。在該系列的后續,我們會詳細的探討Python中的垃圾回收。
內存優化
我們說在Python早期,哈希表並沒有分成兩個數組實現,而是由一個鍵值對數組實現,這個數組也承擔哈希索引的角色:
我們看到這種結構不正是我們在介紹哈希表的時候說的嗎?一個鍵值對數組既用來存儲,又用來充當索引,無需分成兩個步驟,而且這種方式也似乎更簡單、更直觀。而我們說Python在早期確實是通過這種方式實現的哈希表,只是這種實現方式有一個弊端,就是太耗費內存了。
我們說哈希表必須保持一定程度的稀疏,最多只有2/3滿,這意味着至少要浪費1/3的空間。
所以Python為了盡量節省內存,將鍵值對數組壓縮到原來的2/3,只用來存儲,而對key進行映射得到的索引由另一個數組(哈希索引數組)
來存儲。因為鍵值對數組里面一個元素要占用24字節,而哈希索引數組在容量不超過256的時候,里面一個元素只占一個字節;容量不超過65536的時候,里面一個元素只占兩個字節,其它以此類推。由於哈希索引數組里面的元素大小比鍵值對數組里面的元素大小要小很多,所以將哈希表分成兩個數組(避免鍵值對數組的浪費)
來實現會更加的節省內存。我們可以舉個栗子計算一下,假設我們容量是2 ** 16 = 65536的哈希表。
如果是通過第一種方式,只用一個數組來存儲的話:
>>> 2 ** 16 * 24
1572864 # 總共需要這么多字節來存儲
>>>
>>> 2 ** 16 * 24 // 3
524288 # 除以3, 會浪費這么多字節
>>>
如果是通過第二種方式,使用兩個數組來存儲的話:
>>> 2 ** 16 * 24 * 2 / 3 + 2 ** 16 * 2
1179648 # 容量雖然是2 ** 16次方, 但是鍵值對數組是容量的2 / 3, 然后加上哈希索引數組的大小
>>>
所以一個數組存儲比兩個數組存儲要多用393216字節的內存,因此Python選擇使用兩個數組來進行存儲。
小結
我們通過考察字典的搜索效率,並深入源碼研究其內部哈希表的實現,得到以下結論:
字典是一種高效的映射式容器,每秒完成高達 *200* 多萬次搜索操作;
字典內部由哈希表實現,哈希表的稀疏特性意味着昂貴的內存開銷;
為優化內存使用,Python將哈希表分為 哈希索引數組 和 鍵值對數組,也就是通過兩個數組來實現;
哈希表在 1/2 到 2/3 滿時,性能較為理想,較好地平衡了 內存開銷 與 搜索效率;
深入哈希表
通過字典的底層實現,我們找到字典快速、高效的秘密--哈希表。對於映射式容器,一般是通過平衡搜索樹或哈希表實現。而Python的字典選用了哈希表,主要是考慮到在搜索方面哈希表的效率更高。因為我們說Python底層重度依賴字典,所以對字典在搜索、設置元素方面的性能,要求的更加苛刻。
但是由於哈希表的稀疏特性,導致其會有巨大的內存犧牲,而為了優化,Python別出心裁的將哈希表分成兩部分來實現,分別是:哈希索引數組和鍵值對數組。
但是顯然這當中還有很多細節我們沒有說,比如:哈希函數到底是怎么將一個鍵映射成索引的?哈希沖突了怎么辦?哈希攻擊又是什么?以及刪除操作(沒有表面想的那么簡單)
如何實現?而下面我們就來攻破這些難題,深入理解哈希表。
哈希值
Python內置函數hash會返回對象的哈希值,哈希表依賴於哈希值。
而根據哈希表的性質,我們知道鍵對象必須滿足以下兩個條件,否則哈希表便無法正常工作。
1. 哈希表在對象的整個生命周期內不可以改變
2. 可比較,如果兩個對象相等(使用==操作符結果為True),那么它們的哈希值一定相同
滿足這兩個條件的對象便是"可哈希(hashable)"對象,只有可哈希對象才可以作為哈希表的鍵(key)
。因此像字典、集合等底層由哈希表實現的對象,其元素必須是可哈希對象。
Python中內置的不可變對象都是可哈希對象,比如:整數、浮點數、字符串、元組(元組里面也要是不可變對象)
等等,而像可變對象,比如列表、字典等等便不可作為哈希表的鍵。
>>> {1: 1, "xxx": [1, 2, 3], 3.14: 333} # 鍵是可哈希的就行,值是否可哈希則沒有要求
{1: 1, 'xxx': [1, 2, 3], 3.14: 333}
>>>
>>> {[]: 123} # 列表是可變對象,因為無法哈希
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>>
>>> {(1, 2, 3): 123} # 元組也是可哈希的
{(1, 2, 3): 123}
>>> {(1, 2, 3, []): 123} # 但如果元組里面包含了不可哈希的對象,那么整體也會變成不可哈希對象
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>>
而我們自定義的類的實例對象也是可哈希的,且哈希值是通過對象的地址計算得到的。
class A:
pass
a1 = A()
a2 = A()
print(hash(a1), hash(a2)) # 141215868971 141215869022
而且Python也支持我們重寫哈希函數,比如:
class A:
def __hash__(self):
return 123
a1 = A()
a2 = A()
print(hash(a1), hash(a2)) # 123 123
print({a1: 1, a2: 2})
# {<__main__.A object at 0x000002A2842282B0>: 1, <__main__.A object at 0x000002A2842285E0>: 2}
並且我們看到雖然哈希值一樣,但是在作為字典的鍵的時候,如果發生了沖突,會改變規則。注意:我們自定義的類的實例對象默認都是可哈希的,但如果類里面重寫了__eq__方法,且沒有重寫__hash__方法的話,那么這個類的實例對象就不可哈希了。
class A:
def __eq__(self, other):
return True
a1 = A()
a2 = A()
try:
print(hash(a1), hash(a2))
except Exception as e:
print(e) # unhashable type: 'A'
為什么會有這種現象呢?首先我們說在沒有重寫__hash__方法的時候,哈希值默認是根據對象的地址計算得到的。而且對象如果相等
(使用==操作符會得到True)
,那么哈希值一定是一樣的。但是我們重寫了__eq__,相當於控制了==操作符的比較結果,兩個對象是否相等就是由我們來控制了,可哈希值卻還是根據地址計算得到的。兩個對象地址不同,哈希值不同,但是對象卻可以相等、又可以不相等,這就導致了矛盾。因此在重寫了__eq__、但是沒有重寫__hash__的情況下,其實例對象便不可哈希了。但如果重寫了__hash__,那么哈希值計算方式就不再通過地址計算了,因此此時是可以哈希的。
class A:
def __eq__(self, other):
return True
def __hash__(self):
return 123
a1 = A()
a2 = A()
print({a1: 1, a2: 2}) # {<__main__.A object at 0x000001CEC8D682B0>: 2}
"""
此時我們看到字典里面只有一個元素了,因為我們說重寫了__hash__方法之后,計算得到哈希值都是一樣的
但是在沒有重寫__eq__的情況下,默認都是不相等的。如果哈希值一樣,但是對象不相等,所以會重新映射。
而我們重寫了__eq__,返回的結果是True,所以Python認為對象是相等的,由於key的不重復性,保留了后面的鍵值對
"""
同樣的,我們再來看一個Python中字典的例子
d = {1: 123}
d[1.0] = 234
print(d) # {1: 234}
d[True] = 345
print(d) # {1: 345}
天哪,這是咋回事?因為整數在計算哈希的時候,得到結果就是其本身;而浮點數顯然不是,但如果浮點數的小數點后面只有一個0,那么它和整數是等價的。因此兩者的哈希值一樣,而3和3.0在Python中也是相等的,因此它們視為同一個key,所以相當於是替換。同理True也是一樣,我們說它bool繼承自int,所以它等價於1,比如:9 + True = 10,因為True的哈希值和1也是一樣的,而且也是相等的,索引d[True] = 345也是更新。
但是問題來了,值更新了我們可以理解,字典里面只有一個元素也可以理解,但是為什么key一直是1呢?理論上最終結果應該是True才對啊。
其實這算是Python偷了個懶吧
(開個玩笑)
,因為key的哈希值是一樣的,並且也相等,所以Python不會對key進行替換。從字典在設置元素的時候我們也知道,如果對key映射成索引之后發現哈希索引數組的此位置沒有人用,那么就按照先來后到的順序將"鍵值對"存在鍵值對數組中,再將其索引存在哈希索引數組的指定的槽中;如果有人用了,但是對應的key不想等,則重新映射找一個新位置;如果有人用了、並且相等,則說明是同一個key,那么把value換掉即可。所以在替換元素的整個過程中,根本沒有涉及到對鍵的修改,因此上面那個例子的最終結果,value會變、但鍵依舊是1,而不是True。
理想的哈希函數必須保證哈希值盡量均勻地分布於整個哈希空間中,越是相近的值,其哈希值差別應該越大。
所以一個好的哈希函數對實現哈希表起到至關重要的作用。
哈希沖突
一方面,不同的對象,哈希值有可能相同,另一方面,與哈希值空間相比,哈希表的槽位是非常有限的。因此,存在多個鍵被映射到哈希索引的同一槽位的可能性,這便是索引沖突。
解決哈希沖突的常用方法有兩種:
分離鏈接法(separate chaining)
開放尋址法(open addressing)
Python采用的便是開放尋址法。
分離鏈接法
"分離鏈接法"為每個哈希槽維護一個鏈表,所有哈希到同一槽位的鍵保存到對應的鏈表中:
如上圖所示,哈希索引數組的每一個槽都連接着一個鏈表,初始狀態為空,哈希表某個槽位對應的"鍵"則保存在對應的鏈表中。例如:key1和key3都哈希到下標為3的槽位,依次保存在對應的鏈表中;key2被哈希到下標為1的槽位。
開放尋址法
Python依舊是將key映射成索引存在哈希索引數組的槽中,如果發現槽被占了,那么就嘗試另一個。
key3被哈希到槽位為3的時候,發現這個坑被key1給占了,所以只能重新找個坑了。但是為什么找到5呢?顯然在解決哈希沖突的時候是有策略的,一般而言,如果是第i次嘗試,那么會在首槽的基礎上加上一個偏移量\(d_{i}\)。比如哈希之后索引是n,那么首槽就是n,然而n這個槽被占了,於是重新映射,重新映射之后的索引就是n + \(d_{i}\),所以可以看出探測方式因函數\(d_{i}\)而異。
而常見的探測函數有兩種:
線性探測(linear probing)
平方探測(quadratic probing)
線性探測很好理解,\(d_{i}\)是一個線性函數,例如:\(d_{i}\) = 2 * i
哈希之后對應的槽是1,但是被占了,這個時候會在首槽的基礎上加一個偏移量\(d_{i}\)。第1次嘗試,偏移量是2;第2次嘗試,偏移量是4;第3次嘗試,偏移量是6。然后再加上首槽的1,所以嘗試之后的位置分別是3、5、7。
平方探測也很好理解,\(d_{i}\)是一個平方函數,例如:\(d_{i}\) = \(i^{2}\)。同理如果是平方探測,首槽還是1,那么沖突之后重試的槽就是1 + 1、1 + 4、 1+ 9。
線性探測和平方探測很簡單,平方探測似乎更勝一籌。因為如果哈希表存在局部熱點,探測很難快速跳過熱點區域,而平方探測則可以解決這一點。但是這兩種方法其實都不夠好--因為固定的探測序列加大了沖突的概率。
key1和key2都哈希到槽1,而由於探測序列是相同的,因此沖突概率很高。所以Python對此進行了優化,探測函數參考對象哈希值,生成不同的探測序列,進一步降低哈希沖突的可能性:
探測函數
Python為哈希表搜索提供了多種探測函數,lookdict、lookdict_unicode、lookdict_index,一般通用的是lookdict。lookdict_unicode是專門針對key為字符串的entry,lookdict_index針對key為整數的entry,可以把lookdict_unicode、lookdict_index看成lookdict的特殊實現,只不過key是整數和字符串的場景非常常見,因此為其單獨實現了一個函數。
注意: 我們對字典無論是設置值還是獲取值,都需要進行搜索。
我們這里重點看一下lookdict的函數實現,它位於 Objects/dictobject.c 源文件內。關鍵代碼如下:
static Py_ssize_t _Py_HOT_FUNCTION
lookdict(PyDictObject *mp, PyObject *key,
Py_hash_t hash, PyObject **value_addr)
{
size_t i, mask, perturb;
//keys數組的首地址
PyDictKeysObject *dk;
//entries數組的首地址
PyDictKeyEntry *ep0;
top:
dk = mp->ma_keys;
ep0 = DK_ENTRIES(dk);
mask = DK_MASK(dk);
perturb = hash;
//哈希,定位探測鏈沖突的第一個entry的索引
i = (size_t)hash & mask;
for (;;) {
// dk->indecs[i]
Py_ssize_t ix = dk_get_index(dk, i);
//如果ix == DKIX_EMPTY,說明沒有存儲值
//理論上是報錯的,但是在底層是將值的指針設置為NULL
if (ix == DKIX_EMPTY) {
*value_addr = NULL;
return ix;
}
if (ix >= 0) {
//拿到指定的entry的指針
PyDictKeyEntry *ep = &ep0[ix];
assert(ep->me_key != NULL);
//如果兩個key一樣,那么直接將值的地址設置為ep->me_value
/*
但是注意:我們說Python中的變量是一個指針
所以這里的一樣,表示的兩個指針是一樣的, 或者地址是一樣的, 所以在Python中指向的是同一個對象
也就是說這一步等價於Python中的: if a is b
*/
if (ep->me_key == key) {
*value_addr = ep->me_value;
return ix;
}
//如果兩個對象不一樣,那么就比較它們的哈希值是否相同
//比如33和33是一個對象,都是小整數對象池里面整數,但是3333和3333卻不是,但是它們的值是一樣的
//因此先判斷id是否一致,如果不一致再比較哈希值是否一樣
if (ep->me_hash == hash) {
//哈希值一樣的話, 那么獲取me_key
PyObject *startkey = ep->me_key;
Py_INCREF(startkey); //inc ref
//比較key是否一致
int cmp = PyObject_RichCompareBool(startkey, key, Py_EQ);
Py_DECREF(startkey); //dec ref
if (cmp < 0) {
*value_addr = NULL;
return DKIX_ERROR;
}
if (dk == mp->ma_keys && ep->me_key == startkey) {
if (cmp > 0) {
*value_addr = ep->me_value;
return ix;
}
}
else {
/* The dict was mutated, restart */
goto top;
}
}
}
//如果條件均不滿足,調整姿勢,進行下一次探索
//由於參考了對象哈希值,探測序列因哈希值而異
perturb >>= PERTURB_SHIFT;
i = (i*5 + perturb + 1) & mask;
}
Py_UNREACHABLE();
}
哈希攻擊
Python 在 3.3 以前, 哈希算法只根據對象本身計算哈希值。因此,只要 Python 解釋器相同,對象哈希值也肯定相同。
如果一些別有用心的人構造出大量哈希值相同的 key ,並提交給服務器,會發生什么事情呢?例如,向一台 Python 2 Web 服務器 post 一個 json 數據,數據包含大量的 key ,所有 key 的哈希值相同。這意味着哈希表將頻繁發生哈希沖突,性能由 O(1)急劇下降為 O(N),這便是哈希攻擊。
問題雖然很嚴重,但是好在應對方法比較簡單--直接往對象身上撒把鹽(salt)即可。具體做法如下:
1. Python解釋器進程啟動后,產生一個隨機數作為鹽
2. 哈希函數同時參考對象本身以及隨機數計算哈希值
這樣一來,攻擊者無法獲悉解釋器內部的隨機數,也就無法構造出哈希值相同的對象了!Python 自 3.3 以后,哈希函數均采用加鹽模式,杜絕了哈希攻擊的可能性。Python 哈希算法在 Python/pyhash.c 源文件中實現,有興趣的可以自己去了解一下。
以我當前使用的Python3.8為例,在執行hash("夏色祭")
的時候,每次執行得到的結果都是不一樣的。
元素刪除
通過前面的學習,我們現在已經知道哈希表就是通過相應的函數將需要搜索的鍵映射為一個索引,最終通過索引去訪問連續的內存區域。而對於哈希表這種數據結構,最終目的就是加速鍵的搜索過程。用於映射的函數就是哈希函數,映射之后的值就是哈希值,再由哈希值得到索引(不過為了方便,我們有時會將哈希函數映射的結果直接稱為索引)
。因此在哈希表的實現中,哈希函數的優劣將直接決定哈希表搜索效率的高低。
並且我們知道,當鍵值對數量越多,在映射成索引之后就越容易出現沖突。而我們之前說如果沖突了,就改變規則重新映射。事實上,Python也確實是這么做的,這種方法叫做開放尋址法。當發生沖突時,Python會通過一個二次探測函數f,計算下一個候選位置addr,如果可用就插入進去。如果不可用,會繼續使用探測函數,直到找到一個可用的位置。通過多次使用探測函數f,從一個位置可以到達多個位置,我們認為這些位置就形成了一個"沖突探測鏈(探測序列)"。比如當我們插入一個key="satori"的鍵值對,在a位置發現不行,又走b位置,發現也被人占了,於是到達c位置,發現沒有key,於是就占了c這個位置。那么a -> b -> c便形成了一條沖突探測鏈,同理我們查找的時候也會按照這個順序進行查找。
顯然上面這些東西,現在理解起來已經沒什么難度了,但是問題來了。
如果我此時把上面b位置的entry給刪掉的話,會引發什么后果?首先我們知道,b位置上的key和我們指定的"satori"這個key的哈希值是一樣的,不然它們也不會映射到同一個槽。當我們直接獲取d["satori"],肯定會先走a位置,發現有人但key又不是"satori",於是重新映射;然后走到b,發現還不對,再走到c位置,發現key是"satori",於是就把值取出來了。顯然這符合我們的預期,但是,我要說但是了。
如果我們把b位置上的entry刪掉呢?那么老規矩,映射成索引,先走到a位置發現坑被占;於是又走到b位置,結果發現居然沒有內容,那么直接就報出了一個KeyError。所以繼續尋找的前提是,這個地方要存儲了entry,並且存在的
entry -> me_key
和指定的key不相同,但如果沒有的話,就說明根本沒有這個key,直接KeyError。然而"satori"這個key確實是存在的,因此發生這種情況我們就說探測鏈斷裂
。本來應該走到c的,但是由於b沒有元素,因此探測函數在b處就停止了。
因此我們發現,當一個元素只要位於任何一條探測鏈當中,在刪除元素時都不能真正意義上的刪除,而是一種"偽刪除"操作。
//一個鍵值對就是一個entry, 在底層就是一個 PyDictKeyEntry 對象
typedef struct {
Py_hash_t me_hash;
PyObject *me_key;
PyObject *me_value;
} PyDictKeyEntry;
在Python中,當一個PyDictObject對象發生變化時,其中的entry會在三種不同的狀態之間進行切換:unused態、active態、dummy態。
當一個entry的me_key和me_value都是NULL的時候,entry處於unused態。unused態表明該entry中並沒有存儲key、value,並且在此之前也沒有存儲過它們,每一個entry在初始化的時候都會處於這個狀態。不過me_value的話,即使不是unused態也可能為NULL,更准確的說不管何時它都可能會NULL,這取決於到底是combined table、還是split table。我們說如果是分離表的話,value是不存在這里的,只有key存在這里,因此me_value永遠是NULL。而如果是結合表,那么key和value都存在這里面。所以對於me_key,只可能在unused的時候才可能會NULL。
當entry存儲了key時,那么此時entry便從unused態變成了active態。
當entry中的key(value)被刪除后,狀態便從active態變成dummy態。注意:這里是dummy,刪除了並不代表就能夠回到unused態,來存儲其他key了。我們也說了,unused態是指當前沒有、並且之前也沒有存儲過。key被刪除后,會變成dummy,否則就會發生我們之前說的探測鏈斷裂。至於這個dummy到底是啥,我們后面說。總是entry進入dummy態,就是我們剛才提到的偽刪除技術。當Python沿着某條探測鏈搜索時,如果發現一個entry處於dummy態,就會明白雖然當前的entry是無效的,但是后面的entry可能是有效的,所以不會直接就停止搜索、報錯,而是會繼續搜索,這樣就保證了探測鏈的連續性。至於報錯,是在找到了unused狀態的entry時才會報錯,因為這里確實一直都沒有存儲過key,但是索引確實是這個位置,這說明當前指定的key就真的不存在哈希表中,此時才會報錯。
unused態只能轉換為active態;active態只能轉換為dummy態;dummy態只能轉化為active態。
哈希槽位狀態常量在 Objects/dict-common.h 頭文件中定義:
#define DKIX_EMPTY (-1)
#define DKIX_DUMMY (-2) /* Used internally */
#define DKIX_ERROR (-3)
但是問題來了,如果一個entry被刪除了,那么它就變成了dummy態。而我們說dummy態是可以轉為active態的,要如何轉化呢?如果新來了一個entry,這個entry在存儲的時候發生沖突,那么會沿着沖突探測鏈查找,在查找的時候要是遇到了處於dummy態entry,那么原來處於dummy態的entry就會變成active態。
換句話說,對於處理dummy態的entry,Python壓根不會主動理會,只是說這個元素被標記為刪除了,但是內存還會繼續占用。如果新來的entry,沒有發生沖突,一上來就有位置可以存儲,那么是不會理會dummy態entry的。只有當發生沖突的時候,正好撞上了dummy態的entry,才會將dummy態的entry給替換掉。此時entry就變成了active態,然后內部維護的就是新的鍵值對。
如果哈希表滿了,那么就申請新的存儲單元,然后將所有的active態的entry都搬過去,而處於dummy態的entry則直接丟棄。之所以可以丟棄,是因為dummy狀態的entry存在是為了保證探測鏈不斷裂,但是現在所有的active都拷貝到新的內存當中了,它們會形成一條新的探測鏈,因此也就不需要這些dummy態的entry了。至於到底是擴容、縮容、還是容量不變,取決於當前哈希表的entry個數。但是無論怎么樣,當新的哈希表創建之后,便又有新的存儲單元可用了。
PyDictObject的創建與維護
PyDictObject的創建
Python內部通過PyDict_New來創建一個新的dict對象。
PyObject *
PyDict_New(void)
{
//new_keys_object表示創建PyDictKeysObject*對象
//里面傳一個數值,表示entry的容量
//#define PyDict_MINSIZE 8,從宏定義我們能看出來為8
//表示默認初始化能容納8個entry的PyDictKeysObject
//為什么是8,這是通過大量的經驗得來的。
PyDictKeysObject *keys = new_keys_object(PyDict_MINSIZE);
if (keys == NULL)
return NULL;
//這一步則是根據PyDictKeysObject *創建一個新字典
return new_dict(keys, NULL);
}
static PyDictKeysObject *new_keys_object(Py_ssize_t size)
{
PyDictKeysObject *dk;
Py_ssize_t es, usable;
//檢測,size是否>=PyDict_MINSIZE
assert(size >= PyDict_MINSIZE);
assert(IS_POWER_OF_2(size));
usable = USABLE_FRACTION(size);
//es:哈希表中的每個索引占多少字節
if (size <= 0xff) {
es = 1;
}
else if (size <= 0xffff) {
es = 2;
}
#if SIZEOF_VOID_P > 4
else if (size <= 0xffffffff) {
es = 4;
}
#endif
else {
es = sizeof(Py_ssize_t);
}
//注意到,字典里面也有緩沖池,當然這里指定是字典的key
//如果有的話,直接從里面取
if (size == PyDict_MINSIZE && numfreekeys > 0) {
dk = keys_free_list[--numfreekeys];
}
else {
//否則malloc重新申請
dk = PyObject_MALLOC(sizeof(PyDictKeysObject)
+ es * size
+ sizeof(PyDictKeyEntry) * usable);
if (dk == NULL) {
PyErr_NoMemory();
return NULL;
}
}
//設置引用計數、可用的entry個數等信息
DK_DEBUG_INCREF dk->dk_refcnt = 1;
dk->dk_size = size;
dk->dk_usable = usable;
//dk_lookup很關鍵,里面包括了哈希函數和沖突時的二次探測函數的實現
dk->dk_lookup = lookdict_unicode_nodummy;
dk->dk_nentries = 0;
//哈希表的初始化
memset(&dk->dk_indices[0], 0xff, es * size);
memset(DK_ENTRIES(dk), 0, sizeof(PyDictKeyEntry) * usable);
return dk;
}
static PyObject *
new_dict(PyDictKeysObject *keys, PyObject **values)
{
PyDictObject *mp;
assert(keys != NULL);
//這是一個字典的緩沖池
if (numfree) {
mp = free_list[--numfree];
assert (mp != NULL);
assert (Py_TYPE(mp) == &PyDict_Type);
_Py_NewReference((PyObject *)mp);
}
//系統堆中申請內存
else {
mp = PyObject_GC_New(PyDictObject, &PyDict_Type);
if (mp == NULL) {
DK_DECREF(keys);
free_values(values);
return NULL;
}
}
//設置key、value等等
mp->ma_keys = keys;
mp->ma_values = values;
mp->ma_used = 0;
mp->ma_version_tag = DICT_NEXT_VERSION();
assert(_PyDict_CheckConsistency(mp));
return (PyObject *)mp;
}
插入元素
我們對PyDictObject對象的操作都是建立在搜索的基礎之上的,插入和刪除也不例外。
static int
insertdict(PyDictObject *mp, PyObject *key, Py_hash_t hash, PyObject *value)
{
PyObject *old_value;
PyDictKeyEntry *ep;
//增加對key和value的引用計數
Py_INCREF(key);
Py_INCREF(value);
//類型檢查
if (mp->ma_values != NULL && !PyUnicode_CheckExact(key)) {
if (insertion_resize(mp) < 0)
goto Fail;
}
Py_ssize_t ix = mp->ma_keys->dk_lookup(mp, key, hash, &old_value);
if (ix == DKIX_ERROR)
goto Fail;
assert(PyUnicode_CheckExact(key) || mp->ma_keys->dk_lookup == lookdict);
MAINTAIN_TRACKING(mp, key, value);
/* 檢查共享key,可能擴容哈希表
*/
if (_PyDict_HasSplitTable(mp) &&
((ix >= 0 && old_value == NULL && mp->ma_used != ix) ||
(ix == DKIX_EMPTY && mp->ma_used != mp->ma_keys->dk_nentries))) {
if (insertion_resize(mp) < 0)
goto Fail;
ix = DKIX_EMPTY;
}
//搜索成功
if (ix == DKIX_EMPTY) {
/* 插入一個新的slot,這個slot可以直接看成是entry */
assert(old_value == NULL);
if (mp->ma_keys->dk_usable <= 0) {
/* 需要resize */
if (insertion_resize(mp) < 0)
goto Fail;
}
//尋找值的插入位置,就是我們之前說的將key這個值通過哈希函數映射為索引
Py_ssize_t hashpos = find_empty_slot(mp->ma_keys, hash);
//拿到PyDictKeyEntry *指針
ep = &DK_ENTRIES(mp->ma_keys)[mp->ma_keys->dk_nentries];
//設置
dk_set_index(mp->ma_keys, hashpos, mp->ma_keys->dk_nentries);
ep->me_key = key; //設置key
ep->me_hash = hash;//設置哈希
//如果ma_values數組不為空
if (mp->ma_values) {
assert (mp->ma_values[mp->ma_keys->dk_nentries] == NULL);
//設置進去,還記得這是什么表嗎?對,這是一張split table
mp->ma_values[mp->ma_keys->dk_nentries] = value;
}
else {
//ma_values數據為空的話,那么value就設置在PyDictKeyEntry對象的me_value里面
ep->me_value = value;
}
mp->ma_used++;//使用個數+1
mp->ma_version_tag = DICT_NEXT_VERSION();//版本數+1
mp->ma_keys->dk_usable--;//可用數-1
mp->ma_keys->dk_nentries++;//里面entry數量+1
assert(mp->ma_keys->dk_usable >= 0);
assert(_PyDict_CheckConsistency(mp));
return 0;
}
//判斷key是否存在,存在即替換
if (_PyDict_HasSplitTable(mp)) {
mp->ma_values[ix] = value;
if (old_value == NULL) {
/* pending state */
assert(ix == mp->ma_used);
mp->ma_used++;
}
}
else {
assert(old_value != NULL);
DK_ENTRIES(mp->ma_keys)[ix].me_value = value;
}
mp->ma_version_tag = DICT_NEXT_VERSION();
Py_XDECREF(old_value); /* which **CAN** re-enter (see issue #22653) */
assert(_PyDict_CheckConsistency(mp));
Py_DECREF(key);
return 0;
Fail:
Py_DECREF(value);
Py_DECREF(key);
return -1;
}
以上是插入元素,但我們看到無論是插入元素、還是設置元素,insertdict都是可以勝任。但是請注意一下參數,有一個hash參數,這個hash是從什么地方獲取的呢?答案是,在調用這個insertdict之前其實會首先調用PyDict_SetItem
int
PyDict_SetItem(PyObject *op, PyObject *key, PyObject *value)
{
PyDictObject *mp;
Py_hash_t hash;
if (!PyDict_Check(op)) {
PyErr_BadInternalCall();
return -1;
}
assert(key);
assert(value);
mp = (PyDictObject *)op;
//計算hash值
if (!PyUnicode_CheckExact(key) ||
(hash = ((PyASCIIObject *) key)->hash) == -1)
{
//
hash = PyObject_Hash(key);
if (hash == -1)
return -1;
}
/* 調用insertdict,必要時調整元素 */
return insertdict(mp, key, hash, value);
}
我們說如果entry個數達到容量的三分之二,那么會調整容量,如何調整呢?
//增長率
#define GROWTH_RATE(d) ((d)->ma_used*3)
static int
insertion_resize(PyDictObject *mp)
{
//本質上調用了dictresize,傳入PyDictObject * 和增長率
return dictresize(mp, GROWTH_RATE(mp));
}
static int
dictresize(PyDictObject *mp, Py_ssize_t minsize)
{
//新的容量,entry的個數
Py_ssize_t newsize, numentries;
//老的keys
PyDictKeysObject *oldkeys;
//老的values
PyObject **oldvalues;
//老的entries,新的entries
PyDictKeyEntry *oldentries, *newentries;
/* 確定table的大小*/
for (newsize = PyDict_MINSIZE;
newsize < minsize && newsize > 0;
newsize <<= 1)
;
if (newsize <= 0) {
PyErr_NoMemory();
return -1;
}
//獲取原來的所有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;
//獲取已用entries
numentries = mp->ma_used;
//獲取舊信息
oldentries = DK_ENTRIES(oldkeys);
newentries = DK_ENTRIES(mp->ma_keys);
oldvalues = mp->ma_values;
//如果oldvalues不為NULL,這應該是一個 split table
//split table的特點是key是能是unicode、
//那么需要把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);
}
}
else { // 否則的話說明這本身就是一個combined table
if (oldkeys->dk_nentries == numentries) {
//將就得entries拷貝到新的entries里面去
memcpy(newentries, oldentries, numentries * sizeof(PyDictKeyEntry));
}
else {
//處理舊的entries
//active態的entry搬到新table中
//dummy態的entry,調整key的引用計數,丟棄該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;
}
我們再來看一下改變dict內存空間的一些動作:
首先要確定table的大小,很顯然這個大小一定要大於minsize,這個minsize通過我們已經看到了,是通過宏定義的,是已用entry的3倍
根據新的table,重新申請內存
將原來的處於active狀態的entry拷貝到新的內存當中,而對於處於dummy狀態的entry則直接丟棄。可以丟棄的原因我們上面也說過了。主要是因為哈希表擴容了,會申請的一個新的數組,直接將原來的active態的entry組成一條新的探測鏈即可,因此也就不需要這些dummy態的entry了
刪除元素
插入元素(設置元素)如果明白了,刪除元素我覺得都可以不需要說了。
int
PyDict_DelItem(PyObject *op, PyObject *key)
{
//這顯然和dictresize一樣,是先獲取hash值
Py_hash_t hash;
assert(key);
if (!PyUnicode_CheckExact(key) ||
(hash = ((PyASCIIObject *) key)->hash) == -1) {
hash = PyObject_Hash(key);
if (hash == -1)
return -1;
}
//真正來刪除是下面這個函數
return _PyDict_DelItem_KnownHash(op, key, hash);
}
int
_PyDict_DelItem_KnownHash(PyObject *op, PyObject *key, Py_hash_t hash)
{
Py_ssize_t ix;
PyDictObject *mp;
PyObject *old_value;
//類型檢測
if (!PyDict_Check(op)) {
PyErr_BadInternalCall();
return -1;
}
assert(key);
assert(hash != -1);
mp = (PyDictObject *)op;
//獲取對應entry的index
ix = (mp->ma_keys->dk_lookup)(mp, key, hash, &old_value);
if (ix == DKIX_ERROR)
return -1;
if (ix == DKIX_EMPTY || old_value == NULL) {
_PyErr_SetKeyError(key);
return -1;
}
// split table不支持刪除操作,如果是split table,需要轉換成combined table
if (_PyDict_HasSplitTable(mp)) {
if (dictresize(mp, DK_SIZE(mp->ma_keys))) {
return -1;
}
ix = (mp->ma_keys->dk_lookup)(mp, key, hash, &old_value);
assert(ix >= 0);
}
//傳入hash和ix,又調用了delitem_common
return delitem_common(mp, hash, ix, old_value);
}
static int
delitem_common(PyDictObject *mp, Py_hash_t hash, Py_ssize_t ix,
PyObject *old_value)
{
PyObject *old_key;
PyDictKeyEntry *ep;
//找到對應的hash索引
Py_ssize_t hashpos = lookdict_index(mp->ma_keys, hash, ix);
assert(hashpos >= 0);
//已經entries個數-1
mp->ma_used--;
//版本-1
mp->ma_version_tag = DICT_NEXT_VERSION();
//拿到entry的指針
ep = &DK_ENTRIES(mp->ma_keys)[ix];
//將其設置為dummy狀態
dk_set_index(mp->ma_keys, hashpos, DKIX_DUMMY);
ENSURE_ALLOWS_DELETIONS(mp);
old_key = ep->me_key;
//將其key、value都設置為NULL
ep->me_key = NULL;
ep->me_value = NULL;
//減少引用計數
Py_DECREF(old_key);
Py_DECREF(old_value);
assert(_PyDict_CheckConsistency(mp));
return 0;
}
流程非常清晰,也很簡單。先使用PyDict_DelItem計算hash值,再使用_PyDict_DelItem_KnownHash計算出索引,最后使用delitem_common獲取相應的entry,刪除維護的元素,並將entry從active態設置為dummy態,同時還會調整ma_used(已用entry)的數量
PyDictObject緩存池
從介紹PyLongObject的小整數對象池的時候,我們就說過,不同的對象都有自己的緩存池,比如列表,當然字典也不例外。
#ifndef PyDict_MAXFREELIST
#define PyDict_MAXFREELIST 80
#endif
static PyDictObject *free_list[PyDict_MAXFREELIST];
static int numfree = 0;
PyDictObject的緩存池機制其實和PyListObject的緩存池是類似的,開始時,這個緩存池什么也沒有,直到第一個PyDictObject對象被銷毀時,這個PyDictObject緩沖池里面才開始接納被緩沖的PyDictObject對象。
static void
dict_dealloc(PyDictObject *mp)
{
//獲取ma_values指針
PyObject **values = mp->ma_values;
//獲取所有的ma_keys指針
PyDictKeysObject *keys = mp->ma_keys;
//兩個整型
Py_ssize_t i, n;
//追蹤、調試
PyObject_GC_UnTrack(mp);
Py_TRASHCAN_SAFE_BEGIN(mp)
//調整引用計數
if (values != NULL) {
if (values != empty_values) {
for (i = 0, n = mp->ma_keys->dk_nentries; i < n; i++) {
Py_XDECREF(values[i]);
}
free_values(values);
}
DK_DECREF(keys);
}
else if (keys != NULL) {
assert(keys->dk_refcnt == 1);
DK_DECREF(keys);
}
//將被銷毀的對象放到緩沖池當中
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)
}
和PyListObject對象的緩沖池機制一樣,緩沖池中只保留了PyDictObject對象。如果維護的是從系統堆中申請的內存空間,那么Python將釋放這份內存空間,歸還給系統堆。如果不是,那么僅僅只需要調整維護的對象的引用計數即可。
其實在創建一個PyDictObject對象時,如果緩沖池中有可用的對象,也會直接從緩沖池中取,而不需要再重新創建。
static PyObject *
new_dict(PyDictKeysObject *keys, PyObject **values)
{
PyDictObject *mp;
assert(keys != NULL);
if (numfree) {
mp = free_list[--numfree];
assert (mp != NULL);
assert (Py_TYPE(mp) == &PyDict_Type);
_Py_NewReference((PyObject *)mp);
}
...
...
...
關於字典的剖析我們就說到這里,其實內容還是很多的,尤其是哈希表背后的一些原理,值得好好體會一下。
PySetObject
由於集合和字典在底層使用的都是哈希表,所以我們放在一起說吧。
既然集合也使用了哈希表,那么它的查詢性能也是很高的。由於哈希表我們已經說了很多了,所以我們下面就直接來看集合的底層結構吧。
//python中的集合的每一個元素,是通過setentry這個結構體來存儲的
typedef struct {
PyObject *key; // 元素的指針
Py_hash_t hash; // 元素的哈希值
} setentry;
typedef struct {
PyObject_HEAD
//我們發現在set中,每一個元素依然叫做entry
Py_ssize_t fill; /* active態以及dummy態的entry總數量*/
Py_ssize_t used; /* active態的entry數量 */
/* 該table包含mask+1個slot,mask+1是2的冪次方
我們存儲的是mask,而不是size,因為更常需要mask
這個mask是用來和哈希值進行運算的
*/
Py_ssize_t mask;
/* 對於小表,該table指向固定大小的small table,對於bigger table則指向額外的malloc內存
該table的指針永遠不會為NULL。
所以它是指向setentry數組的一個指針
*/
setentry *table;
Py_hash_t hash; /* 該PySetObject的哈希值,只適用於frozenset */
Py_ssize_t finger;
/*
用於pop元素的,search finger就是我們從包含某個元素的節點開始,找到我們希望的元素
*/
//smalltable就是顯然就是一個保存了setentry類型的數組
//PySet_MINSIZE是一個宏定義,默認是8。如果元素比較少的話,存在smalltable里面
//當smalltable存不下的時候(仮),就會使用malloc申請。存不下,指的是超過8個的時候嗎?
//由於哈希表的特性,需要預留一定的空間,因此還沒存到8個的時候,就會擴容了
setentry smalltable[PySet_MINSIZE];
PyObject *weakreflist; /* 弱引用列表 */
} PySetObject;
PySetObject對象的創建
創建一個PySetObject對象可以使用PySet_New方法:
PyObject *
PySet_New(PyObject *iterable)
{
//底層調用了make_new_set
return make_new_set(&PySet_Type, iterable);
}
static PyObject *
make_new_set(PyTypeObject *type, PyObject *iterable)
{
//申明一個PySetObject *指針
PySetObject *so;
//申請該元素所需要的內存
so = (PySetObject *)type->tp_alloc(type, 0);
//申請失敗,返回NULL
if (so == NULL)
return NULL;
//初始化都為0
so->fill = 0;
so->used = 0;
//PySet_MINSIZE默認為8,mask初始化為7
so->mask = PySet_MINSIZE - 1;
//將table指向保存數據的smalltable的頭指針
so->table = so->smalltable;
//初始化hash值為-1
so->hash = -1;
//finger為0
so->finger = 0;
//弱引用列表為NULL
so->weakreflist = NULL;
//如果迭代器不為NULL,那么把元素依次更新的so這個PySetObject中
if (iterable != NULL) {
if (set_update_internal(so, iterable)) {
Py_DECREF(so);
return NULL;
}
}
//返回初始化完成的set
return (PyObject *)so;
}
從以上步驟可以看出,初始化一個PySetObject對象主要初始化其內部的數據結構。
插入元素
插入元素,會調用PySet_Add:
int
PySet_Add(PyObject *anyset, PyObject *key)
{ //參數是兩個指針
//類型檢測
if (!PySet_Check(anyset) &&
(!PyFrozenSet_Check(anyset) || Py_REFCNT(anyset) != 1)) {
PyErr_BadInternalCall();
return -1;
}
//本質上調用了set_add_key
return set_add_key((PySetObject *)anyset, key);
}
static int
set_add_key(PySetObject *so, PyObject *key)
{
//聲明一個變量,顯然是存儲哈希值的
Py_hash_t hash;
//類型檢測
if (!PyUnicode_CheckExact(key) ||
(hash = ((PyASCIIObject *) key)->hash) == -1) {
//計算哈希值
hash = PyObject_Hash(key);
//如果傳入的元素不能被hash,那么直接返回-1
//在python層面顯然會報錯
if (hash == -1)
return -1;
}
//底層又調用了set_add_entry,並把hash也作為參數傳了進去
return set_add_entry(so, key, hash);
}
static int
set_add_entry(PySetObject *so, PyObject *key, Py_hash_t hash)
{
setentry *table; //指向setentry數組的指針,當然數組里面也是指針
setentry *freeslot;//存放不可hash的entry
setentry *entry;//entry指針
size_t perturb;
size_t mask;//和hash運算
size_t i; //一個整型變量,后面的索引值
size_t j;//遍歷用的
int cmp;//比較的結果
/* Pre-increment is necessary to prevent arbitrary code in the rich
comparison from deallocating the key just before the insertion. */
Py_INCREF(key); //增加key的引用計數
restart:
mask = so->mask; //獲取mask
i = (size_t)hash & mask;//mask和hash進行與運算,得到一個索引
entry = &so->table[i];//獲取對應的entry指針
if (entry->key == NULL)
//如果entry->key == NULL,表示當前位置沒有被使用
//直接跳到found_unused標簽
goto found_unused;
//否則說明有人用了
freeslot = NULL;
perturb = hash; // 將perturb設置為hash
while (1) {
/*
找到entry->hash,之前說了,entry結構體由兩部分組成
一個*key,也就是指向真正元素的指針,另一個是hash值
*/
//如果和我們當前的hash值一樣的話
if (entry->hash == hash) {
//拿到當前的key
PyObject *startkey = entry->key;
/* startkey cannot be a dummy because the dummy hash field is -1 */
//entry里面的key不可以為dummy態,因為這相當於刪除(偽刪除)了,hash為-1
assert(startkey != dummy);
//如果已經存在的key和我們添加的key是一樣,說明重復了
//而集合內的元素不允許重復
if (startkey == key)
//直接跳轉到found_active標簽
goto found_active;
//如果是unicode,那么先轉化,然后再比較兩個key是否一樣
if (PyUnicode_CheckExact(startkey)
&& PyUnicode_CheckExact(key)
&& _PyUnicode_EQ(startkey, key))
//如果一樣,跳轉到found_active標簽
goto found_active;
//那么獲取頭部指針
table = so->table;
//增加startkey的引用計數
Py_INCREF(startkey);
//不一樣的話,通過富比較,去比較兩個對象維護的值是否一致
cmp = PyObject_RichCompareBool(startkey, key, Py_EQ);
//介紹startkey的引用計數
Py_DECREF(startkey);
//如果cmp大於0,比較成功
if (cmp > 0)
//說明索引被人占了
goto found_active;
if (cmp < 0)
//小於0說明比較失敗
goto comparison_error;
/* 如果table或者entry改變了,我們必須從頭開始 */
if (table != so->table || entry->key != startkey)
//跳轉到restart標簽
goto restart;
//拿到當前的mask
mask = so->mask; /* help avoid a register spill */
}
//如果不能hash
else if (entry->hash == -1)
//則設置為freeslot
freeslot = entry;
//如果當前索引值加上9小於當前的mask
//#define LINEAR_PROBES 9
if (i + LINEAR_PROBES <= mask) {
//循環9次
for (j = 0 ; j < LINEAR_PROBES ; j++) {
//每次得到下一個entry
entry++;
//如果hash=0,並且對應的key為NULL
if (entry->hash == 0 && entry->key == NULL)
//跳轉到found_unused_or_dummy標簽
goto found_unused_or_dummy;
if (entry->hash == hash) {
//如果hash值相同,獲取對應的key
PyObject *startkey = entry->key;
//key必須不為dummy態
assert(startkey != dummy);
//如果兩個key相同,跳轉到found_active標簽
if (startkey == key)
goto found_active;
//如果為unicode,還是轉化后比較
if (PyUnicode_CheckExact(startkey)
&& PyUnicode_CheckExact(key)
&& _PyUnicode_EQ(startkey, key))
goto found_active;
//下面的跟if (i + LINEAR_PROBES <= mask) {上面的是一樣的
table = so->table;
Py_INCREF(startkey);
cmp = PyObject_RichCompareBool(startkey, key, Py_EQ);
Py_DECREF(startkey);
if (cmp > 0)
goto found_active;
if (cmp < 0)
goto comparison_error;
if (table != so->table || entry->key != startkey)
goto restart;
mask = so->mask;
}
else if (entry->hash == -1)
freeslot = entry;
}
}
// 如果沒有找到,說明哈希值沖突,改變規則,重新計算索引值
perturb >>= PERTURB_SHIFT;
//按照(i * 5 + 1 + perturb) & mask重新計算
i = (i * 5 + 1 + perturb) & mask;
//獲取新索引對應的entry
entry = &so->table[i];
//如果對應的key為NULL,說明重新計算索引之后找到了可以存儲的地方
if (entry->key == NULL)
//跳轉到found_unused_or_dummy
goto found_unused_or_dummy;
//否則說明比較倒霉,改變規則重新映射索引依舊沖突
//那么繼續循環,比較key是否一致等等
}
//未使用或者dummy,dummy我們是不可以使用的
found_unused_or_dummy:
//如果這個freeslot為NULL,說明是可用的
if (freeslot == NULL)
//跳轉
goto found_unused;
//否則,說明為dummy態,那么我們依舊可以使用,正好廢物利用
//將used數量加一
so->used++;
//設置key和hash值
freeslot->key = key;
freeslot->hash = hash;
return 0;
//發現未使用的
found_unused:
//將fill和used個數+1
so->fill++;
so->used++;
//設置key和hash值
entry->key = key;
entry->hash = hash;
//檢查active態+dummy的entry個數是否小於mask的3/5
if ((size_t)so->fill*5 < mask*3)
//是的話,表示無需擴容
return 0;
//否則要進行擴容
//擴容的規則就是如果active態的entry各式各樣如果大於50000,那么兩倍擴容,否則四倍擴容
return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);
//如果是found_active,表示key重復了
//直接減少一個引用計數即可
found_active:
Py_DECREF(key);
return 0;
//比較失敗,同樣減少引用計數,返回-1
comparison_error:
Py_DECREF(key);
return -1;
}
總結一下流程就是:
傳入hash值,計算出索引值,通過索引值找到對應的entry
如果entry->key=NULL,那么將hash和key存到對應的entry
如果有key,但是值相同,則不插入,直接減少引入計數。因為不是字典,不存在更新一說
如果有key,但是值不相同。那么從該索引往后的9個entry(i + 9 <= mask),如果存在key為NULL的entry,那么設置進去。
如果以上條件都不滿足,那么改變策略重新計算索引值,直到找到一個滿足key為NULL的entry
判斷容量問題,如果active態+dummy態的entry個數不小於3/5 * mask,那么擴容,擴容的規則是active態的entry個數是否大於50000,是的話就二倍擴容,否則4倍擴容。
PySetObject擴容
我們之前說PySetObject會改變容量,那么它是如何改變的呢?
static int
set_table_resize(PySetObject *so, Py_ssize_t minused)
{ //顯然參數是:PySetObject *指針以及容量大小
//三個setentry *指針
setentry *oldtable, *newtable, *entry;
//oldmask
Py_ssize_t oldmask = so->mask;
//newmask
size_t newmask;
//是否為其申請過內存
int is_oldtable_malloced;
//將PySet_MINSIZE個entry直接copy過來
//因為你既然要擴容的話,那么肯定是這里面存不下了
setentry small_copy[PySet_MINSIZE];
//minused必須大於等於0
assert(minused >= 0);
/* Find the smallest table size > minused. */
/* XXX speed-up with intrinsics */
//newsize擴大二倍,直到大於minused
//所以我們剛才說的大於50000,二倍擴容,否則四倍擴容
//實際上是最終的newsize是比二倍或者四倍擴容的結果要大的
size_t newsize = PySet_MINSIZE;
while (newsize <= (size_t)minused) {
//newsize最大頂多也就是PY_SSIZE_T_MAX + 1,但是基本不可能存儲這么多元素
newsize <<= 1; // The largest possible value is PY_SSIZE_T_MAX + 1.
}
/* Get space for a new table. */
//為新的table申請空間
oldtable = so->table;
assert(oldtable != NULL);
is_oldtable_malloced = oldtable != so->smalltable;
//如果newsize和PySet_MINSIZE(這里的8)相等
if (newsize == PySet_MINSIZE) {
/* A large table is shrinking, or we can't get any smaller. */
//拿到smalltable,就是默認初始化8個entry數組的那哥們
newtable = so->smalltable;
//如果oldtable和newtable一樣
if (newtable == oldtable) {
//並且沒有dummy態的entry
if (so->fill == so->used) {
/* No dummies, so no point doing anything. */
//那么無需做任何事情
return 0;
}
/* We're not going to resize it, but rebuild the
table anyway to purge old dummy entries.
Subtle: This is *necessary* if fill==size,
as set_lookkey needs at least one virgin slot to
terminate failing searches. If fill < size, it's
merely desirable, as dummies slow searches. */
//否則的話,dummy的個數一定大於0
assert(so->fill > so->used);
//扔掉dummy態,只把oldtable中active態的拷貝過來
memcpy(small_copy, oldtable, sizeof(small_copy));
//將small_copy重新設置為oldtable
oldtable = small_copy;
}
}
else {
//否則的話,肯定大於8,申請newsize個setentry所需要的空間
newtable = PyMem_NEW(setentry, newsize);
//如果newtable為NULL,那么申請內存失敗,返回-1
if (newtable == NULL) {
PyErr_NoMemory();
return -1;
}
}
/* Make the set empty, using the new table. */
//newtable肯定不等於oldtable
assert(newtable != oldtable);
//創建一個能融安newsize個entry的空set
memset(newtable, 0, sizeof(setentry) * newsize);
//將mask設置為newsize-1
//將table設置為newtable
so->mask = newsize - 1;
so->table = newtable;
/* Copy the data over; this is refcount-neutral for active entries;
dummy entries aren't copied over, of course */
//獲取newmask
newmask = (size_t)so->mask;
//將原來舊table的setentry數組里面所有setentry的key和hash值全部設置到新的table里面
if (so->fill == so->used) {
for (entry = oldtable; entry <= oldtable + oldmask; entry++) {
if (entry->key != NULL) {
set_insert_clean(newtable, newmask, entry->key, entry->hash);
}
}
} else {
so->fill = so->used;
for (entry = oldtable; entry <= oldtable + oldmask; entry++) {
if (entry->key != NULL && entry->key != dummy) {
set_insert_clean(newtable, newmask, entry->key, entry->hash);
}
}
}
//如果已經為其申請了內存,那么要將其歸還給系統堆
if (is_oldtable_malloced)
PyMem_DEL(oldtable);
return 0;
}
//設置元素是通過set_insert_clean設置的
static void
set_insert_clean(setentry *table, size_t mask, PyObject *key, Py_hash_t hash)
{
setentry *entry;
size_t perturb = hash;
size_t i = (size_t)hash & mask; //計算索引
size_t j;
while (1) {
entry = &table[i]; //獲取當前entry
if (entry->key == NULL)
goto found_null; //如果為空則跳轉found_null設置key與hash
if (i + LINEAR_PROBES <= mask) {
//如果沒有還是老規矩,遍歷之后的9個entry
for (j = 0; j < LINEAR_PROBES; j++) {
entry++;
//找到空的entry,那么跳轉到found_null設置key與hash
if (entry->key == NULL)
goto found_null;
}
}
// 沒有找到,那么改變規則,重新計算索引
perturb >>= PERTURB_SHIFT;
i = (i * 5 + 1 + perturb) & mask;
}
found_null:
//設置key與hash
entry->key = key;
entry->hash = hash;
}
刪除元素
static PyObject *
set_remove(PySetObject *so, PyObject *key)
{
PyObject *tmpkey;
int rv;
//將該值設置為dummy態
rv = set_discard_key(so, key);
if (rv < 0) {
//類型檢測
if (!PySet_Check(key) || !PyErr_ExceptionMatches(PyExc_TypeError))
return NULL;
PyErr_Clear();
//對該值重新初始化為frozenset
tmpkey = make_new_set(&PyFrozenSet_Type, key);
if (tmpkey == NULL)
return NULL;
//將該key設置為空
rv = set_discard_key(so, tmpkey);
Py_DECREF(tmpkey);
if (rv < 0)
return NULL;
}
//如果沒有找到則報錯
if (rv == DISCARD_NOTFOUND) {
_PyErr_SetKeyError(key);
return NULL;
}
Py_RETURN_NONE;
}
//里面調用了set_discard_key方法
static int
set_discard_key(PySetObject *so, PyObject *key)
{
Py_hash_t hash;
//老套路,先計算hash值
if (!PyUnicode_CheckExact(key) ||
(hash = ((PyASCIIObject *) key)->hash) == -1) {
hash = PyObject_Hash(key);
if (hash == -1)
return -1;
}
//將hash值也船進入
return set_discard_entry(so, key, hash);
}
static int
set_discard_entry(PySetObject *so, PyObject *key, Py_hash_t hash)
{
setentry *entry;
PyObject *old_key;
////通過傳入的key和hash找到該entry
//並且hash對應的key要和傳入的key是一樣的
entry = set_lookkey(so, key, hash);
//如果entry為NULL,直接返回-1
if (entry == NULL)
return -1;
//如果entry不為NULL,但是對應的key為NULL
//返回DISCARD_NOTFOUND
if (entry->key == NULL)
return DISCARD_NOTFOUND;
//獲取要刪除的key
old_key = entry->key;
//並將entry的key設置為dummy
entry->key = dummy;
//hash值設置為-1
entry->hash = -1;
//減少使用數量
so->used--;
//減少引用計數
Py_DECREF(old_key);
//返回DISCARD_FOUND
return DISCARD_FOUND;
}
可以看到集合添加、刪除元素和字典是有些相似的,畢竟底層都是使用了hash表嘛。
集合的運算(交集)
在python中使用集合的時候,可以取兩個集合的交集、並集、差集、對稱差集等等,這里介紹一下交集,其余的可以自行看源碼研究(Objects/setobject.c)
。
static PyObject *
set_intersection(PySetObject *so, PyObject *other)
{
//result,顯然是用來存儲兩者交集運算的結果的
PySetObject *result;
//不看下面代碼的話,很難知道這幾個PyObject * 是用來干啥的
//我們下面代碼再看看這是干啥的
PyObject *key, *it, *tmp;
//這個肯定是hash值
Py_hash_t hash;
int rv;
//如果兩個對象一樣
if ((PyObject *)so == other)
//直接返回其中一個的拷貝即可
return set_copy(so);
//這行代碼表示創建一個空的PySetObject *
result = (PySetObject *)make_new_set_basetype(Py_TYPE(so), NULL);
//如果result == NULL,說明創建失敗
if (result == NULL)
return NULL;
//檢測other是不是PySetObject *
if (PyAnySet_Check(other)) {
//初始索引為0
Py_ssize_t pos = 0;
//setentry *
setentry *entry;
//如果other元素的個數大於so
if (PySet_GET_SIZE(other) > PySet_GET_SIZE(so)) {
//就把so和other進行交換
tmp = (PyObject *)so;
so = (PySetObject *)other;
other = tmp;
}
//從少的那一方的開頭開始便利
while (set_next((PySetObject *)other, &pos, &entry)) {
//拿到key和hash
key = entry->key;
hash = entry->hash;
//傳入other的key和hash,在so中去找
rv = set_contains_entry(so, key, hash);
if (rv < 0) {
//如果對應的rv不存在,那么顯然就沒有
Py_DECREF(result);
return NULL;
}
if (rv) {
//存在的話設置進result里面
if (set_add_entry(result, key, hash)) {
Py_DECREF(result);
return NULL;
}
}
}
//直接返回
return (PyObject *)result;
}
//如果不是PyObject *
//那么獲取其對應的迭代器,相當於python中的__iter__
it = PyObject_GetIter(other);
//如果是NULL,降低其引用計數
if (it == NULL) {
Py_DECREF(result);
//返回NULL
return NULL;
}
//下面的沒必要分析了,在python中,只能set和set(或者frozenset)之間才可以取交集
while ((key = PyIter_Next(it)) != NULL) {
hash = PyObject_Hash(key);
if (hash == -1)
goto error;
rv = set_contains_entry(so, key, hash);
if (rv < 0)
goto error;
if (rv) {
if (set_add_entry(result, key, hash))
goto error;
}
Py_DECREF(key);
}
Py_DECREF(it);
if (PyErr_Occurred()) {
Py_DECREF(result);
return NULL;
}
return (PyObject *)result;
error:
Py_DECREF(it);
Py_DECREF(result);
Py_DECREF(key);
return NULL;
}
集合小結
可以看到,剖析集合的時候話很少。主要是有了剖析字典的經驗,因此再剖析集合的時候就很簡單了。並且在Python中還有一個frozenset,就是不可變的集合。但是不像列表和元組,元組還是有很多特殊的,並不單單只是不可變的列表,從具有自己獨自的結構體就能看出來。而frozenset對象和set對象都是一個結構體,只有一個PySetObject,沒有PyFrozenSetObject。我們在看PySetObject的時候,發現里面有一個hash成員,如果是frozenset的話,那么hash值是不為-1的,因為它不可以添加、刪除元素,是不可變對象。由於比較相似,因此frozenset就不再說了,可以自己源碼中研究,位置還是Object/setobject.c
。
まとめ
所以這一次我們就分析的字典的底層結構,解釋了為什么字典的搜索這么快,並分析哈希表的實現,以及在發生沖突時的處理辦法。另外,由於集合也是使用了哈希表,所以我們就放在一塊說了。
還是吐槽一下Typora這個Markdown編輯器,雖然用的很舒服、並且還免費,但是字數一多就會非常卡,使用體驗就瞬間不好了,所以能優化一下就好了。不過吐槽歸吐槽,既然都讓免費使用了,那么還是要懷着一顆感恩的心的。