[譯]實現 Equality 和 Hashing

JABread發表於2018-04-09

[譯]實現 Equality 和 Hashing

翻譯自 Mike AshImplementing Equality and Hashing

Equality

物件判等是一個基本的概念,在程式碼中經常會被使用到。在 Cocoa 程式設計中,它通過 isEqual: 方法被實現。一些比較簡單的例子像[array indexOfObject:]會在底層使用到它,所以說物件支援判等是非常重要的。

在 Cocoa 程式設計中,它已經為我們在 NSObject 中提供了一個預設的判等實現。這個預設的實現只是通過物件的指標地址來進行判等。換句話說,一個物件只會與它自己相等。這個預設實現從功能上看如下面程式碼所示:

- (BOOL)isEqual: (id)other {
    return self == other;
}
複製程式碼

雖然該預設的判等實現看上去過於簡單,但實際上它對於許多物件來說,是十分有用的。比如,我們永遠不會把一個 NSView 看做跟另外一個 NSView 同等。同樣的,對於很多具有該特性的類來說,這個預設的判等實現已經是足夠了。這或許是個好訊息,因為這意味著如果你的類具有相同的判等特性,那麼你不需要做任何事情就可以免費得到想要的結果。

實現自定義判等

有時候你需要實現更深層次的判等。這對於許多物件來說是很正常的,特別是那些被看作“值型別的物件”,他們是根據邏輯上的判等來區分。舉個例子:

// 使用可變型別保證生成的是不同的字串物件
NSMutableString *s1 = [NSMutableString stringWithString: @"Hello, world"];
NSMutableString *s2 = [NSMutableString stringWithString: @"%@, %@", @"Hello", @"world"];
BOOL equal = [s1 isEqual: s2]; // 返回 YES !
複製程式碼

當然啦,NSMutableString 在這種情況下已經為你做了判等實現。但是如果你想要為自定義的物件做同樣的操作該怎麼辦?

MyClass *c1 = ...;
MyClass *c2 = ...;
BOOL equal = [c1 isEqual: c2];
複製程式碼

在這種情況下你需要實現你自己版本的isEqual:方法。

測試相等性在大多數情況下是相當簡單的。把你的類中所有相關的屬性收集起來,再測試他們的相等性。如果他們當中有不相等的,那麼返回 NO ,否則返回 YES 。

有一個微妙的點就是,當你的物件所對應的類也是檢測相等性中的一個重要的屬性。去檢測 MyClass 和 NSString 的相等性是十分合理的,但是這種比較的結果永遠不會返回 YES (除非 MyClass 是 NSString 的一個子類)。

有一個稍微不那麼微妙的點就是,確保你測試的屬性對於判等來說是非常重要的。一些像快取 caches 這樣的屬性對於你的物件的外部視角而言是無關緊要的,那麼它就不需要被用作判等的因素。

比如說你的類看起來像這樣:

@interface MyClass: NSObject {
   int _length;
   char *_data;
   NSString *_name;
   NSMutableDictionary *_cache;
}
複製程式碼

你的判等實現看起來會像這樣:

- (BOOL)isEqual: (id)other {
   return ([other isKindOfClass: [MyClass class]] && [other length]  == _length && memcmp([other data], _data, _length) == 0 && [[other name] isEqual: _name])
   // 注意:沒有 _cache 的比較
}
複製程式碼

Hashing

雜湊表是一個普通的資料結構,被用於實現 NSDictionary 和 NSSet 等。無論你往容器類中新增多少物件,都能夠支援快速查詢到相應的物件。

如果你已經瞭解雜湊表是如何工作的,你可以直接跳過接下來的一到兩個段落內容。

雜湊表基本上可以被看做是一個帶特殊索引的龐大陣列。所有被新增到陣列的物件都會有一個索引關聯著他們的雜湊值。這個雜湊值本質上是由物件的屬性而產生的偽隨機的數字。這種機制使得索引有足夠的隨機性,那麼兩個物件就不太可能擁有相同的雜湊值了,但這是完全可複寫的。當一個物件被插入到陣列中時,它的雜湊值會被用來決定它該被放到哪個位置上。當一個物件被查詢時,它的雜湊值會被用來決定到哪個位置中查詢。

