一篇來自 Dan Bader 的有趣的博文,一起來學習一下,如何去研究一個意外的Python現象。
一個Python字典表示式謎題
讓我們探究一下下面這個晦澀的python字典表示式,以找出在python直譯器的中未知的內部到底發生了什麼。
# 一個python謎題:這是一個祕密
# 這個表示式計算以後會得到什麼結果?
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
複製程式碼
有時候你會碰到一個很有深度的程式碼示例 --- 哪怕僅僅是一行程式碼,但是如果你能夠有足夠的思考,它可以教會你很多關於程式語言的知識。這樣一個程式碼片段,就像是一個*Zen kōan
*:一個在修行的過程中用來質疑和考驗學生進步的問題或陳述。
譯者注:Zen kōan
,大概就是修行的一種方式,詳情見wikipedia
我們將在本教程中討論的小程式碼片段就是這樣一個例子。乍看之下,它可能看起來像一個簡單的詞典表示式,但是仔細考慮時,通過cpython直譯器,它會帶你進行一次思維擴充的訓練。
我從這個短短的一行程式碼中得到了一個啟發,而且有一次在我參加的一個Python會議上,我還把作為我演講的內容,並以此開始演講。這也激發了我的python郵件列表成員間進行了一些積極的交流。
所以不用多說,就是這個程式碼片。花點時間思考一下下面的字典表示式,以及它計算後將得到的內容:
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
複製程式碼
在這裡,我先等會兒,大家思考一下...
- 5...
- 4...
- 3...
- 2...
- 1...
OK, 好了嗎?
這是在cpython直譯器互動介面中計算上述字典表示式時得到的結果:
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
{True: 'maybe'}
複製程式碼
我承認,當我第一次看到這個結果時,我很驚訝。但是當你逐步研究其中發生的過程時,這一切都是有道理的。所以,讓我們思考一下為什麼我們得到這個 - 我想說的是出乎意料 - 的結果。
這個子字典是從哪裡來的
當python處理我們的字典表示式時,它首先構造一個新的空字典物件;然後按照字典表示式給出的順序賦鍵和值。
因此,當我們把它分解開的時候,我們的字典表達就相當於這個順序的語句:
>>> xs = dict()
>>> xs[True] = 'yes'
>>> xs[1] = 'no'
>>> xs[1.0] = 'maybe'
複製程式碼
奇怪的是,Python認為在這個例子中使用的所有字典鍵是相等的:
>>> True == 1 == 1.0
True
複製程式碼
OK,但在這裡等一下。我確定你能夠接受1.0 == 1,但實際情況是為什麼True
也會被認為等於1呢?我第一次看到這個字典表示式真的讓我難住了。
在python文件中進行一些探索之後,我發現python將bool
作為了int
型別的一個子類。這是在Python 2和Python 3的片段:
“The Boolean type is a subtype of the integer type, and Boolean values behave like the values 0 and 1, respectively, in almost all contexts, the exception being that when converted to a string, the strings ‘False’ or ‘True’ are returned, respectively.”
“布林型別是整數型別的一個子型別,在幾乎所有的上下文環境中布林值的行為類似於值0和1,例外的是當轉換為字串時,會分別將字串”False“或”True“返回。“(原文)
是的,這意味著你可以在程式設計時上使用bool
值作為Python中的列表或元組的索引:
>>> ['no', 'yes'][True]
'yes'
複製程式碼
但為了程式碼的可讀性起見,您不應該類似這樣的來使用布林變數。(也請建議你的同事別這樣做)
Anyway,讓我們回過來看我們的字典表示式。
就python而言,True
,1
和1.0
都表示相同的字典鍵。當直譯器計算字典表示式時,它會重複覆蓋鍵True
的值。這就解釋了為什麼最終產生的字典只包含一個鍵。
在我們繼續之前,讓我們再回顧一下原始字典表示式:
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
{True: 'maybe'}
複製程式碼
這裡為什麼最終得到的結果是以True
作為鍵呢?由於重複的賦值,最後不應該是把鍵也改為1.0
了?經過對cpython直譯器原始碼的一些模式研究,我知道了,當一個新的值與字典的鍵關聯的時候,python的字典不會更新鍵物件本身:
>>> ys = {1.0: 'no'}
>>> ys[True] = 'yes'
>>> ys
{1.0: 'yes'}
複製程式碼
當然這個作為效能優化來說是有意義的 --- 如果鍵被認為是相同的,那麼為什麼要花時間更新原來的?在最開始的例子中,你也可以看到最初的True
物件一直都沒有被替換。因此,字典的字串表示仍然列印為以True
為鍵(而不是1或1.0)。
就目前我們所知而言,似乎看起來像是,結果中字典的值一直被覆蓋,只是因為他們的鍵比較後相等。然而,事實上,這個結果也不單單是由__eq__
比較後相等就得出的。
等等,那雜湊值呢?
python字典型別是由一個雜湊表資料結構儲存的。當我第一次看到這個令人驚訝的字典表示式時,我的直覺是這個結果與雜湊衝突有關。
雜湊表中鍵的儲存是根據每個鍵的雜湊值的不同,包含在不同的“buckets”中。雜湊值是指根據每個字典的鍵生成的一個固定長度的數字串,用來標識每個不同的鍵。(雜湊函式詳情)
這可以實現快速查詢。在雜湊表中搜尋鍵對應的雜湊數字串會快很多,而不是將完整的鍵物件與所有其他鍵進行比較,來檢查互異性。
然而,通常計算雜湊值的方式並不完美。並且,實際上會出現不同的兩個或更多個鍵會生成相同的雜湊值,並且它們最後會出現在相同的雜湊表中。
如果兩個鍵具有相同的雜湊值,那就稱為雜湊衝突(hash collision),這是在雜湊表插入和查詢元素時需要處理的特殊情況。
基於這個結論,雜湊值與我們從字典表達中得到的令人意外的結果有很大關係。所以讓我們來看看鍵的雜湊值是否也在這裡起作用。
我定義了這樣一個類來作為我們的測試工具:
class AlwaysEquals:
def __eq__(self, other):
return True
def __hash__(self):
return id(self)
複製程式碼
這個類有兩個特別之處。
第一,因為它的__eq__
魔術方法(譯者注:雙下劃線開頭雙下劃線結尾的是一些Python的“魔術”物件)總是返回true,所以這個類的所有例項和其他任何物件都會恆等:
>>> AlwaysEquals() == AlwaysEquals()
True
>>> AlwaysEquals() == 42
True
>>> AlwaysEquals() == 'waaat?'
True
複製程式碼
第二,每個Alwaysequals
例項也將返回由內建函式id()
生成的唯一雜湊值值:
>>> objects = [AlwaysEquals(),
AlwaysEquals(),
AlwaysEquals()]
>>> [hash(obj) for obj in objects]
[4574298968, 4574287912, 4574287072]
複製程式碼
在CPython中,id()
函式返回的是一個物件在記憶體中的地址,並且是確定唯一的。
通過這個類,我們現在可以建立看上去與其他任何物件相同的物件,但它們都具有不同的雜湊值。我們就可以通過這個來測試字典的鍵是否是基於它們的相等性比較結果來覆蓋。
正如你所看到的,下面的一個例子中的鍵不會被覆蓋,即使它們總是相等的:
>>> {AlwaysEquals(): 'yes', AlwaysEquals(): 'no'}
{ <AlwaysEquals object at 0x110a3c588>: 'yes',
<AlwaysEquals object at 0x110a3cf98>: 'no' }
複製程式碼
下面,我們可以換個思路,如果返回相同的雜湊值是不是就會讓鍵被覆蓋呢?
class SameHash:
def __hash__(self):
return 1
複製程式碼
這個SameHash
類的例項將相互比較一定不相等,但它們會擁有相同的雜湊值1:
>>> a = SameHash()
>>> b = SameHash()
>>> a == b
False
>>> hash(a), hash(b)
(1, 1)
複製程式碼
一起來看看python的字典在我們試圖使用SameHash
類的例項作為字典鍵時的結果:
>>> {a: 'a', b: 'b'}
{ <SameHash instance at 0x7f7159020cb0>: 'a',
<SameHash instance at 0x7f7159020cf8>: 'b' }
複製程式碼
如本例所示,“鍵被覆蓋”的結果也並不是單獨由雜湊衝突引起的。
Umm..好吧,可以得到什麼結論呢?
python字典型別是檢查兩個物件是否相等,並比較雜湊值以確定兩個金鑰是否相同。讓我們試著總結一下我們研究的結果:
{true:'yes',1:'no',1.0:'maybe'}
字典表示式計算結果為{true:'maybe'}
,是因為鍵true
,1
和1.0
都是相等的,並且它們都有相同的雜湊值:
>>> True == 1 == 1.0
True
>>> (hash(True), hash(1), hash(1.0))
(1, 1, 1)
複製程式碼
也許並不那麼令人驚訝,這就是我們為何得到這個結果作為字典的最終結果的原因:
>>> {True: 'yes', 1: 'no', 1.0: 'maybe'}
{True: 'maybe'}
複製程式碼
我們在這裡涉及了很多方面內容,而這個特殊的python技巧起初可能有點令人難以置信 --- 所以我一開始就把它比作是Zen kōan
。
如果很難理解本文中的內容,請嘗試在Python互動環境中逐個去檢驗一下程式碼示例。你會收穫一些關於python深處知識。
注:轉載請保留下面的內容