Objective-C中的hash方法

简介

Objective-C中,经常会有需要重写- (BOOL)isEuqal:(id)other方法的情况。但是很少有人重写- (NSUInteger)hash方法。本文就详细解释一下hash方法的用处和不重写可能出现的问题。

哈希表

Objective-C中,NSDictionaryNSSet是由哈希表实现的。

在讨论哈希表之前,先规范几个接下来会用到的概念。哈希表的本质是一个数组,数组中每一个元素称为一个箱子(bin),箱子中存放的是需要存储的对象,比如字典中就是键值对,集合中就是要放入集合的对象。

哈希表的存储过程如下:

根据 key 计算出它的哈希值 h。
假设箱子的个数为 n,那么这个键值对应该放在第 (h % n) 个箱子中。
如果该箱子中已经有了键值对,就使用开放寻址法或者拉链法解决冲突。
在使用拉链法解决哈希冲突时,每个箱子其实是一个链表,属于同一个箱子的所有键值对都会排列在链表中。

哈希表还有一个重要的属性: 负载因子(load factor),它用来衡量哈希表的 空/满 程度,一定程度上也可以体现查询的效率,计算公式为:

负载因子 = 总键值对数 / 箱子个数
负载因子越大,意味着哈希表越满,越容易导致冲突,性能也就越低。因此,一般来说,当负载因子大于某个常数(可能是 1,或者 0.75 等)时,哈希表将自动扩容。

重写hash函数

Objective-C中,NSObject的默认hash方法实现为:

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

在实现一个hash函数的时候,需要技巧的一点是,找出哪个值对于对象来说是关键的。

对于一个 NSDate 对象来说,从一个参考日期到它本身的时间间隔就已经足够了:

@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

综合上面所说的内容,下面是一个在子类中重载默认相等性检查时可能的实现:

@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值一样。

为了避免这种情况,我们需要手动打破这种对称性,比如旋转移位操作。

#define NSUINT_BIT (CHAR_BIT * sizeof(NSUInteger))
#define NSUINTROTATE(val, howmuch) ((((NSUInteger)val) << howmuch) | (((NSUInteger)val) >> (NSUINT_BIT - howmuch)))
- (NSUInteger)hash
{
    //这里实际上不需要旋转lastName,这里只是演示多个属性时怎么处理。同理如果还有一个NSUInteger的属性,那么两个NSUInteger的hash值也需要被旋转
    return NSUINTROTATE([_firstName hash], NSUINT_BIT / 2) ^ NSUINTROTATE([_lastName hash], NSUINT_BIT / 3) ^ [self.birthday hash];
}

在实现一个hash函数的时候,一个很常见的误解来源于认为 hash 得到的值 必须 是唯一可区分的。实际上,对于关键属性的散列值进行一个简单的XOR操作,就能够满足在 99% 的情况下的需求了。

何时需要重写hash

Objective-C中,重写了isEqual:方法,一般来说不需要重写hash方法,但是如果这个对象需要被用作key在字典中存储时,就需要重写。

现在有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
{
    //这里实际上不需要旋转lastName,这里只是演示多个属性时怎么处理。同理如果还有一个NSUInteger的属性,那么两个NSUInteger的hash值也最好旋转
    return NSUINTROTATE([_firstName hash], NSUINT_BIT / 2) ^ NSUINTROTATE([_lastName hash], NSUINT_BIT / 3) ^ (NSUInteger)self.age;
}

@end

People2类和People类一模一样,但是没有重新hash方法。现在我们以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]);
}

运行上面的代码,打印结果,多打印几次,这里我列出其中两次的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的结果,Person2类的行为不同,第一次可以使用实例p2读出keyp1value,第二次不行。

思考:
为什么Person2类代码没有改动,但是运行结果会变,第一次可以,第二次不行?

解答:
Person2类的hash方法没有重写,所以p1.hash != p2.hash

p1作为key存储字典时,此时会根据字典的当前“箱子个数”n,做 p1.hash % n 操作,算出应该存放在第几个“箱子”。

再以p2key取值的时候, 同样会 p2.hash % n 算要去第几个“箱子”获取。找到对应的箱子后,再使用isEqual:方法比较key,找到对应的value

因为p1 isEqual: p2,即使 p1.hash != p2.hash, 但是当 (p1.hash % n) == (p2.hash % n) 时,一样可以用p2作为key去对字典进行操作,例如结果1。 但是如果求余结果不等,则找不到,此时就会出现结果2。

因为默认的hash方法是直接返回的对象的地址,也就是说p1p2hash值是不可控的,所以上面的代码,Person2类的行为是未定义的。

结论

综上所述,如果我们重写了isEqual:方法,大部分情况写可以不管hash方法,但是当我们需要把这个类的对象加入一张哈希表中的时候,我们一定要重新hash方法。

最后在总结一下equalhash的关系。

  • 对象相等具有 交换性 ([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
深入理解哈希表

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,843评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,538评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,187评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,264评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,289评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,231评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,116评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,945评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,367评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,581评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,754评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,458评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,068评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,692评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,842评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,797评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,654评论 2 354

推荐阅读更多精彩内容

  • 前言 对数据的等同性判断包括对基本数据类型等同性的判断和对象等同性的判断。对基本数据类型等同性的判断是非常简单的,...
    VV木公子阅读 1,461评论 0 8
  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,625评论 18 399
  • 1寻找潜在受众的心智模式 印象是很难在短时间变化的,不要试图改变受众的心智模式,分析影响心智模式的内外因,占领用户...
    怀才阅读 180评论 0 0
  • 晨起感恩 感恩女儿前不久送给我羊毛靴,昨天穿上它温暖至极,感恩侄女送我暖宝宝贴,让我的身体更加温暖,感恩侄女帮我照...
    安丽娜阅读 105评论 0 0
  • 鸳久阅读 168评论 0 0