Objective-C中的hash方法

簡介

Objective-C中峭状,經(jīng)常會有需要重寫- (BOOL)isEuqal:(id)other方法的情況。但是很少有人重寫- (NSUInteger)hash方法劝赔。本文就詳細解釋一下hash方法的用處和不重寫可能出現(xiàn)的問題胆敞。

哈希表

Objective-C中着帽,NSDictionaryNSSet是由哈希表實現(xiàn)的。

在討論哈希表之前移层,先規(guī)范幾個接下來會用到的概念仍翰。哈希表的本質(zhì)是一個數(shù)組,數(shù)組中每一個元素稱為一個箱子(bin)观话,箱子中存放的是需要存儲的對象予借,比如字典中就是鍵值對,集合中就是要放入集合的對象匪燕。

哈希表的存儲過程如下:

根據(jù) key 計算出它的哈希值 h蕾羊。
假設(shè)箱子的個數(shù)為 n,那么這個鍵值對應(yīng)該放在第 (h % n) 個箱子中帽驯。
如果該箱子中已經(jīng)有了鍵值對,就使用開放尋址法或者拉鏈法解決沖突利凑。
在使用拉鏈法解決哈希沖突時牌借,每個箱子其實是一個鏈表适荣,屬于同一個箱子的所有鍵值對都會排列在鏈表中够吩。

哈希表還有一個重要的屬性: 負載因子(load factor),它用來衡量哈希表的 空/滿 程度湾笛,一定程度上也可以體現(xiàn)查詢的效率,計算公式為:

負載因子 = 總鍵值對數(shù) / 箱子個數(shù)
負載因子越大,意味著哈希表越滿,越容易導(dǎo)致沖突,性能也就越低路捧。因此,一般來說,當負載因子大于某個常數(shù)(可能是 1零渐,或者 0.75 等)時,哈希表將自動擴容。

重寫hash函數(shù)

Objective-C中杀糯,NSObject的默認hash方法實現(xiàn)為:

- (NSUInteger)hash {
    return (NSUInteger)self;
}

在實現(xiàn)一個hash函數(shù)的時候固翰,需要技巧的一點是,找出哪個值對于對象來說是關(guān)鍵的。

對于一個 NSDate 對象來說太示,從一個參考日期到它本身的時間間隔就已經(jīng)足夠了:

@implementation NSDate (Approximate)
- (NSUInteger)hash {
  return (NSUInteger)abs([self timeIntervalSinceReferenceDate]);
}

對于一個 UIColor 對象邻吭,RGB 元素的移位和可以很方便地計算出來:

@implementation UIColor (Approximate)
- (NSUInteger)hash {
  CGFloat red, green, blue;
  [self getRed:&red green:&green blue:&blue alpha:nil];
  return ((NSUInteger)(red * 255) << 16) + ((NSUInteger)(green * 255) << 8) + (NSUInteger)(blue * 255);
}
@end

綜合上面所說的內(nèi)容,下面是一個在子類中重載默認相等性檢查時可能的實現(xiàn):

@interface Person
@property NSString *firstName;
@property NSString *lastName;
@property NSDate *birthday;

- (BOOL)isEqualToPerson:(Person *)person;
@end

@implementation Person

- (BOOL)isEqualToPerson:(Person *)person {
  if (!person) {
    return NO;
  }

  // ||操作符的操作看起來好像是不必要的,但是如果我們需要處理兩個屬性都是 nil 的情形的話幢尚,它能夠正確地返回 YES。比較像 NSUInteger 這樣的標量是否相等時,則只需要使用 == 就可以了。
  BOOL firstNameIsEqual = (self.firstName == person.firstName || [self.firstName isEqual:person.firstName]);
  BOOL lastNameIsEqual = (self.lastName == person.lastName || [self.lastName isEqual:person.lastName]);
  BOOL haveEqualBirthdays = (self.birthday == person.birthday) || [self.birthday isEqualToDate:person.birthday];

  return firstNameIsEqual && lastNameIsEqual && haveEqualBirthdays;
}

#pragma mark - NSObject

- (BOOL)isEqual:(id)object {
  if (self == object) {
    return YES;
  }

  if (![object isKindOfClass:[Person class]]) {
    return NO;
  }

  return [self isEqualToPerson:(Person *)object];
}

- (NSUInteger)hash {
  return [self.firstName hash] ^ [self.lastName hash] ^ [self.birthday hash];
}

