第二章 對(duì)象、消息、運(yùn)行期—第8條:理解"對(duì)象等同性"這一概念

根據(jù)"等同性"(equality)來(lái)比較對(duì)象是一個(gè)非常有用的功能。不過,按照==操作符比較出來(lái)的結(jié)果未必是我們想要的,因?yàn)樵摬僮鞅容^的是兩個(gè)指針本身,而不是其所指的對(duì)象。應(yīng)該使用NSObject協(xié)議中聲明的"isEqual":方法來(lái)判斷兩個(gè)對(duì)象的等同性。一般來(lái)說(shuō),兩個(gè)類型不同的對(duì)象總是不相等的(unequal)。某些對(duì)象提供了特殊的"等同性判定方法"(equality-checking method),如果已經(jīng)知道兩個(gè)受測(cè)對(duì)象都屬于同一個(gè)類,那么就可以使用這種方法。以下述代碼為例:

NSString *foo = @"Badger 123";
NSString *bar = [NSString stringWithFormat:@"Badger %i", 123];
BOOL equalA = (foo == bar);//<equalA = NO
BOOL equalB = [foo isEqual:bar];//<equalB = YES
BOOL equalC = [foo isEqualToString:bar];//<equalC = YES

可以看到==與等同性判斷方法之間的差別。NSString類實(shí)現(xiàn)了一個(gè)自己獨(dú)有的等同性判斷方法,名叫"isEqualToString:"。傳遞給該方法的對(duì)象必須是NSString,否則結(jié)果未定義(undefined)。調(diào)用該方法比調(diào)用"isEqual:"方法快,后者還要執(zhí)行額外的步驟,因?yàn)樗恢朗軠y(cè)對(duì)象的類型。
NSObject協(xié)議中有兩個(gè)用于判斷等同性的關(guān)鍵方法:

- (BOOL)isEqual:(id)object;
- (NSUInteger)hash;

NSObject類對(duì)這兩個(gè)方法的默認(rèn)實(shí)現(xiàn)是:當(dāng)且僅當(dāng)其"指針值"(pointer value)完全相等時(shí),這兩個(gè)對(duì)象才相等。若想在自定義的對(duì)象中正確覆寫這些方法,就必須先理解其約定(contract)。如果"isEqual:"方法判定兩個(gè)對(duì)象相等,那么其hash方法也必須返回同一個(gè)值。但是,如果兩個(gè)對(duì)象的hash方法返回同一個(gè)值,那么"isEqual:"方法未必會(huì)認(rèn)為兩者相等。
比如有下面這個(gè)類:

@interface EOCPerson : NSObject
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@property (nonatomic, assign) NSUInteger age;
@end

我們認(rèn)為,如果兩個(gè)EOCPerson的所有字段均相等,那么這兩個(gè)對(duì)象就相等。于是,"isEqual:"方法可以寫成:

- (BOOL)isEqual:(id)object {
      if (self == object) return YES;
      if ([self class] != [object class]) return NO;

      EOCPerson *otherPerson = (EOCPerson *)object;
      if (![_firstName isEqualToString: otherPerson.firstName])
            return NO;
      if (![_lastName isEqualToString: otherPerson.lastName])
            return NO;
      if (_age != otherPerson.age)
            return NO;
      return YES;
}

首先,直接判斷兩個(gè)指針是否相等。若相等,則其均指向同一對(duì)象,所以受測(cè)的對(duì)象也必定相等。接下來(lái),比較兩對(duì)象所屬的類。若不屬于同一個(gè)類,則兩對(duì)象不相等。EOCPerson對(duì)象當(dāng)然不可能與EOCDog對(duì)象相等。不過,有時(shí)我們可能認(rèn)為:一個(gè)EOCPerson實(shí)例可以與其子類(比如EOCSmithPerson)實(shí)例相等。在繼承體系(inheritance hierarchy)中判斷等同性時(shí),經(jīng)常遭遇此類問題。所以實(shí)現(xiàn)"isEqual:"方法要考慮到這種情況。最后,檢測(cè)每個(gè)屬性是否相等。只要其中有不相等的屬性,就判定兩對(duì)象不等,否則兩對(duì)象相等。
接下來(lái)該實(shí)現(xiàn)hash方法了?;叵胍幌拢鶕?jù)等同性約定:若兩對(duì)象相等,則其哈希碼(hash)也相等,但是兩個(gè)哈希碼相同的對(duì)象卻未必相等。這是能否正確覆寫"isEqual:"方法的關(guān)鍵所在。下面這種寫法完全可行:

