浅析对象等同性判断

作者: VV木公子 | 来源:发表于2017-01-30 20:15 被阅读454次

前言

对数据的等同性判断包括对基本数据类型等同性的判断和对象等同性的判断。对基本数据类型等同性的判断是非常简单的,比如对两个NSInteger类型的变量等同性判断,我们直接使用关系运算符“==”即可。
相比于基本数据类型等同性,对象等同性的判断就稍显复杂。按照大神Mattt Thompson的说法,对象的等同性包括相等性本体性。从字面不难发现,相等性是指:两个对象的值是否相等。本体性是指:两个对象本质上是否是同一个对象

关系运算符"=="不仅可以应用在基本数据类型上,还可以应用在两个对象类型的对象上。不过,按照==”比较两个对象,本质上是对两个对象指针地址的比较,即对象本体性的判断。单纯的比较两个对象的指针并不能完全满足要求,因为对象的等同性不仅包括本体性,还包括相等性。有时候指向两个对象的指针虽然不相同,但是两个对象的值是相同的,我们也认为其是相同的,即相等性。换句话说,单纯的通过比较两个对象的指针来判断等同性总是太过苛刻。而对于自定义的类型,开发中经常要对两个对象的相等性进行判断,即对两个对象每个属性进行比较。如果两个对象的类型相同,且属性值都一样,我们也会认为其是相等的。如果对象是集合类型,比如数组,相等性检查要求我们对两个数组相同位置的元素进行逐个比较。

NSFoundation提供的一些方法

Objective-C的NSFoundation框架中给我们提供了很多判断对象等同性的方法。比如:

// NSString类提供了判断两个NSString对象是否相等的方法
- (BOOL)isEqualToString:(NSString *)aString;
// NSArray类提供了判断两个NSAarray对象是否相等的方法
- (BOOL)isEqualToArray:(NSArray<ObjectType> *)otherArray;
// NSDictionary类提供了判断两个NSDictionary对象是否相等的方法
- (BOOL)isEqualToDictionary:(NSDictionary<KeyType, ObjectType> *)otherDictionary;
// NSSet类提供了判断两个NSSet对象是否相等的方法
- (BOOL)isEqualToSet:(NSSet<ObjectType> *)otherSet;
// ......
- (BOOL)isEqualToData:(NSData *)other;
- (BOOL)isEqualToNumber:(NSNumber *)number;
- (BOOL)isEqualToValue:(NSValue *)value;
- (BOOL)isEqualToTimeZone:(NSTimeZone *)aTimeZone;
- (BOOL)isEqualToDate:(NSDate *)otherDate;
- (BOOL)isEqualToOrderedSet:(NSOrderedSet<ObjectType> *)other;
- (BOOL)isEqualToHashTable:(NSHashTable<ObjectType> *)other;
- (BOOL)isEqualToIndexSet:(NSIndexSet *)indexSet;

前面说,对于NSFoundation框架中的一些类,苹果已经为我们提供了现成的等同性判断的方法。比如NSString提供的判断两个字符串对象是否相等的方法- (BOOL)isEqualToString:

那么你可能会问:NSString类默认提供了比较字符串等同性的方法,而那些继承自NSObject基类的自定义类,我们该怎么判断等同性呢?不用担心,NSObject类的协议已经默认提供了- (BOOL)isEqual:(id)object;方法,且NSObject类也遵守并实现了NSObject协议中的isEqual:方法。我们可以通过调用- (BOOL)isEqual:方法来检验两个NSObject对象的等同性。其实,个人认为,NSString的- (BOOL)isEqualToString:就是在- (BOOL)isEqual:基础之上进行的扩展。因为NSString类继承自NSObject这个基类,我们也可以使用- (BOOL)isEqual:方法对两个字符串进行比较。但是不建议这么做,因为系统已经给我们提供了现成的API,调用- (BOOL)isEqualToString:比调用- (BOOL)isEqual:方法快。后者还要执行额外的步骤,因为他不知道受测对象的真实类型。

覆写NSObject类的- (BOOL)isEqual:方法