上面的例子中,有一個小問題,因為 ^ 操作是有對稱性的, 即A^B == B^A胳搞,所以如果兩個生日相同的人姑原,一個叫"George Frederick"阴绢,另一個叫"Frederick George"左电,則他們的hash值一樣闰蚕。

為了避免這種情況,我們需要手動打破這種對稱性,比如旋轉(zhuǎn)移位操作。

#define NSUINT_BIT (CHAR_BIT * sizeof(NSUInteger))
#define NSUINTROTATE(val, howmuch) ((((NSUInteger)val) << howmuch) | (((NSUInteger)val) >> (NSUINT_BIT - howmuch)))
- (NSUInteger)hash
{
    //這里實際上不需要旋轉(zhuǎn)lastName,這里只是演示多個屬性時怎么處理。同理如果還有一個NSUInteger的屬性,那么兩個NSUInteger的hash值也需要被旋轉(zhuǎn)
    return NSUINTROTATE([_firstName hash], NSUINT_BIT / 2) ^ NSUINTROTATE([_lastName hash], NSUINT_BIT / 3) ^ [self.birthday hash];
}

在實現(xiàn)一個hash函數(shù)的時候,一個很常見的誤解來源于認為 hash 得到的值 必須 是唯一可區(qū)分的。實際上娜亿,對于關(guān)鍵屬性的散列值進行一個簡單的XOR操作吼畏,就能夠滿足在 99% 的情況下的需求了躲舌。

何時需要重寫hash

Objective-C中诀拭,重寫了isEqual:方法,一般來說不需要重寫hash方法,但是如果這個對象需要被用作key在字典中存儲時沽瘦,就需要重寫。

現(xiàn)在有PeoplePeople2兩個類,代碼如下:

@interface People : NSObject <NSCopying>
@property (nonatomic,copy) NSString *firstName;
@property (nonatomic,copy) NSString *lastName;
@property (nonatomic,assign) NSInteger age;
@end

@implementation People

- (id)copyWithZone:(nullable NSZone *)zone {
    return self;
}

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

- (BOOL)isEqualToPeople:(People *)other {
    BOOL firstNameIsEqual = (self.firstName == other.firstName || [self.firstName isEqual:other.firstName]);
    BOOL lastNameIsEqual = (self.lastName == other.lastName || [self.lastName isEqual:other.lastName]);
    BOOL ageIsEqual = (self.age == other.age);
    return firstNameIsEqual && lastNameIsEqual && ageIsEqual;
}

#define NSUINT_BIT (CHAR_BIT * sizeof(NSUInteger))
#define NSUINTROTATE(val, howmuch) ((((NSUInteger)val) << howmuch) | (((NSUInteger)val) >> (NSUINT_BIT - howmuch)))
- (NSUInteger)hash
{
    //這里實際上不需要旋轉(zhuǎn)lastName,這里只是演示多個屬性時怎么處理棚潦。同理如果還有一個NSUInteger的屬性荚孵,那么兩個NSUInteger的hash值也最好旋轉(zhuǎn)
    return NSUINTROTATE([_firstName hash], NSUINT_BIT / 2) ^ NSUINTROTATE([_lastName hash], NSUINT_BIT / 3) ^ (NSUInteger)self.age;
}

@end

People2類和People類一模一樣谒麦,但是沒有重新hash方法√В現(xiàn)在我們以People類的實例來作為key,在字典中存儲一對鍵值對刁赦。代碼如下:

printf("\n---------------- Person類 ----------------\n");
{
    People *p1 = [[People alloc] init];
    p1.firstName = @"lucy";
    p1.lastName = @"Green";
    
    People *p2 = [[People alloc] init];
    p2.firstName = @"lucy";
    p2.lastName = @"Green";
    
    
    NSLog(@"p1 = %p, p2 = %p, (p1和p2%@)", p1, p2, [p1 isEqual:p2] ? @"相等" : @"不相等");
    
    BOOL hashEqual = ([p1 hash] == [p2 hash]);
    NSLog(@"p1.hash = %ld, p2.hash = %ld, p1.hash %@ p2.hash", [p1 hash], [p2 hash], hashEqual?@"==":@"!=");
    
    NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:@{p1:@"value1"}];
    NSLog(@"用p2作為key取值 %@",[dict objectForKey:p2]);
}


