前言
对数据的等同性判断包括对基本数据类型等同性
的判断和对象等同性
的判断。对基本数据类型等同性的判断是非常简单的,比如对两个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这种集合对象中,就不宜改变其内容。
总结
- 把某个对象添加到set中时,都会调用这个对象的hash方法计算hash值。
- 把一个对象添加到set中时,如果set中不存在任何元素,这个对象会被直接添加到set中。相反。如果set中存在元素,那么待添加的对象的hash值会和set中的每个元素的hash值进行比较。如果不等,会继续和set中的下一个元素比较hash值,直到待添加的对象的hash值和set中所有元素的hash值比较完毕为止。
- 如果set中存在一个元素的hash值和待添加的对象的hash值相等,那么待插入的对象会调用自己的isEqual:方法,以set中的元素为参数,进行比较,如果isEqual:返回YES,证明这两个对象相同,那么待插入的对象不会插入到set中。如果isEqual:返回NO,证明这两个对象不同,对象可以插入到set中。
- hash的默认实现是返回对象的指针地址。isEqual:的默认实现是比较两个对象的指针地址。
- 相同的对象必须具有相同的hash值,但是两个hash值相等的对象未必相同。
- 若想检测对象的等同性,需要提供“isEqual:”与hash方法。
- 根据实际也需重写isEqual:方法,不要盲目检查每条属性,而是应该按照具体需求来指定检查方案。
- 最好不要把可变对象添加到set中,最好也请不要改变set中某个元素,否则容易产生想象不到的错误,也会增加调试的难度。
- hash方法应该使用计算速度快而且哈希值碰撞几率低的算法。一般情况下,建议使用按位异或操作。
最后,借用大神“Mattt Thompson”的话:
经过这么多的解释,希望我们在这个有些诡谲的话题上取得了”相同“的认识。 作为人类,我们很努力地去理解和实现平等,在我们的社会中,在自然生态环境中,在立法和执法中,在选举我们领导人的过程中,在人类作为一个物种互相沟通延续我们的存在这一共识中。愿我们能继续这个奋斗的过程,最终达到理想的彼岸,在那里,评价一个人的标准是他的人格,就像我们判断一个变量是通过它的内存地址一样
。
文/VV木公子(简书作者)
PS:如非特别说明,所有文章均为原创作品,著作权归作者所有,转载请联系作者获得授权,并注明出处,所有打赏均归本人所有!
如果您是iOS开发者,或者对本篇文章感兴趣,请关注本人,后续会更新更多相关文章!敬请期待!
参考文章
iOS开发 之 不要告诉我你真的懂isEqual与hash!
Equality(翻译)
Equality(英文)
isEqual & hash