NSObject类对- (BOOL)isEqual:的默认实现是:当且仅当被比较的两个对象的指针值相等时,才被认为相等。即,isEqual:的默认实现就是对对象本体性的判断。前面已经说过,但对于自定义类型和集合类型,这种默认的判断有时候太过苛刻。针对于这种情况,如果有判断自定义对象等同性的需求,我们需要覆写- (BOOL)isEqual:方法。

- (BOOL)isEqual:(id)object{
    // 两个对象指针相等,其指向同一块内存,则肯定相等。
    if (self == object) {
        return YES;
    }

    // 一般来说,如果两个对象的类型完全不同,则肯定不等。
    if ([self class] != [object class]) {
        return NO;
    }
    EOCPerson *otherPerson = (EOCPerson *)object;
    // 两个对象相应的属性如果不等,则也认为不等(忽略继承和多态)。
    if (![self.firstName isEqualToString:otherPerson.firstName]){
        return NO;
    }
    if (![self.lastName isEqualToString:otherPerson.lastName]){
        return NO;
    }
    if (self.age != otherPerson.age) {
        return NO;
    }
    // 如果属性也相等,则认为相等。
    return YES;
}

上面的EOCPerson类,实现了NSObject协议的- (BOOL)isEqual:方法,首先,直接判断两个指针是否相等,若相等则其均指向同一个对象,所以受测对象肯定相等。然后,比较两个受测对象所属的类,若不属于同一个类(忽略多态),则认为两对象不相等。最后,检查两个对象的属性是否相等,如果对象只要有某个属性不相等,就认为两个对象不相等,否则对象相等。

EOCPerson *p1 = [[EOCPerson alloc] init];
    p1.firstName = @"VV";
    p1.lastName = @"S";
    
    EOCPerson *p2 = [[EOCPerson alloc] init];
    p2.firstName = @"VV";
    p2.lastName = @"S";
    
    BOOL isEqual = [p1 isEqual:p2];
    NSLog(@"isEqual == %d",isEqual); // isEqual == 1
    

上面我们覆写EOCPerson类的isEqual:方法时,没有考虑多态的情况,开发中如果存在继承,我们还需要对两个对象的类型进行比较,直接调用NSObject类型查询方法- (BOOL)isKindOfClass:(Class)aClass;即可。

- (BOOL)isEqual:(id)object{
    // return [super isEqual:object];
    // 考虑多态
    if (![self isKindOfClass:[object class]] && ![object isKindOfClass:[self class]]) {
        return NO;
    }
    
    // 两个对象指针相等,其指向同一块内存,则肯定相等。
    if (self == object) {
        return YES;
    }
    EOCPerson *otherPerson = (EOCPerson *)object;
    // 两个对象相应的属性如果不等,则也认为不等。
    if (![self.firstName isEqualToString:otherPerson.firstName]){
        return NO;
    }
    if (![self.lastName isEqualToString:otherPerson.lastName]){
        return NO;
    }
    if (self.age != otherPerson.age) {
        return NO;
    }
    // 如果属性也相等,则认为相等。
    return YES;
}

Hash方法

Hash一词经常被译作“哈希”,有时候也被译作“杂凑”“散列”。因此,“hash table”有时候被译作“哈希表”,也有人称之为“散列表”。我们只需要知道他们表达的是同一个意思。

1.为什么要有Hash方法

根据约定:如果两个对象相等,则其哈希值也相等,但是如果两个哈希值相等,则对象未必相等。这是能否覆写isEqual:方法的关键。
另外,我们知道,哈希也是会存在碰撞的。即,两个对象如果相等则哈希值肯定相等。但两个对象的哈希值如果相等,则这两个对象也不一定相等。

哈希表是一种数据结构,经常被用来实现set和dictionary。我们知道,set和dictionary都属于collection(集合)的一种形式。set和dictionary相对于array而言,是其查询速度是比较快的,事件复杂度仅为O(1)。而对于一个无序的array,查询某个元素的事件复杂度是O(n)(其中n为数组的长度)。这其中hash就起到了至关重要的作用。

2.Hash方法的默认实现