printf("\n---------------- Person2類 ----------------\n");
{
    People2 *p1 = [[People2 alloc] init];
    p1.firstName = @"lucy";
    p1.lastName = @"Green";
    
    People2 *p2 = [[People2 alloc] init];
    p2.firstName = @"lucy";
    p2.lastName = @"Green";
    
    NSLog(@"p1 = %p, p2 = %p, (p1和p2%@) ", p1, p2, [p1 isEqual:p2] ? @"相等" : @"不相等");
    
    BOOL hashEqual = ([p1 hash] == [p2 hash]);
    NSLog(@"p1.hash = %ld, p2.hash = %ld, p1.hash %@ p2.hash", [p1 hash], [p2 hash], hashEqual?@"==":@"!=");
    
    NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithDictionary:@{p1:@"value1"}];
    NSLog(@"用p2作為key取值 %@",[dict objectForKey:p2]);
}

運行上面的代碼,打印結(jié)果揉阎,多打印幾次,這里我列出其中兩次的log情況:

---------------- Person類 ----------------
2017-09-20 17:34:46.820 NSAD[1308:39307] p1 = 0x610000037500, p2 = 0x610000036e60, (p1和p2相等)
2017-09-20 17:34:46.820 NSAD[1308:39307] p1.hash = 2714281438307418121, p2.hash = 2714281438307418121, p1.hash == p2.hash
2017-09-20 17:34:46.820 NSAD[1308:39307] 用p2作為key取值 value1

---------------- Person2類 ----------------
2017-09-20 17:34:46.820 NSAD[1308:39307] p1 = 0x608000035780, p2 = 0x6080000357e0, (p1和p2相等)
2017-09-20 17:34:46.821 NSAD[1308:39307] p1.hash = 106102872299392, p2.hash = 106102872299488, p1.hash != p2.hash
2017-09-20 17:34:46.821 NSAD[1308:39307] 用p2作為key取值 value1
---------------- Person類 ----------------
2017-09-20 17:45:44.665 NSAD[1359:43287] p1 = 0x608000221c00, p2 = 0x608000221ca0, (p1和p2相等)
2017-09-20 17:45:44.666 NSAD[1359:43287] p1.hash = 2714281438307418121, p2.hash = 2714281438307418121, p1.hash == p2.hash
2017-09-20 17:45:44.666 NSAD[1359:43287] 用p2作為key取值 value1

---------------- Person2類 ----------------
2017-09-20 17:45:44.666 NSAD[1359:43287] p1 = 0x600000223c00, p2 = 0x600000223c20, (p1和p2相等)
2017-09-20 17:45:44.667 NSAD[1359:43287] p1.hash = 105553118510080, p2.hash = 105553118510112, p1.hash != p2.hash
2017-09-20 17:45:44.667 NSAD[1359:43287] 用p2作為key取值 (null)

可以看到站粟,兩次log的結(jié)果修械,Person2類的行為不同,第一次可以使用實例p2讀出keyp1value柬唯,第二次不行。

思考:
為什么Person2類代碼沒有改動涂屁,但是運行結(jié)果會變,第一次可以,第二次不行?

解答:
Person2類的hash方法沒有重寫胞谭,所以p1.hash != p2.hash宛渐。

p1作為key存儲字典時幔荒,此時會根據(jù)字典的當前“箱子個數(shù)”n糊闽,做 p1.hash % n 操作,算出應(yīng)該存放在第幾個“箱子”爹梁。

再以p2key取值的時候右犹, 同樣會 p2.hash % n 算要去第幾個“箱子”獲取。找到對應(yīng)的箱子后姚垃,再使用isEqual:方法比較key念链,找到對應(yīng)的value

因為p1 isEqual: p2,即使 p1.hash != p2.hash, 但是當 (p1.hash % n) == (p2.hash % n) 時掂墓,一樣可以用p2作為key去對字典進行操作谦纱,例如結(jié)果1。 但是如果求余結(jié)果不等君编,則找不到跨嘉,此時就會出現(xiàn)結(jié)果2。

因為默認的hash方法是直接返回的對象的地址吃嘿,也就是說p1p2hash值是不可控的祠乃,所以上面的代碼,Person2類的行為是未定義的兑燥。

結(jié)論

綜上所述亮瓷,如果我們重寫了isEqual:方法,大部分情況寫可以不管hash方法降瞳,但是當我們需要把這個類的對象加入一張哈希表中的時候嘱支,我們一定要重新hash方法。

最后在總結(jié)一下equalhash的關(guān)系挣饥。

  • 對象相等具有 交換性 ([a isEqual:b] ? [b isEqual:a])
  • 如果兩個對象相等除师,它們的 hash 值也一定是相等的 ([a isEqual:b] ? [a hash] == [b hash])
  • 反過來則不然,兩個對象的散列值相等不一定意味著它們就是相等的 ([a hash] == [b hash] ?? [a isEqual:b])

