在一些語言實作物件相等性時,例如Java,必須重新定義hashCode、equals方法,前者傳回的整數可用於一些雜湊運算場合,例如,作為HashSet的元素,或者是HashMap的鍵,按照Java的API文件規範:物件相等比較成立時(equals為true),hashCode傳回的雜湊值必須相同;hashCode的雜湊值不同的話,物件必不相等;hashCode的雜湊值相同,物件不一定相等,需要再進一步透過equals比較相等性。

這些應該是Java開發者都知道的基本常識,如果是更嚴謹的開發者,或許會提及API文件上規範的反身性(reflexive)、對稱性(symmetric)、傳遞性(transitive),以及一致性(consistent),而且,建議在定義相等性之際,要同時重新定義equals與hashCode方法。

物件雜湊與相等性

API文件規範寫著:若實作equals時用到的資料成員沒有改變,hashCode傳回的雜湊值就不會改變。這似乎暗示著,hashCode、equals在實作時,可以使用到可變動的資料成員?實際上,Java標準API裡確實有這類實作,例如ArrayList的hashCode,會因為收納的元素不同或長度不同,而有不同的傳回值。

然而,我在先前專欄文章〈Wat?為何不相等?〉曾經提過,重新定義equasl與hashCode時,應避免使用會變動的資料成員,以免因為資料成員變動,造成hashCode傳回值不同,使得在雜湊計算的場合出現意料之外的臭蟲。

Wat?為何不相等?〉談到了簡單的臭蟲案例,另一個情況是,想想看將ArrayList實例lt放入HashSet,ArrayList在收集了新元素後,明明是同一個實例,透過set.contains(lt)處理之後是false,然而,set.stream().findFirst().get().equals(lt)卻會是true的尷尬案例。

Python的可雜湊物件

在Python中,若我們將list放入set,如{[1, 2, 3]},會引發TypeError,錯誤訊息將指出list不是可雜湊型態(unhashable type),而且Python在官方術語表(Glossary)也定義hashable型態的實例:其存活週期的雜湊值絕不會改變,並且可以比較相等性。因此對於list、set、dict這類狀態可變的物件,不會有雜湊值,具體而言,Python內建的可變物件,__hash__會被設為None。

既然如此,狀態不可變的物件,不就是可雜湊型態嗎?確實地,Python內建的不可變型態,例如tuple、frozenset等,都是可雜湊型態(這類容器裡的元素也要是可雜湊型態);然而,有趣的是,使用者自訂類別時,若沒有定義__eq__與__hash__,預設也會是可雜湊型態。

這是因為,在未重新定義的情況下,會繼承object的__eq__與__hash__,前者使用is比較,後者是基於物件id計算而來(在Python 3,目前是id(self)//16),在整個物件的生命週期內,id值不變、雜湊值也就不變,因此是可雜湊物件,可應用於雜湊計算場合,使用預設的__eq__與__hash__,也代表你 想比較物件是否為同一實例。

在必須依類別值域進行相等性比較的場合,要重新定義__eq__,而在未重新定義__hash__的情況下,__hash__會設為None,也就是說,只定義__eq__的類別,是不可雜湊型態,這是因為,在雜湊計算場合,如實例放入set時,若真的使用object預設的__hash__,由於id每個實例是不同的,結果就是值域相同的兩個實例,也會被放到不同的雜湊桶裡了,為了避免這類問題,Python就將__hash__設為None了。

不應該只有重新定義__hash__,因為方才提到,物件相等比較成立時,雜湊碼必須相同,如果__hash__實作時根據值域計算,同一實例將會有不同的雜湊碼,也就是__eq__為True時,__hash__卻是不同,這本身就違反了__hash__的協定。

雖然在自定義類別之際,若同時重新定義__eq__與__hash__,Python直譯器就會視其為可雜湊型態,不過對於狀態可變物件而言,若__hash__的計算使用到可變動的值域,就無法滿足存活週期雜湊值絕不改變這個約定,所以,面對狀態可變物件,只能重新定義__eq__,不應該重新定義__hash__。

可雜湊就是不可變

事實已經很明顯了,在Python的定義裡,可雜湊物件就是不可變動物件,雖然自定義類別時,在沒有特別對值域做任何處理的情況下,值域的狀態都是可變動,然而,如果我們想重新定義__hash__、__eq__,也就是定義可雜湊型態時,就是在定義不可變動型態,對於這種自定義類別,生成實例後,就不該改變其狀態,畢竟Python之父說過「We are all consenting adults」,對吧!

另一方面,這也意謂著,能加入set或者作為dict鍵的物件,就是不可變物件,關於這一點,與〈Wat?為何不相等?〉談到「重新定義equasl與hashCode時,應避免使用會變動的資料成員」相符。

先前我在專欄文章〈Python資料類別方案〉談過NamedTuple,在自定義類別時若繼承它,就可以定義有欄位名稱的tuple,因為本質上還是tuple,自定義的NamedTuple就是可雜湊型態;Python 3.7新增了dataclasses模組,可以透過@dataclass裝飾器、field函式等來定義資料類別,對於一個資料類別,會根據欄位來定義__eq__,不過預設不會有__hash__。

如果想要使資料類別成為可雜湊型態,此時,我們可以設定@dataclass(frozen=True)來凍結欄位,成為不可變型態,或者是設定@dataclass(eq=False)令其不產生__eq__,這時,就是直接繼承object的__hash__、__eq__。

如果資料類別只是想要形式上的可雜湊,那麼,我們可以設定@dataclass(unsafe_hash=True),這會根據欄位來產生__hash__,只不過正如unsafe_hash明示的,這是不安全的選項;當然,還有一個方式,那就是自定義__hash__,只不過這也是單純符合Python直譯器要求的協定罷了。

雜湊計算就是要求不可變

那麼Java的ArrayList,它的hashCode實現是錯的嗎?這要看ArrayList如何應用,如果像方才談到的,將ArrayList實例lt放入HashSet,後續又用ArrayList收集新元素,顯然就是不正確的做法;然而,如果已經收集了元素才放入HashSet,後續不再改變ArrayList,或者甚至透過List.of轉換為不可變的List,再放入HashSet,那就是合理的做法。

這也提醒了Java(或其他有hashCode、equals類似協定的語言)開發者,雖然過往有許多文件指出,equals與hashCode最好同時實作,不過,若是可變動物件,只定義equals,並在hashCode實作時拋出例外,或許可以是一種方式,同時,能以此提醒使用者,不應該將可變動物件,用於雜湊計算的場合。

也就是說,當你將物件用於雜湊計算場合,就應當意識到此時就是要求不可變特性,當然,若是不可變動物件,就不會有這類問題。從Java 16以後,記錄類別(record class)成為正式特性,因為欄位不可變,可以自動產生equals、hashCode方法,也就適用於雜湊計算的場合。

雖然其他語言,不像Python會在某些情況下,自定義類別時能自動決定__hash__是否為None,從而確定是否為可雜湊型態,不過,在重新定義equals、hashCode時,也可以多思考一下Python裡處理可雜湊型態的方式,獲得實作時的啟發。

專欄作者

熱門新聞

Advertisement