這一篇是
《流暢的 python》
讀書筆記。主要介紹:
- 常見的字典方法
- 如何處理查不到的鍵
- 標準庫中 dict 型別的變種
- 雜湊表的工作原理
泛對映型別
collections.abc 模組中有 Mapping 和 MutableMapping 這兩個抽象基類,它們的作用是為 dict 和其他類似的型別定義形式介面。
標準庫裡所有對映型別都是利用 dict 來實現的,它們有個共同的限制,即只有可雜湊的資料型別才能用做這些對映裡的鍵。
問題:
什麼是可雜湊的資料型別?
在 python 詞彙表(https://docs.python.org/3/glossary.html#term-hashable)中,關於可雜湊型別的定義是這樣的:
如果一個物件是可雜湊的,那麼在這個物件的生命週期中,它的雜湊值是不變的,而且這個物件需要實現
__hash__()
方法。另外可雜湊物件還要有__eq__()
方法,這樣才能跟其他鍵做比較。如果兩個可雜湊物件是相等的,那麼它們的雜湊只一定是一樣的
根據這個定義,原子不可變型別(str,bytes和數值型別)都是可雜湊型別,frozenset 也是可雜湊的(因為根據其定義,frozenset 裡只能容納可雜湊型別),如果元組內都是可雜湊型別的話,元組也是可雜湊的(元組雖然是不可變型別,但如果它裡面的元素是可變型別,這種元組也不能被認為是不可變的)。
一般來講,使用者自定義的型別的物件都是可雜湊的,雜湊值就是它們的 id() 函式的返回值,所以這些物件在比較的時候都是不相等的。(如果一個物件實現了 eq 方法,並且在方法中用到了這個物件的內部狀態的話,那麼只有當所有這些內部狀態都是不可變的情況下,這個物件才是可雜湊的。)
根據這些定義,字典提供了很多種構造方法,https://docs.python.org/3/library/stdtypes.html#mapping-types-dict 這個頁面有個例子來說明建立字典的不同方式。
>>> a = dict(one=1, two=2, three=3)
>>> b = {`one`: 1, `two`: 2, `three`: 3}
>>> c = dict(zip([`one`, `two`, `three`], [1, 2, 3]))
>>> d = dict([(`two`, 2), (`one`, 1), (`three`, 3)])
>>> e = dict({`three`: 3, `one`: 1, `two`: 2})
>>> a == b == c == d == e
True
除了這些方法以外,還可以用字典推導的方式來建造新 dict。
字典推導
自 Python2.7 以來,列表推導和生成器表示式的概念就移植到了字典上,從而有了字典推導。字典推導(dictcomp)可以從任何以鍵值對作為元素的可迭代物件中構建出字典。
比如:
>>> data = [(1, `a`), (2, `b`), (3, `c`)]
>>> data_dict = {num: letter for num, letter in data}
>>> data_dict
{1: `a`, 2: `b`, 3: `c`}
常見的對映方法
下表為我們展示了 dict、defaultdict 和 OrderedDict 的常見方法(後兩種是 dict 的變種,位於 collections模組內)。
- default_factory 並不是一個方法,而是一個可呼叫物件,它的值 defaultdict 初始化的時候由使用者設定。
- OrderedDict.popitem() 會移除字典最先插入的元素(先進先出);可選引數 last 如果值為真,則會移除最後插入的元素(後進先出)。
- 用 setdefault 處理找不到的鍵
當字典 d[k] 不能找到正確的鍵的時候,Python 會丟擲異常,平時我們都使用d.get(k, default)
來代替 d[k],給找不到的鍵一個預設值,還可以使用效率更高的 setdefault
my_dict.setdefault(key, []).append(new_value)
# 等同於
if key not in my_dict:
my_dict[key] = []
my_dict[key].append(new_value)
這兩段程式碼的效果一樣,只不過,後者至少要進行兩次鍵查詢,如果不存在,就是三次,而用 setdefault
只需一次就可以完成整個操作。
那麼,我們取值的時候,該如何處理找不到的鍵呢?
對映的彈性查詢
有時候,就算某個鍵在對映裡不存在,我們也希望在通過這個鍵讀取值的時候能得到一個預設值。有兩個途徑能幫我們達到這個目的,
一個是通過 defaultdict
這個型別而不是普通的 dict,另一個是給自己定義一個 dict
的子類,然後在子類中實現__missing__
方法。
defaultdict:處理找不到的鍵的一個選擇
首先我們看下如何使用 defaultdict :
import collections
index = collections.defaultdict(list)
index[new_key].append(new_value)
這裡我們新建了一個字典 index,如果鍵 new_key
在 index 中不存在,表示式 index[new_key]
會按以下步驟來操作:
- 呼叫 list() 來建立一個新的列表
- 把這個新列表作為值,`new_key` 作為它的鍵,放入 index 中
- 返回這個列表的引用。
而這個用來生成預設值的可呼叫物件存放在名為 default_factory
的例項屬性中。
defaultdict 中的 default_factory 只會在 getitem 裡呼叫,在其他方法中不會發生作用。比如 index[k] 這個表示式會呼叫 default_factory 創造的某個預設值,而 index.get(k) 則會返回 None。(這是因為特殊方法 missing 會在 defaultdict 遇到找不到的鍵的時候呼叫 default_factory,實際上,這個特性所有對映方法都可以支援)。
特殊方法 missing
所有對映在處理找不到的鍵的時候,都會牽扯到 missing 方法。但基類 dict 並沒有提供 這個方法。不過,如果有一個類繼承了 dict ,然後這個繼承類提供了 missing 方法,那麼在 getitem 碰到找不到鍵的時候,Python 會自動呼叫它,而不是丟擲一個 KeyError 異常。
__missing__
方法只會被__getitem__
呼叫。提供 missing 方法對 get 或者 __contains__(in 運算子會用到這個方法)這些方法的是有沒有影響。
下面這段程式碼實現了 StrKeyDict0 類,StrKeyDict0 類在查詢的時候把非字串的鍵轉化為字串。
class StrKeyDict0(dict): # 繼承 dict
def __missing__(self, key):
if isinstance(key, str):
# 如果找不到的鍵本身就是字串,丟擲 KeyError
raise KeyError(key)
# 如果找不到的鍵不是字串,轉化為字串再找一次
return self[str(key)]
def get(self, key, default=None):
# get 方法把查詢工作用 self[key] 的形式委託給 __getitem__,這樣在宣佈查詢失敗錢,還能通過 __missing__ 再給鍵一個機會
try:
return self[key]
except KeyError:
# 如果丟擲 KeyError 說明 __missing__ 也失敗了,於是返回 default
return default
def __contains__(self, key):
# 先按傳入的鍵查詢,如果沒有再把鍵轉為字串再找一次
return key in self.keys() or str(key) in self.keys()
contains 方法存在是為了保持一致性,因為 k in d 這個操作會呼叫它,但我們從 dict 繼承到的 contains 方法不會在找不到鍵的時候用 missing 方法。
my_dict.keys() 在 Python3 中返回值是一個 “檢視”,”檢視”就像是一個集合,而且和字典一樣速度很快。但在 Python2中,my_dict.keys() 返回的是一個列表。 所以 k in my_dict.keys() 操作在 python3中速度很快,但在 python2 中,處理效率並不高。
如果要自定義一個對映型別,合適的策略是繼承
collections.UserDict
類。這個類就是把標準 dict 用 python 又實現了一遍,UserDict 是讓使用者繼承寫子類的,改進後的程式碼如下:
import collections
class StrKeyDict(collections.UserDict):
def __missing__(self, key):
if isinstance(key, str):
raise KeyError(key)
return self[str(key)]
def __contains__(self, key):
# 這裡可以放心假設所有已經儲存的鍵都是字串。因此只要在 self.data 上查詢就好了
return str(key) in self.data
def __setitem__(self, key, item):
# 這個方法會把所有的鍵都轉化成字串。
self.data[str(key)] = item
因為 UserDict 繼承的是 MutableMapping,所以 StrKeyDict 裡剩下的那些對映型別都是從 UserDict、MutableMapping 和 Mapping 這些超類繼承而來的。
Mapping 中提供了 get 方法,和我們在 StrKeyDict0 中定義的一樣,所以我們在這裡不需要定義 get 方法。
字典的變種
在 collections 模組中,除了 defaultdict 之外還有其他的對映型別。
- collections.OrderedDict
- collections.ChainMap
- collections.Counter
不可變的對映型別
問題:
標準庫中所有的對映型別都是可變的,如果我們想給使用者提供一個不可變的對映型別該如何處理呢?
從 Python3.3 開始 types 模組中引入了一個封裝類名叫 MappingProxyType
。如果給這個類一個對映,它會返回一個只讀的對映檢視(如果原對映做了改動,這個檢視的結果頁會相應的改變)。例如
>>> from types import MappingProxy Type
>>> d = {1: `A`}
>>> d_proxy = MappingProxyType(d)
>>> d_proxy
mappingproxy({1: `A`})
>>> d_proxy[1]
`A`
>>> d_proxy[2] = `x`
Traceback(most recent call last):
File "<stdin", line 1, in <module>
TypeError: `MappingProxy` object does not support item assignment
>>> d[2] = `B`
>>> d_proxy[2] # d_proxy 是動態的,d 的改動會反饋到它上邊
`B`
字典中的雜湊表
雜湊表其實是一個稀疏陣列(總有空白元素的陣列叫稀疏陣列),在 dict 的雜湊表中,每個鍵值都佔用一個表元,每個表元都有兩個部分,一個是對鍵的引用,另一個是對值的引用
。因為所有表元的大小一致,所以可以通過偏移量來讀取某個表元
。
python 會設法保證大概有1/3 的表元是空的,所以在快要達到這個閾值的時候,原有的雜湊表會被複制到一個更大的空間。
如果要把一個物件放入雜湊表,那麼首先要計算這個元素的雜湊值。
Python內建的 hash() 方法可以用於計算所有的內建型別物件。
如果兩個物件在比較的時候是相等的,那麼它們的雜湊值也必須相等。例如 1==1.0 那麼,hash(1) == hash(1.0)
雜湊表演算法
為了獲取 my_dict[search_key] 的值,Python 會首先呼叫 hash(search_key) 來計算 search_key 的雜湊值,把這個值的最低幾位當做偏移量在雜湊表中查詢元。若表元為空,丟擲 KeyError 異常。若不為空,則表元會有一對 found_key:found_value
。
這時需要校驗 search_key == found_key,如果相等,返回 found_value。
如果不匹配(雜湊衝突),再在雜湊表中再取幾位,然後處理一下,用處理後的結果當做索引再找表元。 然後重複上面的步驟。
取值流程圖如下:
新增新值和上述的流程基本一致,只不過對於前者,在發現空表元的時候會放入一個新元素,而對於後者,在找到相應表元后,原表裡的值物件會被替換成新值。
另外,在插入新值是,Python 可能會按照雜湊表的擁擠程度來決定是否重新分配記憶體為它擴容,
如果增加了雜湊表的大小,那雜湊值所佔的位數和用作索引的位數都會隨之增加
字典的優勢和限制
1、鍵必須是可雜湊的
可雜湊物件要求如下:
- 支援 hash 函式,並且通過__hash__() 方法所得的雜湊值不變
- 支援通過 __eq__() 方法檢測相等性
- 若 a == b 為真, 則 hash(a) == hash(b) 也為真
2、字典開銷巨大
因為字典使用了雜湊表,而雜湊表又必須是稀疏的,這導致它在空間上效率低下。
3、鍵查詢很快
dict 的實現是典型的空間換時間:字典型別由著巨大的記憶體開銷,但提供了無視資料量大小的快速訪問。
4、鍵的次序決定於新增順序
當往 dict 裡新增新鍵而又發生雜湊衝突時,新建可能會被安排存放在另一個位置。
5、往字典裡新增新鍵可能會改變已有鍵的順序
無論何時向字典中新增新的鍵,Python 直譯器都可能做出為字典擴容的決定。擴容導致的結果就是要新建一個更大的雜湊表,並把原有的鍵新增到新的雜湊表中,這個過程中可能會發生新的雜湊衝突,導致新雜湊表中次序發生變化。
因此,不要對字典同時進行迭代和修改。
總結
這一篇主要介紹了:
- 常見的字典方法
- 如何處理查不到的鍵
- 標準庫中 dict 型別的變種
- 雜湊表的工作原理
- 雜湊表帶來的潛在影響
參考連結
- https://docs.python.org/3/glossary.html#term-hashable
- https://docs.python.org/3/library/stdtypes.html#mapping-types-dict
最後,感謝女朋友支援。
歡迎關注(April_Louisa) | 請我喝芬達 |
---|---|