- (NSUInteger)hash
{
    return 1337;
}

不過若是這么寫的話,在collection中使用這種對(duì)象將產(chǎn)生性能問題,因?yàn)閏ollection在檢索哈希表(hash table)時(shí),會(huì)用對(duì)象的哈希碼做索引。假如某個(gè)collection是用set實(shí)現(xiàn)的,那么set可能會(huì)根據(jù)哈希碼把對(duì)象分裝到不同的數(shù)組中。在向set中添加新對(duì)象時(shí),要根據(jù)其哈希碼找到與之相關(guān)的那個(gè)數(shù)組,依次檢查其中各個(gè)元素,看數(shù)組中已有的對(duì)象是否和將要添加的新對(duì)象相等。如果相等,那就說(shuō)明要添加的對(duì)象已經(jīng)在set里面了。由此可知,如果令每個(gè)對(duì)象都返回相同的哈希碼,那么在set中已有1000000個(gè)對(duì)象的情況下,若是繼續(xù)向其中添加對(duì)象,則需將這1000000個(gè)對(duì)象全部掃描一遍。
hash方法也可以這樣來(lái)實(shí)現(xiàn):

- (NSUInteger)hash
{
      NSString *stringToHash = [NSString stringWithFormat:@"%@: %@: %i", _firstName, _lastName, _age];
      return [stringToHash hash];
}

這次所用的辦法是將NSString對(duì)象中的屬性都塞入另一個(gè)字符串中,然后令hash方法返回該字符串的哈希碼。這么做符合約定,因?yàn)閮蓚€(gè)相等的EOCPerson對(duì)象總會(huì)返回相同的哈希碼。但是這樣做還需負(fù)擔(dān)創(chuàng)建字符串的開銷,所以比返回單一值要慢。把這種對(duì)象添加到collection中時(shí),也會(huì)產(chǎn)生性能問題,因?yàn)橐胩砑?,必須先?jì)算其哈希值。
再來(lái)看最后一種計(jì)算哈希碼的辦法:

- (NSUInteger)hash
{
      NSUInteger firstNameHash = [_firstName hash];
      NSUInteger lastNameHash = [_lastName hash];
      NSUInteger ageHash = [_age hash]; 
      return firstNameHash ^ lastNameHash ^ ageHash;
}

這種做法既能保持較高效率,又能使生成的哈希碼至少位于一定范圍之內(nèi),而不會(huì)過于頻繁地重復(fù)。當(dāng)然,此算法生成的哈希碼還是會(huì)碰撞(collision),不過至少可以保證哈希碼有多種可能的取值。編寫hash方法時(shí),應(yīng)該用當(dāng)前的對(duì)象做做實(shí)驗(yàn),以便在減少碰撞頻度與降低運(yùn)算復(fù)雜程度之間取舍。

特定類所具有的等同性判定方法
除了剛才提到的NSString之外,NSArray與NSDictionary類也具有特殊的等同性判定方法,前者名為"isEqualToArray:",后者名為"isEqualToDictionary:"。如果和其相比較的對(duì)象不是數(shù)組或字典,那么這兩個(gè)方法會(huì)各自拋出異常。由于Objective-C在編譯期不做強(qiáng)制類型檢查(strong type checking),這樣容易不小心傳入類型錯(cuò)誤的對(duì)象,因此開發(fā)者應(yīng)該保證所傳對(duì)象的類型是正確的。
如果經(jīng)常需要判斷等同性,那么可能會(huì)自己來(lái)創(chuàng)建等同性判定方法,因?yàn)闊o(wú)須檢測(cè)參數(shù)類型,所以能大大提升檢測(cè)速度。自己來(lái)編寫判定方法的另一個(gè)原因是,我們想令代碼看上去更美觀、更易讀,此動(dòng)機(jī)與NSString類"isEqualToString:"方法的創(chuàng)建緣由相似,純粹為了裝點(diǎn)門面。使用此種判定方法編出來(lái)的代碼更容易讀懂,而且不用再檢查兩個(gè)受測(cè)對(duì)象的類型了。
在編寫判定方法時(shí),也應(yīng)一并覆寫"isEqual:"方法。后者的常見實(shí)現(xiàn)方式為:如果受測(cè)的參數(shù)與接收該消息的對(duì)象都屬于同一個(gè)類,那么就調(diào)用自己編寫的判定方法,否則就交由超類來(lái)判斷。例如,在EOCPerson類中可以實(shí)現(xiàn)如下兩個(gè)方法:

