一個情境,如果想用 set 把重複的座標點去掉,class 裡寫好 __eq__,兩個座標相同的點用 == 比較也確實相等,但執行到建立 set 的時候就出現錯誤:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
a = Point(1, 2)
b = Point(1, 2)
print(a == b) # True
print(Point.__hash__) # None
{a, b}
TypeError: unhashable type: 'Point'
(從 Python 3.14 開始,這行訊息的開頭不同,會是 TypeError: cannot use 'Point' as a set element (unhashable type: 'Point')。說不定以後又會改了..)
Point.__hash__ 是 None,可是 class 裡並沒有設定過它。Python 的資料模型文件覆寫了 __eq__、卻沒有定義 __hash__ 的 class,__hash__ 會被自動設成 None,之後只要取得這種實例的雜湊值,就會丟出 TypeError 錯誤。set 的元素和 dict 的 key 都需要雜湊值,所以錯誤發生在建立 set 的時候,前面的 a == b 反而沒事。
在沒有寫 __eq__ 之前,Point 的實例其實可以放到 set。術語表自訂 class 的實例預設就是 hashable,只會跟自己相等,雜湊值則從 id() 來的。預設的 == 和雜湊值都看物件身分,兩邊是一致的(is 跟 == 的差別可以參考 Python 的 is 和 == 差在哪)。寫了 __eq__ 之後,== 改成比較座標,從 id() 來的雜湊值卻還是只看身分,會沒辦法對上,Python 也就不再沿用預設的雜湊值。
用 id 補上
兩個方法裡各加一行 print,可以看到建立 set 時呼叫了哪些方法:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __eq__(self, other):
print("呼叫 __eq__")
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
def __hash__(self):
print("呼叫 __hash__")
return id(self)
a = Point(1, 2)
b = Point(1, 2)
print(a == b)
s = {a, b}
print(len(s))
呼叫 __eq__
True
呼叫 __hash__
呼叫 __hash__
2
錯誤沒有了,但原本想去掉的重複地方還在,set 裡有兩個元素。第一行的「呼叫 eq」來自 print(a == b),建立 set 的時候只呼叫了兩次 __hash__,「呼叫 eq」反而沒有 print 出來。
這跟 set 加入元素的步驟有關。它先用 __hash__ 的回傳值決定要到雜湊表的哪個位置找,如果碰到雜湊值相同的元素,才會再用 == 確認兩者是否相等。a 和 b 是兩個不同的物件,id 不同,雜湊值也就不同,set 不會再拿 b 跟 a 用 == 比較,所以 __eq__ 當然就變得沒有用了。dict 的 key 也走同樣的流程,用 Point(1, 2) 當 key 存進去的值,換另一個 Point(1, 2) 也查詢不到。
也因為 set 先看雜湊值才比較,Python 對 __hash__ 的硬性要求只有一個:相等的物件,雜湊值必須相同。用 id 補上的版本沒有做到這一點,執行時卻也沒有任何錯誤訊息。
計算雜湊值
把參與比較的欄位放進 tuple,同時計算這個 tuple 的雜湊值(資料模型文件也這樣建議)。__eq__ 比較 (self.x, self.y),__hash__ 就用同組,其他程式則不變:
def __hash__(self):
print("呼叫 __hash__")
return hash((self.x, self.y))
呼叫 __eq__
True
呼叫 __hash__
呼叫 __hash__
呼叫 __eq__
1
這次的雜湊值相同,set 在同一個位置碰到 a,才呼叫 __eq__ 確認,結果相等,b 沒有被加進去。座標相同的點不管建立幾次,計算出來的雜湊值都一樣,拿新的 Point(1, 2) 到 set 或 dict 裡查詢也沒問題。但是如果 __hash__ 用到了 __eq__ 不比較的欄位,就又會出現相等但雜湊值不同的情況。
欄位被修改
用欄位計算雜湊值之後,還有一個地方要注意:Point 的 x、y 隨時都可以修改(下面的 Point 已經拿掉兩行 print)。
a = Point(1, 2)
s = {a}
a.x = 10
print(a in s) # False
print(list(s)[0] is a) # True
set 裡唯一的元素就是 a 本身,但用 a in s 查詢卻得到 False。a 放進 set 時,位置是依照 (1, 2) 的雜湊值決定的;改成 (10, 2) 之後,查詢時是照新的雜湊值去找,原本的 a 找不到。
如果欄位在建立之後就不會再修改,像設定值這類資料,就用 __eq__ 比較的同一組欄位計算 __hash__。如果欄位會在使用中被修改,資料模型文件建議不要實作 __hash__,讓它維持 None;真的需要放進 set 或當成 dict key 時,可以改成把 (p.x, p.y) 這個 tuple 放進去,tuple 建立之後就不會變動。
這篇是照 Python 文件的資料模型與術語表整理的,範例則是在 Python 3.11 和 3.14 跑過。
