iOS--打赌你没有真的理解isEqual,hash!

为了是文章的结构比较清晰,先理出文章的整体目录如下:

  1. 为什么会有isEqual和hash方法
  2. isEqual和hash在NSString, NSArray, NSSet, NSDictionary中的使用
  3. 如何重写isEqual和hash方法

为什么会有isEqual和hash方法
isEqual顾名思义是用来判断两个对象是否相等的。那"= =" 和isEqual的区别在哪里呢?==其实是值的判等。这里分两种情况:

  1. 在内置类型比如int float等判断的是值是不是相等,
  2. 如果是对象用"= ="进行判等则是判断的两个对象的的地址是否相等。

和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中的去重的流程如下
iOS--打赌你没有真的理解isEqual,hash!_第1张图片

如何重写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就聊这么多,如果大家有更加全面系统的理解可以留言讨论。

你可能感兴趣的:(iOS高级进阶,iOS,iOS高级进阶,isEqual,hash)