用更加正式的術語來講,一個物件的雜湊值被定義了,如果兩個物件是相等的,那麼他們會有相同的雜湊值。要注意的是,反過來說是不正確的,也不應該這樣,因為:兩個物件可以有相同的雜湊值,但是他們可以不相等。你想要儘可能的避免出現這種情況,因為當兩個不相等的物件擁有兩個相同的雜湊值(稱為碰撞),那麼雜湊表就必須採取特殊的措施去處理這種情況,這是一個非常耗時的操作。然而,這已經被證明了要想完全避免雜湊碰撞的發生是不可能的。

在 Cocoa 程式設計中,雜湊的實現通過雜湊方法,它的方法簽名為:

- (NSUInteger)hash;
複製程式碼

跟物件判等一樣,NSObject 也為你提供了一個預設的雜湊實現,但這是通過使用物件的標識來實現的。粗略的講,它做了這些事情:

- (NSUInteger)hash {
    return (NSUInteger)self;
}
複製程式碼

實際返回的值可能不一樣,但本質的關鍵點是,這種方式是基於實際指向 self 的指標的值。跟判等方法一樣,如果基於物件標識的判等已經達到你想要的需求,那麼預設的實現對你來說已經是有用的了。

實現自定義雜湊值

因為雜湊的語義,如果你重寫了isEqual:方法,你就必須重寫雜湊方法。如果你不這樣做,你會遇到兩個相同物件卻擁有不同的雜湊值的情況,這是十分不安全的。如果你在字典、集合或者其他需要雜湊表的地方使用到這些物件,那麼會出現問題。

因為物件雜湊值的定義和相等性的關係是十分密切的,同樣的,雜湊方法的實現和判等方法的實現也十分密切。

一個例外的情況是,不需要在雜湊值的定義中包含你的物件所屬的類。這主要是作為isEqual:方法的一個保護措施,是為了確保跟不同物件之間比較時剩餘內容的檢測有意義。如果通過不同的數學方式去合併不同屬性的雜湊值,那麼你的雜湊值很可能跟其他不同的類的雜湊值相比就會非常不一樣。

生成屬性的雜湊值

檢測屬性的相等性通常來說是很簡單的,但計算他們的雜湊值卻不總是那麼簡單。你如何計算一個屬性的雜湊值取決於物件的型別是什麼。

對於數值型屬性,雜湊值可以被簡單的設定為數字的值。

對於物件型屬性,你可以通過呼叫物件的雜湊方法,來使用其返回的雜湊值。

對於資料型屬性,你會想要使用一些雜湊演算法來生成雜湊值。你可以使用 CRC32 ,或者重量型的 MD5 。後者的執行速度相對較慢,但便於使用,它通過把資料封裝在 NSData 中,並且獲取它的雜湊值。在上面的例子中,你可以像這樣計算出 _data 的雜湊值:

[[NSData dataWithBytes: _data length: _length] hash];
複製程式碼

合併屬性的雜湊值

所以你已經知道了如何為每個屬性生成雜湊值,但是要如何將他們合併在一起呢?

最簡單的方式就是將他們相加在一起,或者使用按位或的特性。然而,這會破壞雜湊值的獨特性,因為這些操作都是對稱性的,意味著區分不同物件時會出錯。舉個例子,假設一個物件有 first 和 last name 兩個屬性,它的雜湊方法的實現如下:

- (NSUInteger)hash {
    return [_firstName hash] ^ [_lastName hash];
}
複製程式碼

現在假設你有兩個物件,一個是 “George Frederick” ,另一個是 “Frederick George”。即使他們很明顯是不同的,但他們還是會有相同的雜湊值。雖然雜湊碰撞的發生是完全不可避免的,但我們也應該儘量讓這種情況不輕易出現。

如何合併雜湊值是一個複雜的主題,是無法用一個回答就能解釋的。然而,使用任何不對稱的方式去合併雜湊值卻是一個很好的開始。我打算使用位移運算加上按位異或預算來合併他們。

