前言
對數據的等同性判斷包括對基本數據類型等同性
的判斷和對象等同性
的判斷。對基本數據類型等同性的判斷是非常簡單的,比如對兩個NSInteger類型的變量等同性判斷,我們直接使用關系運算符“==”即可。
相比于基本數據類型等同性,對象等同性的判斷就稍顯復雜。按照大神Mattt Thompson
的說法,對象的等同性
包括相等性
和本體性
。從字面不難發現,相等性是指:兩個對象的值是否相等
。本體性是指:兩個對象本質上是否是同一個對象
。
關系運算符"=="不僅可以應用在基本數據類型上,還可以應用在兩個對象類型的對象上。不過,按照==”比較兩個對象,本質上是對兩個對象指針地址的比較,即對象本體性的判斷。單純的比較兩個對象的指針并不能完全滿足要求,因為對象的等同性不僅包括本體性,還包括相等性。有時候指向兩個對象的指針雖然不相同,但是兩個對象的值是相同的,我們也認為其是相同的,即相等性。換句話說,單純的通過比較兩個對象的指針來判斷等同性總是太過苛刻。而對于自定義的類型,開發中經常要對兩個對象的相等性進行判斷,即對兩個對象每個屬性進行比較。如果兩個對象的類型相同,且屬性值都一樣,我們也會認為其是相等的。如果對象是集合類型,比如數組,相等性檢查要求我們對兩個數組相同位置的元素進行逐個比較。
NSFoundation提供的一些方法
Objective-C的NSFoundation框架中給我們提供了很多判斷對象等同性的方法。比如:
// NSString類提供了判斷兩個NSString對象是否相等的方法
- (BOOL)isEqualToString:(NSString *)aString;
// NSArray類提供了判斷兩個NSAarray對象是否相等的方法
- (BOOL)isEqualToArray:(NSArray<ObjectType> *)otherArray;
// NSDictionary類提供了判斷兩個NSDictionary對象是否相等的方法
- (BOOL)isEqualToDictionary:(NSDictionary<KeyType, ObjectType> *)otherDictionary;
// NSSet類提供了判斷兩個NSSet對象是否相等的方法
- (BOOL)isEqualToSet:(NSSet<ObjectType> *)otherSet;
// ......
- (BOOL)isEqualToData:(NSData *)other;
- (BOOL)isEqualToNumber:(NSNumber *)number;
- (BOOL)isEqualToValue:(NSValue *)value;
- (BOOL)isEqualToTimeZone:(NSTimeZone *)aTimeZone;
- (BOOL)isEqualToDate:(NSDate *)otherDate;
- (BOOL)isEqualToOrderedSet:(NSOrderedSet<ObjectType> *)other;
- (BOOL)isEqualToHashTable:(NSHashTable<ObjectType> *)other;
- (BOOL)isEqualToIndexSet:(NSIndexSet *)indexSet;
前面說,對于NSFoundation框架中的一些類,蘋果已經為我們提供了現成的等同性判斷的方法。比如NSString提供的判斷兩個字符串對象是否相等的方法- (BOOL)isEqualToString:
。
那么你可能會問:NSString類默認提供了比較字符串等同性的方法,而那些繼承自NSObject基類的自定義類,我們該怎么判斷等同性呢?不用擔心,NSObject類的協議已經默認提供了- (BOOL)isEqual:(id)object;
方法,且NSObject類也遵守并實現了NSObject協議中的isEqual:方法。我們可以通過調用- (BOOL)isEqual:
方法來檢驗兩個NSObject對象的等同性。其實,個人認為,NSString的- (BOOL)isEqualToString:
就是在- (BOOL)isEqual:
基礎之上進行的擴展。因為NSString類繼承自NSObject這個基類,我們也可以使用- (BOOL)isEqual:
方法對兩個字符串進行比較。但是不建議這么做,因為系統已經給我們提供了現成的API,調用- (BOOL)isEqualToString:
比調用- (BOOL)isEqual:
方法快。后者還要執行額外的步驟,因為他不知道受測對象的真實類型。
覆寫NSObject類的- (BOOL)isEqual:方法
NSObject類對- (BOOL)isEqual:的默認實現是:當且僅當被比較的兩個對象的指針值相等時,才被認為相等
。即,isEqual:的默認實現就是對對象本體性的判斷。前面已經說過,但對于自定義類型和集合類型,這種默認的判斷有時候太過苛刻。針對于這種情況,如果有判斷自定義對象等同性的需求,我們需要覆寫- (BOOL)isEqual:
方法。
- (BOOL)isEqual:(id)object{
// 兩個對象指針相等,其指向同一塊內存,則肯定相等。
if (self == object) {
return YES;
}
// 一般來說,如果兩個對象的類型完全不同,則肯定不等。
if ([self class] != [object class]) {
return NO;
}
EOCPerson *otherPerson = (EOCPerson *)object;
// 兩個對象相應的屬性如果不等,則也認為不等(忽略繼承和多態)。
if (![self.firstName isEqualToString:otherPerson.firstName]){
return NO;
}
if (![self.lastName isEqualToString:otherPerson.lastName]){
return NO;
}
if (self.age != otherPerson.age) {
return NO;
}
// 如果屬性也相等,則認為相等。
return YES;
}
上面的EOCPerson類,實現了NSObject協議的- (BOOL)isEqual:方法,首先,直接判斷兩個指針是否相等,若相等則其均指向同一個對象,所以受測對象肯定相等。然后,比較兩個受測對象所屬的類,若不屬于同一個類(忽略多態),則認為兩對象不相等。最后,檢查兩個對象的屬性是否相等,如果對象只要有某個屬性不相等,就認為兩個對象不相等,否則對象相等。
EOCPerson *p1 = [[EOCPerson alloc] init];
p1.firstName = @"VV";
p1.lastName = @"S";
EOCPerson *p2 = [[EOCPerson alloc] init];
p2.firstName = @"VV";
p2.lastName = @"S";
BOOL isEqual = [p1 isEqual:p2];
NSLog(@"isEqual == %d",isEqual); // isEqual == 1
上面我們覆寫EOCPerson類的isEqual:方法時,沒有考慮多態的情況,開發中如果存在繼承,我們還需要對兩個對象的類型進行比較,直接調用NSObject類型查詢方法- (BOOL)isKindOfClass:(Class)aClass;
即可。
- (BOOL)isEqual:(id)object{
// return [super isEqual:object];
// 考慮多態
if (![self isKindOfClass:[object class]] && ![object isKindOfClass:[self class]]) {
return NO;
}
// 兩個對象指針相等,其指向同一塊內存,則肯定相等。
if (self == object) {
return YES;
}
EOCPerson *otherPerson = (EOCPerson *)object;
// 兩個對象相應的屬性如果不等,則也認為不等。
if (![self.firstName isEqualToString:otherPerson.firstName]){
return NO;
}
if (![self.lastName isEqualToString:otherPerson.lastName]){
return NO;
}
if (self.age != otherPerson.age) {
return NO;
}
// 如果屬性也相等,則認為相等。
return YES;
}
Hash方法
Hash一詞經常被譯作“哈?!保袝r候也被譯作“雜湊”“散列”。因此,“hash table”有時候被譯作“哈希表”,也有人稱之為“散列表”。我們只需要知道他們表達的是同一個意思。
1.為什么要有Hash方法
根據約定:如果兩個對象相等,則其哈希值也相等,但是如果兩個哈希值相等,則對象未必相等。這是能否覆寫isEqual:
方法的關鍵。
另外,我們知道,哈希也是會存在碰撞的。即,兩個對象如果相等則哈希值肯定相等。但兩個對象的哈希值如果相等,則這兩個對象也不一定相等。
哈希表是一種數據結構,經常被用來實現set和dictionary。我們知道,set和dictionary都屬于collection(集合)的一種形式。set和dictionary相對于array而言,是其查詢速度是比較快的,事件復雜度僅為O(1)。而對于一個無序的array,查詢某個元素的事件復雜度是O(n)(其中n為數組的長度)。這其中hash就起到了至關重要的作用。
2.Hash方法的默認實現
hash的默認實現是:返回對象的內存地址作為哈希值
。即,NSObject類實現的hash方法,本質上是返回的對象的內存地址。乍一想,這種默認實現是可行的,但是對于一些數據類型是不行的,比如我們自定義的類型。
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
EOCPerson *p1 = [[EOCPerson alloc] init];
p1.firstName = @"VV";
p1.lastName = @"S";
EOCPerson *p2 = [[EOCPerson alloc] init];
p2.firstName = @"VV";
p2.lastName = @"S";
BOOL isEqual = [p1 isEqual:p2];
NSLog(@"isEqual == %d",isEqual);
NSMutableSet *set = [NSMutableSet set];
[set addObject:p1];
[set addObject:p2];
NSLog(@"count == %ld",[set count]); // count == 2
如上,我們沒有覆寫默認的isEqual:方法和默認的hash方法。p1和p2雖然指針不同,但是對象的屬性都是完全相同的。我們把本質上兩個完全相同的對象插入到set這種數據類型中,set應該是可以自動去重的。即,正確的情況下set應該只有一個對象。但是因為hash方法默認返回指針地址作為哈希值,導致set中出現了兩個本質上完全相同的對象,這完全違背了set的機制和作用。所以,返回內存地址作為哈希值并不是一個好主意。
上面說過,根據約定:如果兩個對象相等,則其哈希值也相等,但是如果兩個哈希值相等,則對象未必相等。所以我們可以這么實現hash方法:
- (NSUInteger)hash {
return 666;
}
這么寫顯然符合約定,即相等的對象hash值相等,相等hash值的對象未必相等。但是在set中大量使用這種對象將會產生性能問題。因為set在檢索哈希表時,會用對象的哈希值作為索引。set會根據哈希值把對象分組。在向set中添加新對象時,要根據待插入的新對象的哈希值找到與之相關的那個組。然后依次檢查各個元素(調用isEqual:方法),看待插入的對象是否和數組中的某個元素相等,如果相等,那么就說明待添加的對象已經在set中存在。由此可知,如果令每個對象都返回相同的哈希值,那么在set中有1000000個對象的情況下,若是繼續想其中添加對象,則需要將這1000000個對象全部遍歷一遍。這樣一來就喪失了hash值的作用,把set變成了一個活生生的array。
稍微好一點的方法
- (NSUInteger)hash {
NSString *stringToHash = [NSString stringWithFormat:@"%@,%@,%ld",self.firstName,self.lastName,self.age];
return [stringToHash hash];
}
這次所使用的方法是將NSString對象中的屬性拼接成一個新的字符串,然后另該字符串調用hash方法,返回該字符串的哈希值作為這個對象的哈希值。這么做符合約定,因為兩個相等的對象總是會返回相同的哈希值。但是這樣做還需要負擔創建一個新字符串的額外的開銷,所以比返回一個單一值慢。相對而言,把這種對象添加到collection中,也會產生性能問題。
更加優秀的方法
分別計算每個屬性的哈希值,然后對哈希值進行按位異或運算,的出的結果作為對象的哈希值。
- (NSUInteger)hash {
NSInteger firstNameHash = [self.firstName hash];
NSInteger lastNameHash = [self.lastName hash];
NSInteger ageHash = self.age;
return firstNameHash ^ lastNameHash ^ ageHash;
}
對哈希值進行按位異或操作,這種方式既能保持較高的效率,又能使生成的哈希值至少位于一定范圍之內,而不會過于頻繁的重復。當然,此算法的哈希值還是會生成碰撞,不過至少可以保證哈希值有多重可能的取值,編寫hash方法時,應該用當前的對象多做做實驗,以便在減少碰撞頻度與降低運算復雜程度之間取舍。
3.Hash方法調用時機
當把一個對象添加到set時會調用這個對象的hash方法。或者把一個對象作為key添加到dictionary中時,也會調用這個對象的hash方法。因為dictionary在查找某個value時,也是根據key的hash值來提高查詢效率。
當然,如果我們把對象作為value添加到dictionary中,并不會調用對象的hash方法。
注意:
如果一個自定義對象作為dictionary的key,切記要實現NSCopying協議中的- (id)copyWithZone:(nullable NSZone *)zone
方法。如下:
- (id)copyWithZone:(nullable NSZone *)zone {
EOCPerson *copy = [[EOCPerson alloc] init];
copy.lastName = self.lastName;
copy.firstName = self.firstName;
copy.age = self.age;
return copy;
}
4.Hash方法與isEqual:的關系
拿set為例,為了優化插入效率,當在set中插入某個對象時,首先會調用待插入對象的hash方法,根據返回的hash值查找hash table。如果待插入對象的hash值和set中的對象的hash值都不相等。則認為set中不存在和待插入對象相等的對象,那么就可以把待插入的對象插入到set中。如果set中存在一個對象的hash值和待插入對象的hash值相等,則再調用對象的isEqual:
方法,進行對象的判等,如果經過isEqual:方法返回YES,則認為兩個對象相等,即set中已經存在一個和待插入對象相等的對象,待插入的對象不能插入到set中。否則繼續上面的操作。
isEqual:調用時機
- 當手動調用isEqual:方法,對兩個對象進行顯式的比較時。
- 當把一個對象添加到一個成員count不為0的set中,且待插入的對象的hash值和set中的成員的hash值相等的情況下,才會調用isEqual:方法。即,首先調用hash方法,然后才有可能調用isEqual:方法。
等同性判定的執行深度
創建等同性判定方法時,需啊喲決定是根據某個對象來判斷等同性,還是僅根據其中的某個或者某幾個屬性來判斷,這個取決于業務場景。NSArray的檢測方式為:先看兩個數組所含對象個數是否等,若想等,則在每個對應位置的兩個對象身上調用“isEqual:”方法。如果對應位置上的對象均相等,那么這兩個數組相等,這叫做“深度等同性判定”。不過有時無需將所有數據逐個比較,只根據其中部分數據即可判斷二者是否相同。比如某個Person類總有一個identity字段代表身份證號碼,在不存在臟數據的情況下,完全可以僅憑這個identity字段判斷兩個對象是否是相同的。
不要向set中添加可變的對象
不要向set中添加可變的對象。確切的說,如果向set中添加了可變對象,那么盡量保證這個可變對象不再改變。為什么呢?我們已經了解,set和dictionary是通過哈希值檢索元素的,我們已經說過,set火把各個對象按照其哈希值進行分組,如果某個可變對象在set中被分組后哈希值又改變了,那么這個對象現在所在的組就不再合適了。要想解決這個問題,我們需要確保被添加到set中的對象是不可變的或者確??勺儗ο蟊惶砑拥絪et后就不再改變,或者這個對象的hash值的計算不受可變部分的影響,即,這個對象的hash值不是根據其可變部分計算出來的。
// 重寫isEqual:
- (BOOL)isEqual:(id)object{
// return [super isEqual:object];
// 一般來說,如果兩個對象的類型完全不同,則肯定不等。
if ([self class] != [object class]) {
return NO;
}
// 兩個對象指針相等,其指向同一塊內存,則肯定相等。
if (self == object) {
return YES;
}
EOCPerson *otherPerson = (EOCPerson *)object;
// 兩個對象相應的屬性如果不等,則也認為不等。
if (![self.firstName isEqualToString:otherPerson.firstName]){
return NO;
}
if (![self.lastName isEqualToString:otherPerson.lastName]){
return NO;
}
if (self.age != otherPerson.age) {
return NO;
}
// 如果屬性也相等,則認為相等。
return YES;
}
// 重寫hash
- (NSUInteger)hash {
NSLog(@"%s",__func__);
NSInteger firstNameHash = [self.firstName hash];
NSInteger lastNameHash = [self.lastName hash];
NSInteger ageHash = self.age;
return firstNameHash ^ lastNameHash ^ ageHash;
}
// 調用
EOCPerson *p1 = [[EOCPerson alloc] init];
p1.firstName = @"VV";
p1.lastName = @"S";
EOCPerson *p2 = [[EOCPerson alloc] init];
p2.firstName = @"VV";
p2.lastName = @"S";
NSMutableSet *setM = [NSMutableSet set];
[setM addObject:p1];
[setM addObject:p2];
NSLog(@"set count == %ld",[setM count]); // set count == 1
上面我們看到,向set中添加兩個相同的對象,firstName和lastName值完全相同,打印set中元素的個數,其打印結果為1。這樣完全符合set能夠去重的功能。但是,如果我們繼續添加一個不同于p1和p2的p3對象,然后改變p3的各個屬性和p1相同,再觀察set count,如下:
EOCPerson *p1 = [[EOCPerson alloc] init];
p1.firstName = @"VV";
p1.lastName = @"S";
EOCPerson *p2 = [[EOCPerson alloc] init];
p2.firstName = @"VV";
p2.lastName = @"S";
NSMutableSet *setM = [NSMutableSet set];
[setM addObject:p1];
[setM addObject:p2];
NSLog(@"set count == %ld",[setM count]); // set count == 1
EOCPerson *p3 = [[EOCPerson alloc] init];
p3.firstName = @"VV";
[setM addObject:p3];
NSLog(@"set count == %ld",[setM count]); // set count == 2
p3.lastName = @"S";
NSLog(@"set count == %ld",[setM count]); // set count == 2
我們看到,上面給setM對象添加了一個p3后,其count == 2,這樣是ok的。但是把p3的lastName改為和p1的lastName相同時,set count 仍然為2。此時set中竟然出現了兩個完全相同的對象!這完全違背了set的本意,因為set的作用就是去重,根據set的語義,set中是不會也不應該出現了兩個完全相同的對象。
如果把這個setM對象在拷貝一下,情況更糟了:
NSSet *s = [setM copy];
NSLog(@"set count == %ld",[s count]); // set count == 1
你會發現,s對象雖然是setM的副本,但是s.count卻是1。此s對象看上去像是由一個空set開始,通過把setM中的對象添加到s中而創建出來的。無論如何,這樣做已經存在了很大的風險,這可能給我們的程序調試帶來無法想象的難度。
舉這個例子是想說明:把某個對象放入set這種集合對象中,就不宜改變其內容。
總結
- 把某個對象添加到set中時,都會調用這個對象的hash方法計算hash值。
- 把一個對象添加到set中時,如果set中不存在任何元素,這個對象會被直接添加到set中。相反。如果set中存在元素,那么待添加的對象的hash值會和set中的每個元素的hash值進行比較。如果不等,會繼續和set中的下一個元素比較hash值,直到待添加的對象的hash值和set中所有元素的hash值比較完畢為止。
- 如果set中存在一個元素的hash值和待添加的對象的hash值相等,那么待插入的對象會調用自己的isEqual:方法,以set中的元素為參數,進行比較,如果isEqual:返回YES,證明這兩個對象相同,那么待插入的對象不會插入到set中。如果isEqual:返回NO,證明這兩個對象不同,對象可以插入到set中。
- hash的默認實現是返回對象的指針地址。isEqual:的默認實現是比較兩個對象的指針地址。
- 相同的對象必須具有相同的hash值,但是兩個hash值相等的對象未必相同。
- 若想檢測對象的等同性,需要提供“isEqual:”與hash方法。
- 根據實際也需重寫isEqual:方法,不要盲目檢查每條屬性,而是應該按照具體需求來指定檢查方案。
- 最好不要把可變對象添加到set中,最好也請不要改變set中某個元素,否則容易產生想象不到的錯誤,也會增加調試的難度。
- hash方法應該使用計算速度快而且哈希值碰撞幾率低的算法。一般情況下,建議使用按位異或操作。
最后,借用大神“Mattt Thompson”的話:
經過這么多的解釋,希望我們在這個有些詭譎的話題上取得了”相同“的認識。 作為人類,我們很努力地去理解和實現平等,在我們的社會中,在自然生態環境中,在立法和執法中,在選舉我們領導人的過程中,在人類作為一個物種互相溝通延續我們的存在這一共識中。愿我們能繼續這個奮斗的過程,最終達到理想的彼岸,在那里,評價一個人的標準是他的人格,就像我們判斷一個變量是通過它的內存地址一樣
。
文/VV木公子(簡書作者)
PS:如非特別說明,所有文章均為原創作品,著作權歸作者所有,轉載請聯系作者獲得授權,并注明出處,所有打賞均歸本人所有!
如果您是iOS開發者,或者對本篇文章感興趣,請關注本人,后續會更新更多相關文章!敬請期待!
參考文章
iOS開發 之 不要告訴我你真的懂isEqual與hash!
Equality(翻譯)
Equality(英文)
isEqual & hash