为了是文章的结构比较清晰,先理出文章的整体目录如下:
为什么会有isEqual和hash方法
isEqual顾名思义是用来判断两个对象是否相等的。那"= =" 和isEqual的区别在哪里呢?==其实是值的判等。这里分两种情况:
和isEqual配套的另一个方法hash也经常被提起,官方文档甚至规定isEqual和hash必须被同时实现。学习过hash表之后,我们知道如果两个对象业务上相等,那么他们的hash值一定是相等的,hash方法的用处还是在于判断相等性,系统默认的hash方法实际上返回的就是对象的内存地址。问题是我们已经有isEqual方法来判断相等性了,为什么还需要一个hash呢?
答案是hash可以更加高效快速的判断一个对象是否存在集合当中。在NSArray当中我们需要遍历Array,调用N次isEqual才能知道对象是否存在集合当中,时间复杂度是O(N)。在调用isEqual之前,可以通过调用hash来判断是否相等,如果hash值不等就没有进一步调用isEqual的必要了,如果相等必须再调用一次isEqual来确认是否真正相等。但是hash为什么会比isEqual的效率要高呢?
hash方法的返回值是一个NSUInteger,这个值往往和对象在内存当中的存储位置直接相关,也就是说我们可以通过这个值以O(1)的复杂度快速读取到某个对象来判断相等性,和Array O(N)的复杂度相比快了太多了,Array显然不具备这种特性,Array当中的元素是在一片内存空间当中连续排放的,和hash的返回值没任何关系。
数组把元素存储在一系列连续的地址当中,例如一个容量为 n 的数组当中,包含了位置 0,1 一直到 n-1 这么多空白的槽位。要判断一个元素是不是在数组中存在,需要对数组中每个元素的位置都进行检查(除非数组当中的元素是已经排序过的,那是另一回事了)。
散列表使用了一个不太一样的办法。相对于数组把元素按顺序存储(0, 1, …, n-1),散列表在内存中分配 n 个位置,然后使用一个函数来计算出位置范围之内的某个具体位置。散列函数需要具有确定性。一个 好的 散列函数在不需要太多计算量的情况下,可以使得生成的位置分布接近于均匀分布。当两个不同的对象计算出相同的散列值时,我们称其为发生了 散列碰撞 。当出现碰撞时,散列表会从碰撞产生的位置开始向后寻找,把新的元素放在第一个可供放置的位置。随着散列表变得越来越致密,发生碰撞的可能性也会随之增加,导致查找可用位置花费的时间也会增加(这也是为什么我们希望散列函数的结果分布更接近于均匀分布)。
isEqual和hash在NSString, NSArray, NSSet, NSDictionary中的使用
NSString 中hash是如何处理的呢?我在CFString中找到了如下部分代码:
CFHashCode __CFStringHash(CFTypeRef cf) {
/* !!! We do not need an IsString assertion here, as this is called by the CFBase runtime only */
CFStringRef str = (CFStringRef)cf;
const uint8_t *contents = (uint8_t *)__CFStrContents(str);
CFIndex len = __CFStrLength2(str, contents);
if (__CFStrIsEightBit(str)) {
contents += __CFStrSkipAnyLengthByte(str);
return __CFStrHashEightBit(contents, len);
} else {
// 如果是unicode字符串
return __CFStrHashCharacters((const UniChar *)contents, len, len);
}
}
#define HashNextFourUniChars(accessStart, accessEnd, pointer) \
{result = result * 67503105 + (accessStart 0 accessEnd) * 16974593 + (accessStart 1 accessEnd) * 66049 + (accessStart 2 accessEnd) * 257 + (accessStart 3 accessEnd); pointer += 4;}
#define HashNextUniChar(accessStart, accessEnd, pointer) \
{result = result * 257 + (accessStart 0 accessEnd); pointer++;}
CF_INLINE CFHashCode __CFStrHashCharacters(const UniChar *uContents, CFIndex len, CFIndex actualLen) {
CFHashCode result = actualLen;
// ****X 这里HashEverythingLimit = 96
if (len <= HashEverythingLimit) {
// ****X 若字符串长度在96以内,对所有的字符做hash运算得到一个结果
const UniChar *end4 = uContents + (len & ~3);
const UniChar *end = uContents + len;
while (uContents < end4) HashNextFourUniChars(uContents[, ], uContents); // First count in fours
while (uContents < end) HashNextUniChar(uContents[, ], uContents); // Then for the last <4 chars, count in ones...
} else {
// ****X 若字符串长度超过96
const UniChar *contents, *end;
// ****X 取前32个字符做hash运算
contents = uContents;
end = contents + 32;
while (contents < end) HashNextFourUniChars(contents[, ], contents);
// ****X 取中间32个字符做hash运算
contents = uContents + (len >> 1) - 16;
end = contents + 32;
while (contents < end) HashNextFourUniChars(contents[, ], contents);
// ****X 取最后32个字符做hash运算
end = uContents + len;
contents = end - 32;
while (contents < end) HashNextFourUniChars(contents[, ], contents);
}
return result + (result << (actualLen & 31));
}
所以对于[NSString hash],如果长度大于96,只有前、中、后32个字符做了哈希运算,也就是说在这些字符相同的情况下,其他任意位置的字符发生改变,Hash值都不会变。
NSArray 允许添加重复元素,添加元素时不查重,所以不调用isEqual和hash方法。在移除元素时,会对当前数组内的元素进行遍历,每个元素的 isEqual 方法都会被调用(使用 remove 方法传入的元素作为参数),所有返回真值的元素都被移除。在字典中,不涉及 hash 方法。
NSSet 不允许添加重复元素,所以添加新元素时,该元素的 hash 方法会被调用。若集合中不存在与此元素 hash 值相同的元素,则它直接被加入集合,不调用 isEqual 方法;若存在,则调用集合内的对应元素的 isEqual 方法,返回真值则判等,不加入,处理结束。若返回 false,则判定集合内不存在该元素,将其加入。
从集合中移除元素时,首先调用它的 hash 方法。若集合中存在与其 hash 值相等的元素,则调用该元素的 isEqual 方法,若真值则判等,进行移除;若不存在,则会依次调用集合中每个元素的 isEqual 方法,只要找到一个返回真值的元素,就进行移除,并结束整个过程。(所以这样会有其他满足 isEqual 方法但却被漏掉未被移除的元素)。调用 contains 方法时,过程类似。因此,若某自定义对象会被加入到集合或作为字典的 key 时,需要同时重写 isEqual 方法和 hash 方法。这样,若集合中某元素存在,则调用它的 contains 和 remove 方法时,可以在 O(1) 完成查询。否则,查询它的时间复杂度提升为 O(n)。
NSDictionary 利用key设置object时会调用key的hash值去进行去重
在NSSet,NSDictionary中的去重的流程如下
如何重写isEqual和hash方法
那了解了数组,集合,字典是如何利用isEqual和hash如何进行判等和去重的,下面来看看我们如何自己定义isEqual和hash。
一般的重写可能直接就调用父类的hash方法:
- (NSUInteger)hash {
return [super hash]; // 而这一般返回的就是自己的地址
}
但更多的情况是我们需要将自己的类在集合或者字典中进行去重,移除等操作代码如下
// 以Person类为例
@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSDate *birthday;
@end
#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 {
NSLog(@"[self.name hash] = %ld", [self.name hash]);
NSLog(@"[self.birthday hash] = %ld", [self.birthday hash]);
return [self.name hash] ^ [self.birthday hash];
}
#pragma mark - NSCopying
// 特别需要提醒一点NSDictionary 的键和值都是对象类型即可。
// 但是被设为键的对象需要遵守 NSCopying 协议
- (id)copyWithZone:(NSZone *)zone {
Person *person = [[[self class] allocWithZone:zone] init];
person.name = self.name;
person.birthday = self.birthday;
return person;
}
@end
在实现一个 hash 函数的时候,一个很常见的误解来源于肯定后项,认为 hash 得到的值 必须 是唯一可区分的。实际上,对于关键属性的散列值进行一个简单的 XOR操作,就能够满足在 99% 的情况下的需求了。
其中需要技巧的一点是,找出哪个值对于对象来说是关键的。比如上面的代码我就认为person的name和birthday是重要的判等依据,就取了按位异或。
关于isEqual,hash就聊这么多,如果大家有更加全面系统的理解可以留言讨论。