Python 3.6 重寫了 dict 的內部結構,記憶體用量比 3.5 少了 20% 到 25%,到 3.7,這個順序也從實作細節寫進規格,變成可以放心的依賴。

現在寫 Python,把幾個值丟進去 dict、再用 for 迴圈跑一遍,出來的順序就是當初塞進去的順序。但3.5 以前還不是這樣:dict 的迭代順序並沒有任何意義,同一份程式換機器、換版本,順序就可能不一樣。那這個轉變是從 3.6 改版開始的。

3.6 換掉了 dict 的內部結構

3.6 改版,原始的動機是記憶體。它把 dict 換成一種比較緊湊的排法,官方的 What’s New 上寫著,新的 dict 記憶體用量比 Python 3.5 少了 20% 到 25%。那至於我們開頭說的「照插入順序」這個效果:只算實作細節,不建議依賴,以後也可能會改變。這個順序在當時,也只是順著新結構冒出來的新東西。

一次查詢、兩層陣列

省記憶體跟保留順序,是同一個結構的兩面。以目前 CPython 的原始碼看,dict 內部拆成兩塊陣列。第一塊 indices,是真正的雜湊表,稀疏、有很多空格子;不過是很小的整數,代表數值存在哪裡。整數指向的是第二塊陣列 entries,key跟值一組一組密集地存在那裡。

查詢的時候,會先算雜湊、落到 indices 的某一格,讀出裡面的整數,再拿著它到 entries,取出真正的值。多繞一層看起來好像很麻煩,得到的是很明確的省空間:indices 每格只要放一個整數,dict 小的時候用 int8 就夠,大一點才需要換 int16、int32,每格頂多幾個 byte;佔空間的指標全集中在 entries,不必為了雜湊表的稀疏跟著留一堆空位。

那順序就是在這裡產生的。entries 會往後增加,新的鍵值只會加在最後,不會插進中間,所以如果此時從頭讀取 entries,先後就是當初的順序。原始碼註解點出的也是這件事:因為 entries 幾乎只增不改,要拿插入順序,把 entries 跑一遍就好。indices 負責雜湊定位,順序則是 entries 這層完成的。函式用 **kwargs 收到的關鍵字參數也開始照傳入順序排(PEP 468),底下靠的就是有序的 dict。

3.7 變規格

在 3.6,順序只是實作細節,把順序固定下來的則是 3.7:從 3.7 起,官方文件的說法改了,dict 的順序保證就是插入順序,也額外說了一句,順序從 3.6 開始本來只是 CPython 的實作細節,是刻意的觀察期,先確定新結構在真實程式裡正確,再寫進規格。

更新和刪除之後

知道順序靠的是 entries 一路往後接,有兩個常見的行為就不奇怪了。更新已經存在的key,只改掉 entries 裡的值,位置沒動,順序上還是一樣。但先把某個key刪掉、再加回來,就不一樣了:刪除會在 indices 對應的格子標成特別狀態(原始碼裡叫 DKIX_DUMMY),在 entries 裡留成一個用不到的空位;等鍵重新加進來,會當成新的一筆接到最後。

d = {"a": 1, "b": 2, "c": 3}
list(d)          # ['a', 'b', 'c']

d["a"] = 99      # 更新既有
list(d)          # ['a', 'b', 'c'],a 沒有換位置

del d["b"]
d["b"] = 5       # 先刪再加
list(d)          # ['a', 'c', 'b'],b 跑到最後

現在依賴 dict 的順序是安全的,只要記得更新鍵不會換位置,先刪再加的鍵會排到最後就好。

參考來源:What’s New in Python 3.6、內建型別文件的 dict 一節,以及 CPython 的 Objects/dictobject.c。