- (BOOL)isEqualToPerson:(EOCPerson *)otherPerson {
        if (self == object) return YES;
        if (![_first isEqualToString:otherPerson.firstName]) 
                return NO;
        if (![_lastName isEqualToString:otherPerson.lastName]) 
                return NO;
        if (![_age != otherPerson.age]) 
                return NO;
        return YES;
}

- (BOOL)isEqual:(id)object {
      if ([self class] == [object class]) {
            return [self isEqualToPerson:(EOCPerson *)object];
      }else {
          return [super isEqual:object];
      }
}

等同性判定的執(zhí)行深度
創(chuàng)建等同性判定方法時(shí),需要決定是根據(jù)整個(gè)對(duì)象來(lái)判斷等同性,還是僅根據(jù)其中幾個(gè)字段來(lái)判斷。NSArray的檢測(cè)方式為先看兩個(gè)數(shù)組所含對(duì)象個(gè)數(shù)是否相同,若相同,則在每個(gè)對(duì)應(yīng)位置的兩個(gè)對(duì)象身上調(diào)用其"isEqual:"方法。如果對(duì)應(yīng)位置上的對(duì)象均相等,那么這兩個(gè)數(shù)組就相等,這叫做"深度等同性判定"(deep equality)。不過有時(shí)候無(wú)須將所有數(shù)據(jù)逐個(gè)比較,只根據(jù)其中部分?jǐn)?shù)據(jù)即可判明二者是否等同。
比方說(shuō),我們假設(shè)EOCPerson類的實(shí)例是根據(jù)數(shù)據(jù)庫(kù)里的數(shù)據(jù)創(chuàng)建而來(lái),那么其中就可能會(huì)含有另外一個(gè)屬性,此屬性是"唯一標(biāo)識(shí)符"(unique identifier),在數(shù)據(jù)庫(kù)中用作"主鍵"(primary key):

@property NSUInteger identifier;

在這種情況下,我們也許只會(huì)根據(jù)標(biāo)識(shí)符來(lái)判斷等同性,尤其是在此屬性聲明為readonly時(shí)更應(yīng)該如此。因?yàn)橹灰獌烧邩?biāo)識(shí)符相同,就肯定表示同一個(gè)對(duì)象,因而必然相等。這樣的話,無(wú)須逐個(gè)比較EOCPerson對(duì)象的每條數(shù)據(jù),只要標(biāo)識(shí)符相同,就說(shuō)明這兩個(gè)對(duì)象就是由同一個(gè)數(shù)據(jù)源所創(chuàng)建的,據(jù)此我們能夠判定,其余數(shù)據(jù)也必然相同。
是否需要在等同性判定方法中檢測(cè)全部字段取決于受測(cè)對(duì)象。只有類的編寫者才可以確定兩個(gè)對(duì)象實(shí)例在何種情況下應(yīng)判定為相等。