#define NSUINT_BIT (CHAR_BIT * sizeof(NSUInteger))
#define NSUINTROTATE(val, howmuch) ((((NSUInteger)val) << howmuch) | (((NSUInteger)val) >> (NSUINT_BIT - howmuch)))
複製程式碼
- (NSUInteger)hash {
    return NSUINTROTATE([_firstName hash], NSUINT_BIT / 2) ^ [_firstName hash];
}
複製程式碼

自定義雜湊值用例

現在我們可以運用上述內容來為前面的例子生成一個雜湊值。這跟判等方法的實現一樣會遵循一些基本的格式,並且會使用上述的技術去獲取和合並每個屬性的雜湊值。

- (NSUInteger)hash {
    NSUInteger dataHash = [[NSData dataWithBytes: _data length: _length] hash];
    return NSUINTROTATE(dataHash, NSUINT_BIT / 2) ^ [_name hash];
}
複製程式碼

如果你還有更多的屬性,你可以新增更多的位移運算和按位或操作,而且這個流程都是類似的。你還可能會想要為每一個屬性調整位移運算來使得他們每一個都是不同的。

子類化時注意點

你必須要注意當你子類化的是一個實現了自定義的雜湊方法和判等方法的父類。尤其是你的子類不應該暴露那些在判等方法的實現中使用到的新的屬性。如果你這樣做了,那麼該子類的例項肯定與父類的例項不相等。

為了解釋這種情況,假設一個子類擁有 first/last name 屬性,且包含一個 birthday 屬性,而且 birthday 作為判等計算的一部分。然而,這不可以用在父類的例項中比較相等性,所以它的判等方法看起來像這樣:

- (BOOL)isEqual: (id)other {
    // 筆者注:如果呼叫父類的判等實現的結果返回了 NO ,那麼不用比較新屬性(如果有)也可知道肯定也不相等。
    if(![super isEqual: other])
        return NO;
    
    // 如果執行到這一步,證明通過父類的判等實現的結果返回的是 YES ,接下來觀察要判斷 other 是否是子類或者子類的子類型別,如果不是,則證明要判等的兩個物件實質上是同一個父類物件。
    if(![other isKindOfClass: [MyClass class]])
        return YES;
        
    // 如果執行到這一步,證明要判等的是兩個子類型別,而且對於父類中的屬性已被證明是相等的,那麼接下來繼續判斷新屬性是否相等即可。
    return [[other birthday] isEqual: _birthday];
}
複製程式碼

現在假設你有一個父類的例項對應 “John Smith” ,我稱之為 A ,和一個子類例項對應 “John Smith”,並且生日為 5/31/1982,我稱之為 B 。因為有了上述的判等定義,那麼結果為,A 等於 B ,B 也等於他自己,得到了期望的結果。

現在假設你有一個子類的例項對應 “John Smith” ,生日為 6/7/1994,我稱之為 C 。那麼 C 不等於 B ,得到我們期望的結果。 C 等於 A ,同樣得到期望的結果。但是現在出現了一個問題,A 等於 B 和 C ,但是 B 和 C 不相等!這打破了相等操作的傳遞性,並且會造成非常意外的後果。

通常來講這不應該是一個嚴重的問題。如果你的子類新增了會影響父類物件判等的新屬性,這是你的類層級結構中的一個明顯的設計問題。你應該去考慮如何重新設計你的類層級結構,而不是在isEqual:方法中做一些複雜的實現。

使用字典時注意點

如果你想要在 NSDictionary 中使用你的物件來作為 key 值,你需要實現對應的雜湊方法和判等方法,而且你也需要實現-copyWithZone:方法。做這樣的技巧已經超出了本文的內容,但你應該意識到在某些情況下你需要做更多事情。

總結

在 Cocoa 程式設計中已經為你提供了雜湊方法和判等方法的預設實現,這對於許多物件而言是有用的,但是如果你想要為你自己的物件即使在記憶體地址是不相同的情況下,也想要通過判等結果返回 YES 來指明他們是相等的,那麼你就必須要做一點額外的工作。幸運的是,這實現起來並不困難,並且一旦你實現了他們,你自定義的類將可以用在許多 Cocoa 的集合類中。

相關文章