Python學習之對象的哈希值

楔子

通過字典的底層實現,我們找到了字典快速且高效的祕密,就是哈希表。對於映射型容器,一般會採用平衡搜索樹或哈希表實現,而Python的字典選用了哈希表,主要是考慮到哈希表在搜索方面的效率更高。因爲Python虛擬機重度依賴字典,所以對字典在搜索、設置元素方面的性能,要求的更加苛刻。

但是由於哈希表的稀疏特性,導致其會有巨大的內存犧牲,而爲了優化,Python別出心裁地將哈希表分成兩部分來實現,分別是:哈希索引數組和鍵值對數組。

但是顯然這當中還有很多細節我們沒有說,比如:key到底是如何映射成索引的?索引衝突了怎麼辦?哈希攻擊又是什麼?以及刪除元素也會面臨一些問題,又是如何解決的?

我們將用三篇文章來攻破這些難題,深入理解哈希表,下面先來看看對象的哈希值。

哈希值

Python內置函數hash可以計算對象的哈希值,哈希表依賴於哈希值。而根據哈希表的性質,我們知道鍵對象必須滿足以下兩個條件,否則它無法容納在哈希表中。

  • 哈希值在對象的整個生命週期內不可以改變;
  • 可比較,如果兩個對象相等,那麼它們的哈希值一定相同;

滿足這兩個條件的對象便是可哈希(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__方法的時候,哈希值默認是根據對象的地址計算得到的。而且對象如果相等,那麼哈希值一定是一樣的。

但是我們重寫了__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的不重複性,保留了後面的鍵值對。

但需要注意的是,在比較相等時,會先比較地址是否一樣,如果地址一樣,那麼哈希表會直接認爲相等。

class A:

    def __eq__(self, other):
        return False

    def __hash__(self):
        return 123

    def __repr__(self):
        return "A instance"


a1 = A()
# 我們看到 a1 == a1False
print(a1 == a1)  # False
# 但是隻保留了一個key,原因是地址一樣
# 在比較是否相等之前,會先判斷地址是否一樣
# 如果地址一樣,那麼認爲是同一個key
print({a1: 1, a1: 2})  # {A instance: 2}

a2 = A()
# 此時會保留兩個key
# 因爲 a1 和 a2 地址不同,a1 == a2 也爲False
# 所以哈希表認爲這是兩個不同的 key
# 但由於哈希值一樣,那麼映射出來的索引也一樣
# 因此寫入 a2:2 時相當於發生了索引衝突,於是會重新映射
# 但總之這兩個key都會被保留
print({a1: 1, a2: 2})  # {A instance: 1, A instance: 2}

同樣的,我們再來看一個Python字典的例子

d = {1: 123}

d[1.0] = 234
print(d)  # {1: 234}

d[True] = 345
print(d)  # {1: 345}

天哪嚕,這是咋回事?首先整數在計算哈希值的時候,得到結果就是其本身;而浮點數顯然不是,但如果浮點數的小數點後面是0,那麼它和整數是等價的。

因此3和3.0的哈希值一樣,並且兩者也是相等的,因此它們被視爲同一個key,所以相當於是更新。同理True也一樣,因爲bool繼承自int,所以它等價於1,比如:9 + True = 10。因此True和1相等,並且哈希值也相等,那麼索引d[True] = 345同樣相當於更新。

但是問題來了,值更新了我們可以理解,字典裏面只有一個元素也可以理解,可爲什麼key一直是1呢?理論上最終結果應該是True纔對啊。

其實這算是Python偷了個懶吧(開個玩笑),因爲key的哈希值是一樣的,並且也相等,所以Python不會對key進行替換。

從字典在設置元素的時候我們也知道,如果對key映射成索引之後,發現哈希索引數組的該槽沒有人用,那麼就按照先來後到的順序將鍵值對存儲在鍵值對數組中,再把它在鍵值對數組中的索引存在哈希索引數組的指定槽中。

但如果發現槽有人用了,那麼根據槽裏面存的索引,去鍵值對數組中查找指定的entry,然後比較兩個key是否相等。如果對應的key不相等,則重新映射找一個新的槽;如果相等,則說明是同一個key,那麼把value換掉即可。

所以在替換元素的整個過程中,根本沒有涉及到對鍵的修改,因此上面那個例子的最終結果,value會變、但鍵依舊是1,而不是True。

總之理想的哈希函數必須保證哈希值儘量均勻地分佈於整個哈希空間中,越是相近的值,其哈希值差別應該越大。還是那句話,哈希函數對哈希表的好壞起着至關重要的作用。

以上就是本次分享的所有內容,想要了解更多歡迎前往公衆號:Python編程學習圈,每日干貨分享

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章