hash的默认实现是:返回对象的内存地址作为哈希值。即,NSObject类实现的hash方法,本质上是返回的对象的内存地址。乍一想,这种默认实现是可行的,但是对于一些数据类型是不行的,比如我们自定义的类型。


    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    
    EOCPerson *p1 = [[EOCPerson alloc] init];
    p1.firstName = @"VV";
    p1.lastName = @"S";
    
    EOCPerson *p2 = [[EOCPerson alloc] init];
    p2.firstName = @"VV";
    p2.lastName = @"S";
    
    BOOL isEqual = [p1 isEqual:p2];
    NSLog(@"isEqual == %d",isEqual);
    
    NSMutableSet *set = [NSMutableSet set];
    [set addObject:p1];
    [set addObject:p2];
    NSLog(@"count == %ld",[set count]); // count == 2

如上,我们没有覆写默认的isEqual:方法和默认的hash方法。p1和p2虽然指针不同,但是对象的属性都是完全相同的。我们把本质上两个完全相同的对象插入到set这种数据类型中,set应该是可以自动去重的。即,正确的情况下set应该只有一个对象。但是因为hash方法默认返回指针地址作为哈希值,导致set中出现了两个本质上完全相同的对象,这完全违背了set的机制和作用。所以,返回内存地址作为哈希值并不是一个好主意。
上面说过,根据约定:如果两个对象相等,则其哈希值也相等,但是如果两个哈希值相等,则对象未必相等。所以我们可以这么实现hash方法:

- (NSUInteger)hash {
    return 666;
}

这么写显然符合约定,即相等的对象hash值相等,相等hash值的对象未必相等。但是在set中大量使用这种对象将会产生性能问题。因为set在检索哈希表时,会用对象的哈希值作为索引。set会根据哈希值把对象分组。在向set中添加新对象时,要根据待插入的新对象的哈希值找到与之相关的那个组。然后依次检查各个元素(调用isEqual:方法),看待插入的对象是否和数组中的某个元素相等,如果相等,那么就说明待添加的对象已经在set中存在。由此可知,如果令每个对象都返回相同的哈希值,那么在set中有1000000个对象的情况下,若是继续想其中添加对象,则需要将这1000000个对象全部遍历一遍。这样一来就丧失了hash值的作用,把set变成了一个活生生的array。

稍微好一点的方法

- (NSUInteger)hash {
    NSString *stringToHash = [NSString stringWithFormat:@"%@,%@,%ld",self.firstName,self.lastName,self.age];
    return [stringToHash hash];
}

这次所使用的方法是将NSString对象中的属性拼接成一个新的字符串,然后另该字符串调用hash方法,返回该字符串的哈希值作为这个对象的哈希值。这么做符合约定,因为两个相等的对象总是会返回相同的哈希值。但是这样做还需要负担创建一个新字符串的额外的开销,所以比返回一个单一值慢。相对而言,把这种对象添加到collection中,也会产生性能问题。

更加优秀的方法

分别计算每个属性的哈希值,然后对哈希值进行按位异或运算,的出的结果作为对象的哈希值。

- (NSUInteger)hash {
    NSInteger firstNameHash = [self.firstName hash];
    NSInteger lastNameHash = [self.lastName hash];
    NSInteger ageHash = self.age;
    return firstNameHash ^ lastNameHash ^ ageHash;
}

对哈希值进行按位异或操作,这种方式既能保持较高的效率,又能使生成的哈希值至少位于一定范围之内,而不会过于频繁的重复。当然,此算法的哈希值还是会生成碰撞,不过至少可以保证哈希值有多重可能的取值,编写hash方法时,应该用当前的对象多做做实验,以便在减少碰撞频度与降低运算复杂程度之间取舍。

3.Hash方法调用时机

当把一个对象添加到set时会调用这个对象的hash方法。或者把一个对象作为key添加到dictionary中时,也会调用这个对象的hash方法。因为dictionary在查找某个value时,也是根据key的hash值来提高查询效率。
当然,如果我们把对象作为value添加到dictionary中,并不会调用对象的hash方法。
注意:如果一个自定义对象作为dictionary的key,切记要实现NSCopying协议中的- (id)copyWithZone:(nullable NSZone *)zone方法。如下:

- (id)copyWithZone:(nullable NSZone *)zone {
    EOCPerson *copy = [[EOCPerson alloc] init];
    copy.lastName = self.lastName;
    copy.firstName = self.firstName;
    copy.age = self.age;
    return copy;
}

4.Hash方法与isEqual:的关系