容器中可變類的的等同性
還有一種情況一定要注意,就是在容器中放入可變類對(duì)象的時(shí)候。把某個(gè)對(duì)象放入collection之后,就不應(yīng)再改變其哈希碼了。前面解釋過,collection會(huì)把各個(gè)對(duì)象按照其哈希碼分裝到不同的"箱子數(shù)組"中。如果某對(duì)象在放入"箱子"之后哈希碼又變了,那么其現(xiàn)在所處的這個(gè)箱子對(duì)它來(lái)說(shuō)就是"錯(cuò)誤"的。要想解決這個(gè)問題,需要確保哈希碼不是根據(jù)對(duì)象的"可變部分"(mutable portion)計(jì)算出來(lái)的,或是保證放入collection之后就不再改變對(duì)象內(nèi)容了。筆者將在第18條中解釋為何要將對(duì)象做成"不可變的"(immutable)。這里先舉個(gè)例子,此例能很好地說(shuō)明其中緣由。
用一個(gè)NSMutableSet與幾個(gè)NSMutableArray對(duì)象測(cè)試一下,就能發(fā)現(xiàn)這個(gè)問題了。首先把一個(gè)數(shù)組加入set中:

NSMutableSet *set = [NSMutableSet new];

NSMutableArray *arrayA = [@[@1, @2] mutableCopy];
[set addObject:arrayA];
NSLog(@"set = %@", set);
//Output : set = {((1, 2))}

現(xiàn)在set里含有一個(gè)數(shù)組對(duì)象,數(shù)組中包含兩個(gè)對(duì)象。再向set中加入一個(gè)數(shù)組,此數(shù)組與前一個(gè)數(shù)組所含的對(duì)象相同,順序也相同,于是,待加入的數(shù)組與set中已有的數(shù)組是相等的:

NSMutableArray *arrayB = [@[@1, @2] mutableCopy];
[set addObject:arrayB];
NSLog(@"set = %@", set);
//Output : set = {((1, 2))}

此時(shí)set里仍然只有一個(gè)對(duì)象:因?yàn)閯偛乓尤氲哪莻€(gè)數(shù)組對(duì)象和set中已有的數(shù)組對(duì)象相等,所以set并不會(huì)改變。這次我們來(lái)添加一個(gè)和set中已有對(duì)象不同的數(shù)組:

NSMutableArray *arrayC = [@[@1] mutableCopy];
[set addObject:arrayC];
NSLog(@"set = %@", set);
//Output: set = {((1), (1, 2))}

正如大家所料,由于arrayC與set里已有的對(duì)象不相等,所以現(xiàn)在set里有兩個(gè)數(shù)組了:其中一個(gè)是最早加入的,另一個(gè)是剛才新添加的。最后,我們改變arrayC的內(nèi)容,令其和最早加入set的那個(gè)數(shù)組相等:

[arrayC addObject:@2];
NSLog(@"set = %@", set);
//Output : set = {((1, 2), (1, 2))}

set中居然可以包含兩個(gè)彼此相等的數(shù)組!根據(jù)set的語(yǔ)義是不允許出現(xiàn)這種情況的,然而現(xiàn)在卻無(wú)法保證這一點(diǎn)了,因?yàn)槲覀冃薷牧藄et中已有的對(duì)象。若是拷貝此set,那就更糟糕了:

NSSet *setB = [set copy];
NSLog(@"setB = %@", setB);
//Output: setB = {((1, 2))}

復(fù)制過來(lái)的set又只剩一個(gè)對(duì)象了,此set看上去好像是由一個(gè)空set開始、通過逐個(gè)向其中添加新對(duì)象而創(chuàng)建出來(lái)的。這可能符合你的需求,也可能不符合。有的開發(fā)者也許想要忽略set中的錯(cuò)誤,"照原樣"(verbatim)復(fù)制一個(gè)新的出來(lái),還有的開發(fā)者則會(huì)認(rèn)為這樣做挺合適的。這兩種拷貝算法都說(shuō)的通,于是就進(jìn)一步印證了剛才提到的那個(gè)問題:如果把某對(duì)象放入set之后又修改其內(nèi)容,那么后面的行為將很難預(yù)料。
舉這個(gè)例子是想提醒大家:把某對(duì)象放入collection之后改變其內(nèi)容將會(huì)造成何種后果。筆者并不是說(shuō)絕對(duì)不能這么做,而是說(shuō)如果真要這么做,那就得注意其隱患,并用相應(yīng)的代碼處理可能發(fā)生的問題。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容