第二章 對象、消息、運行期—第8條:理解"對象等同性"這一概念

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

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類實現了一個自己獨有的等同性判斷方法,名叫"isEqualToString:"。傳遞給該方法的對象必須是NSString,否則結果未定義(undefined)。調用該方法比調用"isEqual:"方法快,后者還要執行額外的步驟,因為它不知道受測對象的類型。
NSObject協議中有兩個用于判斷等同性的關鍵方法:

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

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

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

我們認為,如果兩個EOCPerson的所有字段均相等,那么這兩個對象就相等。于是,"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;
}

首先,直接判斷兩個指針是否相等。若相等,則其均指向同一對象,所以受測的對象也必定相等。接下來,比較兩對象所屬的類。若不屬于同一個類,則兩對象不相等。EOCPerson對象當然不可能與EOCDog對象相等。不過,有時我們可能認為:一個EOCPerson實例可以與其子類(比如EOCSmithPerson)實例相等。在繼承體系(inheritance hierarchy)中判斷等同性時,經常遭遇此類問題。所以實現"isEqual:"方法要考慮到這種情況。最后,檢測每個屬性是否相等。只要其中有不相等的屬性,就判定兩對象不等,否則兩對象相等。
接下來該實現hash方法了。回想一下,根據等同性約定:若兩對象相等,則其哈希碼(hash)也相等,但是兩個哈希碼相同的對象卻未必相等。這是能否正確覆寫"isEqual:"方法的關鍵所在。下面這種寫法完全可行:

- (NSUInteger)hash
{
    return 1337;
}

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

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

這次所用的辦法是將NSString對象中的屬性都塞入另一個字符串中,然后令hash方法返回該字符串的哈希碼。這么做符合約定,因為兩個相等的EOCPerson對象總會返回相同的哈希碼。但是這樣做還需負擔創建字符串的開銷,所以比返回單一值要慢。把這種對象添加到collection中時,也會產生性能問題,因為要想添加,必須先計算其哈希值。
再來看最后一種計算哈希碼的辦法:

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

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

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

- (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];
      }
}

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

@property NSUInteger identifier;

在這種情況下,我們也許只會根據標識符來判斷等同性,尤其是在此屬性聲明為readonly時更應該如此。因為只要兩者標識符相同,就肯定表示同一個對象,因而必然相等。這樣的話,無須逐個比較EOCPerson對象的每條數據,只要標識符相同,就說明這兩個對象就是由同一個數據源所創建的,據此我們能夠判定,其余數據也必然相同。
是否需要在等同性判定方法中檢測全部字段取決于受測對象。只有類的編寫者才可以確定兩個對象實例在何種情況下應判定為相等。

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

NSMutableSet *set = [NSMutableSet new];

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

現在set里含有一個數組對象,數組中包含兩個對象。再向set中加入一個數組,此數組與前一個數組所含的對象相同,順序也相同,于是,待加入的數組與set中已有的數組是相等的:

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

此時set里仍然只有一個對象:因為剛才要加入的那個數組對象和set中已有的數組對象相等,所以set并不會改變。這次我們來添加一個和set中已有對象不同的數組:

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

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

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

set中居然可以包含兩個彼此相等的數組!根據set的語義是不允許出現這種情況的,然而現在卻無法保證這一點了,因為我們修改了set中已有的對象。若是拷貝此set,那就更糟糕了:

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

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容