拿set为例,为了优化插入效率,当在set中插入某个对象时,首先会调用待插入对象的hash方法,根据返回的hash值查找hash table。如果待插入对象的hash值和set中的对象的hash值都不相等。则认为set中不存在和待插入对象相等的对象,那么就可以把待插入的对象插入到set中。如果set中存在一个对象的hash值和待插入对象的hash值相等,则再调用对象的isEqual:方法,进行对象的判等,如果经过isEqual:方法返回YES,则认为两个对象相等,即set中已经存在一个和待插入对象相等的对象,待插入的对象不能插入到set中。否则继续上面的操作。

isEqual:调用时机

  • 当手动调用isEqual:方法,对两个对象进行显式的比较时。
  • 当把一个对象添加到一个成员count不为0的set中,且待插入的对象的hash值和set中的成员的hash值相等的情况下,才会调用isEqual:方法。即,首先调用hash方法,然后才有可能调用isEqual:方法。

等同性判定的执行深度

创建等同性判定方法时,需啊哟决定是根据某个对象来判断等同性,还是仅根据其中的某个或者某几个属性来判断,这个取决于业务场景。NSArray的检测方式为:先看两个数组所含对象个数是否等,若想等,则在每个对应位置的两个对象身上调用“isEqual:”方法。如果对应位置上的对象均相等,那么这两个数组相等,这叫做“深度等同性判定”。不过有时无需将所有数据逐个比较,只根据其中部分数据即可判断二者是否相同。比如某个Person类总有一个identity字段代表身份证号码,在不存在脏数据的情况下,完全可以仅凭这个identity字段判断两个对象是否是相同的。

不要向set中添加可变的对象

不要向set中添加可变的对象。确切的说,如果向set中添加了可变对象,那么尽量保证这个可变对象不再改变。为什么呢?我们已经了解,set和dictionary是通过哈希值检索元素的,我们已经说过,set火把各个对象按照其哈希值进行分组,如果某个可变对象在set中被分组后哈希值又改变了,那么这个对象现在所在的组就不再合适了。要想解决这个问题,我们需要确保被添加到set中的对象是不可变的或者确保可变对象被添加到set后就不再改变,或者这个对象的hash值的计算不受可变部分的影响,即,这个对象的hash值不是根据其可变部分计算出来的。

// 重写isEqual:
- (BOOL)isEqual:(id)object{
    // return [super isEqual:object];
    // 一般来说,如果两个对象的类型完全不同,则肯定不等。
    if ([self class] != [object class]) {
        return NO;
    }
    // 两个对象指针相等,其指向同一块内存,则肯定相等。
    if (self == object) {
        return YES;
    }
    EOCPerson *otherPerson = (EOCPerson *)object;
    // 两个对象相应的属性如果不等,则也认为不等。
    if (![self.firstName isEqualToString:otherPerson.firstName]){
        return NO;
    }
    if (![self.lastName isEqualToString:otherPerson.lastName]){
        return NO;
    }
    if (self.age != otherPerson.age) {
        return NO;
    }
    // 如果属性也相等,则认为相等。
    return YES;
}
// 重写hash
- (NSUInteger)hash {
    NSLog(@"%s",__func__);
    NSInteger firstNameHash = [self.firstName hash];
    NSInteger lastNameHash = [self.lastName hash];
    NSInteger ageHash = self.age;
    return firstNameHash ^ lastNameHash ^ ageHash;
}
// 调用
    EOCPerson *p1 = [[EOCPerson alloc] init];
    p1.firstName = @"VV";
    p1.lastName = @"S";
    
    EOCPerson *p2 = [[EOCPerson alloc] init];
    p2.firstName = @"VV";
    p2.lastName = @"S";
    
    NSMutableSet *setM = [NSMutableSet set];
    [setM addObject:p1];
    [setM addObject:p2];
    NSLog(@"set count == %ld",[setM count]);  // set count == 1
    