本文代碼可以在Github上我的demo中找到亮靴。

參考資料:

Equality - NSHipster
Implementing Equality and Hashing
深入理解哈希表

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末馍盟,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子茧吊,更是在濱河造成了極大的恐慌贞岭,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,820評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件搓侄,死亡現(xiàn)場離奇詭異瞄桨,居然都是意外死亡,警方通過查閱死者的電腦和手機讶踪,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,648評論 3 399
  • 文/潘曉璐 我一進店門芯侥,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人乳讥,你說我怎么就攤上這事柱查。” “怎么了云石?”我有些...
    開封第一講書人閱讀 168,324評論 0 360
  • 文/不壞的土叔 我叫張陵唉工,是天一觀的道長。 經(jīng)常有香客問我汹忠,道長淋硝,這世上最難降的妖魔是什么雹熬? 我笑而不...
    開封第一講書人閱讀 59,714評論 1 297
  • 正文 為了忘掉前任,我火速辦了婚禮谣膳,結(jié)果婚禮上竿报,老公的妹妹穿的比我還像新娘。我一直安慰自己继谚,他們只是感情好烈菌,可當我...
    茶點故事閱讀 68,724評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著花履,像睡著了一般僧界。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上臭挽,一...
    開封第一講書人閱讀 52,328評論 1 310
  • 那天,我揣著相機與錄音咬腕,去河邊找鬼欢峰。 笑死,一個胖子當著我的面吹牛涨共,可吹牛的內(nèi)容都是我干的纽帖。 我是一名探鬼主播,決...
    沈念sama閱讀 40,897評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼举反,長吁一口氣:“原來是場噩夢啊……” “哼懊直!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起火鼻,我...
    開封第一講書人閱讀 39,804評論 0 276
  • 序言:老撾萬榮一對情侶失蹤室囊,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后魁索,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體融撞,經(jīng)...
    沈念sama閱讀 46,345評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,431評論 3 340
  • 正文 我和宋清朗相戀三年粗蔚,在試婚紗的時候發(fā)現(xiàn)自己被綠了尝偎。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,561評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡鹏控,死狀恐怖致扯,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情当辐,我是刑警寧澤抖僵,帶...
    沈念sama閱讀 36,238評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站瀑构,受9級特大地震影響裆针,放射性物質(zhì)發(fā)生泄漏刨摩。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,928評論 3 334
  • 文/蒙蒙 一世吨、第九天 我趴在偏房一處隱蔽的房頂上張望澡刹。 院中可真熱鬧,春花似錦耘婚、人聲如沸罢浇。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,417評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽嚷闭。三九已至,卻和暖如春赖临,著一層夾襖步出監(jiān)牢的瞬間胞锰,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,528評論 1 272
  • 我被黑心中介騙來泰國打工兢榨, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留嗅榕,地道東北人。 一個月前我還...
    沈念sama閱讀 48,983評論 3 376
  • 正文 我出身青樓吵聪,卻偏偏與公主長得像凌那,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子吟逝,可洞房花燭夜當晚...
    茶點故事閱讀 45,573評論 2 359

推薦閱讀更多精彩內(nèi)容

  • 前言 對數(shù)據(jù)的等同性判斷包括對基本數(shù)據(jù)類型等同性的判斷和對象等同性的判斷帽蝶。對基本數(shù)據(jù)類型等同性的判斷是非常簡單的,...
    VV木公子閱讀 1,470評論 0 8
  • 1. Java基礎(chǔ)部分 基礎(chǔ)部分的順序:基本語法块攒,類相關(guān)的語法励稳,內(nèi)部類的語法,繼承相關(guān)的語法局蚀,異常的語法麦锯,線程的語...
    子非魚_t_閱讀 31,664評論 18 399
  • 1尋找潛在受眾的心智模式 印象是很難在短時間變化的扶欣,不要試圖改變受眾的心智模式,分析影響心智模式的內(nèi)外因千扶,占領(lǐng)用戶...
    懷才閱讀 180評論 0 0
  • 晨起感恩 感恩女兒前不久送給我羊毛靴料祠,昨天穿上它溫暖至極,感恩侄女送我暖寶寶貼澎羞,讓我的身體更加溫暖髓绽,感恩侄女幫我照...
    安麗娜閱讀 110評論 0 0
  • 鴛久閱讀 172評論 0 0