上面我们看到,向set中添加两个相同的对象,firstName和lastName值完全相同,打印set中元素的个数,其打印结果为1。这样完全符合set能够去重的功能。但是,如果我们继续添加一个不同于p1和p2的p3对象,然后改变p3的各个属性和p1相同,再观察set count,如下:

    EOCPerson *p1 = [[EOCPerson alloc] init];
    p1.firstName = @"VV";
    p1.lastName = @"S";
    
    EOCPerson *p2 = [[EOCPerson alloc] init];
    p2.firstName = @"VV";
    p2.lastName = @"S";
    
    NSMutableSet *setM = [NSMutableSet set];
    [setM addObject:p1];
    [setM addObject:p2];
    NSLog(@"set count == %ld",[setM count]); // set count == 1
    
    EOCPerson *p3 = [[EOCPerson alloc] init];
    p3.firstName = @"VV";
    [setM addObject:p3];
    NSLog(@"set count == %ld",[setM count]); // set count == 2
    
    p3.lastName = @"S";
    NSLog(@"set count == %ld",[setM count]); // set count == 2

我们看到,上面给setM对象添加了一个p3后,其count == 2,这样是ok的。但是把p3的lastName改为和p1的lastName相同时,set count 仍然为2。此时set中竟然出现了两个完全相同的对象!这完全违背了set的本意,因为set的作用就是去重,根据set的语义,set中是不会也不应该出现了两个完全相同的对象。
如果把这个setM对象在拷贝一下,情况更糟了:

NSSet *s = [setM copy];
NSLog(@"set count == %ld",[s count]); // set count == 1
    

你会发现,s对象虽然是setM的副本,但是s.count却是1。此s对象看上去像是由一个空set开始,通过把setM中的对象添加到s中而创建出来的。无论如何,这样做已经存在了很大的风险,这可能给我们的程序调试带来无法想象的难度。
举这个例子是想说明:把某个对象放入set这种集合对象中,就不宜改变其内容。

总结

  1. 把某个对象添加到set中时,都会调用这个对象的hash方法计算hash值。
  2. 把一个对象添加到set中时,如果set中不存在任何元素,这个对象会被直接添加到set中。相反。如果set中存在元素,那么待添加的对象的hash值会和set中的每个元素的hash值进行比较。如果不等,会继续和set中的下一个元素比较hash值,直到待添加的对象的hash值和set中所有元素的hash值比较完毕为止。
  1. 如果set中存在一个元素的hash值和待添加的对象的hash值相等,那么待插入的对象会调用自己的isEqual:方法,以set中的元素为参数,进行比较,如果isEqual:返回YES,证明这两个对象相同,那么待插入的对象不会插入到set中。如果isEqual:返回NO,证明这两个对象不同,对象可以插入到set中。
  2. hash的默认实现是返回对象的指针地址。isEqual:的默认实现是比较两个对象的指针地址。
  3. 相同的对象必须具有相同的hash值,但是两个hash值相等的对象未必相同。
  4. 若想检测对象的等同性,需要提供“isEqual:”与hash方法。
  5. 根据实际也需重写isEqual:方法,不要盲目检查每条属性,而是应该按照具体需求来指定检查方案。
  6. 最好不要把可变对象添加到set中,最好也请不要改变set中某个元素,否则容易产生想象不到的错误,也会增加调试的难度。
  7. hash方法应该使用计算速度快而且哈希值碰撞几率低的算法。一般情况下,建议使用按位异或操作。

最后,借用大神“Mattt Thompson”的话:
经过这么多的解释,希望我们在这个有些诡谲的话题上取得了”相同“的认识。 作为人类,我们很努力地去理解和实现平等,在我们的社会中,在自然生态环境中,在立法和执法中,在选举我们领导人的过程中,在人类作为一个物种互相沟通延续我们的存在这一共识中。愿我们能继续这个奋斗的过程,最终达到理想的彼岸,在那里,评价一个人的标准是他的人格,就像我们判断一个变量是通过它的内存地址一样
文/VV木公子(简书作者)
PS:如非特别说明,所有文章均为原创作品,著作权归作者所有,转载请联系作者获得授权,并注明出处,所有打赏均归本人所有!

如果您是iOS开发者,或者对本篇文章感兴趣,请关注本人,后续会更新更多相关文章!敬请期待!

参考文章

iOS开发 之 不要告诉我你真的懂isEqual与hash!
Equality(翻译)
Equality(英文)
isEqual & hash

相关文章

网友评论

    本文标题:浅析对象等同性判断

    本文链接:https://www.haomeiwen.com/subject/ogpqittx.html