[译]实现 Equality 和 Hashing

748 阅读11分钟

翻译自 Mike AshImplementing Equality and Hashing

Equality

对象判等是一个基本的概念,在代码中经常会被使用到。在 Cocoa 编程中,它通过 isEqual: 方法被实现。一些比较简单的例子像[array indexOfObject:]会在底层使用到它,所以说对象支持判等是非常重要的。

在 Cocoa 编程中,它已经为我们在 NSObject 中提供了一个默认的判等实现。这个默认的实现只是通过对象的指针地址来进行判等。换句话说,一个对象只会与它自己相等。这个默认实现从功能上看如下面代码所示:

- (BOOL)isEqual: (id)other {
    return self == other;
}

虽然该默认的判等实现看上去过于简单,但实际上它对于许多对象来说,是十分有用的。比如,我们永远不会把一个 NSView 看做跟另外一个 NSView 同等。同样的,对于很多具有该特性的类来说,这个默认的判等实现已经是足够了。这或许是个好消息,因为这意味着如果你的类具有相同的判等特性,那么你不需要做任何事情就可以免费得到想要的结果。

实现自定义判等

有时候你需要实现更深层次的判等。这对于许多对象来说是很正常的,特别是那些被看作“值类型的对象”,他们是根据逻辑上的判等来区分。举个例子:

// 使用可变类型保证生成的是不同的字符串对象
NSMutableString *s1 = [NSMutableString stringWithString: @"Hello, world"];
NSMutableString *s2 = [NSMutableString stringWithString: @"%@, %@", @"Hello", @"world"];
BOOL equal = [s1 isEqual: s2]; // 返回 YES !

当然啦,NSMutableString 在这种情况下已经为你做了判等实现。但是如果你想要为自定义的对象做同样的操作该怎么办?

MyClass *c1 = ...;
MyClass *c2 = ...;
BOOL equal = [c1 isEqual: c2];

在这种情况下你需要实现你自己版本的isEqual:方法。

测试相等性在大多数情况下是相当简单的。把你的类中所有相关的属性收集起来,再测试他们的相等性。如果他们当中有不相等的,那么返回 NO ,否则返回 YES 。

有一个微妙的点就是,当你的对象所对应的类也是检测相等性中的一个重要的属性。去检测 MyClass 和 NSString 的相等性是十分合理的,但是这种比较的结果永远不会返回 YES (除非 MyClass 是 NSString 的一个子类)。

有一个稍微不那么微妙的点就是,确保你测试的属性对于判等来说是非常重要的。一些像缓存 caches 这样的属性对于你的对象的外部视角而言是无关紧要的,那么它就不需要被用作判等的因素。

比如说你的类看起来像这样:

@interface MyClass: NSObject {
   int _length;
   char *_data;
   NSString *_name;
   NSMutableDictionary *_cache;
}

你的判等实现看起来会像这样:

- (BOOL)isEqual: (id)other {
   return ([other isKindOfClass: [MyClass class]] && [other length]  == _length && memcmp([other data], _data, _length) == 0 && [[other name] isEqual: _name])
   // 注意:没有 _cache 的比较
}

Hashing

哈希表是一个普通的数据结构,被用于实现 NSDictionary 和 NSSet 等。无论你往容器类中添加多少对象,都能够支持快速查找到相应的对象。

如果你已经了解哈希表是如何工作的,你可以直接跳过接下来的一到两个段落内容。

哈希表基本上可以被看做是一个带特殊索引的庞大数组。所有被添加到数组的对象都会有一个索引关联着他们的哈希值。这个哈希值本质上是由对象的属性而产生的伪随机的数字。这种机制使得索引有足够的随机性,那么两个对象就不太可能拥有相同的哈希值了,但这是完全可复写的。当一个对象被插入到数组中时,它的哈希值会被用来决定它该被放到哪个位置上。当一个对象被查找时,它的哈希值会被用来决定到哪个位置中查找。

用更加正式的术语来讲,一个对象的哈希值被定义了,如果两个对象是相等的,那么他们会有相同的哈希值。要注意的是,反过来说是不正确的,也不应该这样,因为:两个对象可以有相同的哈希值,但是他们可以不相等。你想要尽可能的避免出现这种情况,因为当两个不相等的对象拥有两个相同的哈希值(称为碰撞),那么哈希表就必须采取特殊的措施去处理这种情况,这是一个非常耗时的操作。然而,这已经被证明了要想完全避免哈希碰撞的发生是不可能的。

在 Cocoa 编程中,哈希的实现通过哈希方法,它的方法签名为:

- (NSUInteger)hash;

跟对象判等一样,NSObject 也为你提供了一个默认的哈希实现,但这是通过使用对象的标识来实现的。粗略的讲,它做了这些事情:

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

实际返回的值可能不一样,但本质的关键点是,这种方式是基于实际指向 self 的指针的值。跟判等方法一样,如果基于对象标识的判等已经达到你想要的需求,那么默认的实现对你来说已经是有用的了。

实现自定义哈希值

因为哈希的语义,如果你重写了isEqual:方法,你就必须重写哈希方法。如果你不这样做,你会遇到两个相同对象却拥有不同的哈希值的情况,这是十分不安全的。如果你在字典、集合或者其他需要哈希表的地方使用到这些对象,那么会出现问题。

因为对象哈希值的定义和相等性的关系是十分密切的,同样的,哈希方法的实现和判等方法的实现也十分密切。

一个例外的情况是,不需要在哈希值的定义中包含你的对象所属的类。这主要是作为isEqual:方法的一个保护措施,是为了确保跟不同对象之间比较时剩余内容的检测有意义。如果通过不同的数学方式去合并不同属性的哈希值,那么你的哈希值很可能跟其他不同的类的哈希值相比就会非常不一样。

生成属性的哈希值

检测属性的相等性通常来说是很简单的,但计算他们的哈希值却不总是那么简单。你如何计算一个属性的哈希值取决于对象的类型是什么。

对于数值型属性,哈希值可以被简单的设定为数字的值。

对于对象型属性,你可以通过调用对象的哈希方法,来使用其返回的哈希值。

对于数据型属性,你会想要使用一些哈希算法来生成哈希值。你可以使用 CRC32 ,或者重量型的 MD5 。后者的执行速度相对较慢,但便于使用,它通过把数据封装在 NSData 中,并且获取它的哈希值。在上面的例子中,你可以像这样计算出 _data 的哈希值:

[[NSData dataWithBytes: _data length: _length] hash];

合并属性的哈希值

所以你已经知道了如何为每个属性生成哈希值,但是要如何将他们合并在一起呢?

最简单的方式就是将他们相加在一起,或者使用按位或的特性。然而,这会破坏哈希值的独特性,因为这些操作都是对称性的,意味着区分不同对象时会出错。举个例子,假设一个对象有 first 和 last name 两个属性,它的哈希方法的实现如下:

- (NSUInteger)hash {
    return [_firstName hash] ^ [_lastName hash];
}

现在假设你有两个对象,一个是 “George Frederick” ,另一个是 “Frederick George”。即使他们很明显是不同的,但他们还是会有相同的哈希值。虽然哈希碰撞的发生是完全不可避免的,但我们也应该尽量让这种情况不轻易出现。

如何合并哈希值是一个复杂的主题,是无法用一个回答就能解释的。然而,使用任何不对称的方式去合并哈希值却是一个很好的开始。我打算使用位移运算加上按位异或预算来合并他们。

#define NSUINT_BIT (CHAR_BIT * sizeof(NSUInteger))
#define NSUINTROTATE(val, howmuch) ((((NSUInteger)val) << howmuch) | (((NSUInteger)val) >> (NSUINT_BIT - howmuch)))
- (NSUInteger)hash {
    return NSUINTROTATE([_firstName hash], NSUINT_BIT / 2) ^ [_firstName hash];
}

自定义哈希值用例

现在我们可以运用上述内容来为前面的例子生成一个哈希值。这跟判等方法的实现一样会遵循一些基本的格式,并且会使用上述的技术去获取和合并每个属性的哈希值。

- (NSUInteger)hash {
    NSUInteger dataHash = [[NSData dataWithBytes: _data length: _length] hash];
    return NSUINTROTATE(dataHash, NSUINT_BIT / 2) ^ [_name hash];
}

如果你还有更多的属性,你可以添加更多的位移运算和按位或操作,而且这个流程都是类似的。你还可能会想要为每一个属性调整位移运算来使得他们每一个都是不同的。

子类化时注意点

你必须要注意当你子类化的是一个实现了自定义的哈希方法和判等方法的父类。尤其是你的子类不应该暴露那些在判等方法的实现中使用到的新的属性。如果你这样做了,那么该子类的实例肯定与父类的实例不相等。

为了解释这种情况,假设一个子类拥有 first/last name 属性,且包含一个 birthday 属性,而且 birthday 作为判等计算的一部分。然而,这不可以用在父类的实例中比较相等性,所以它的判等方法看起来像这样:

- (BOOL)isEqual: (id)other {
    // 笔者注:如果调用父类的判等实现的结果返回了 NO ,那么不用比较新属性(如果有)也可知道肯定也不相等。
    if(![super isEqual: other])
        return NO;
    
    // 如果执行到这一步,证明通过父类的判等实现的结果返回的是 YES ,接下来观察要判断 other 是否是子类或者子类的子类类型,如果不是,则证明要判等的两个对象实质上是同一个父类对象。
    if(![other isKindOfClass: [MyClass class]])
        return YES;
        
    // 如果执行到这一步,证明要判等的是两个子类类型,而且对于父类中的属性已被证明是相等的,那么接下来继续判断新属性是否相等即可。
    return [[other birthday] isEqual: _birthday];
}

现在假设你有一个父类的实例对应 “John Smith” ,我称之为 A ,和一个子类实例对应 “John Smith”,并且生日为 5/31/1982,我称之为 B 。因为有了上述的判等定义,那么结果为,A 等于 B ,B 也等于他自己,得到了期望的结果。

现在假设你有一个子类的实例对应 “John Smith” ,生日为 6/7/1994,我称之为 C 。那么 C 不等于 B ,得到我们期望的结果。 C 等于 A ,同样得到期望的结果。但是现在出现了一个问题,A 等于 B 和 C ,但是 B 和 C 不相等!这打破了相等操作的传递性,并且会造成非常意外的后果。

通常来讲这不应该是一个严重的问题。如果你的子类添加了会影响父类对象判等的新属性,这是你的类层级结构中的一个明显的设计问题。你应该去考虑如何重新设计你的类层级结构,而不是在isEqual:方法中做一些复杂的实现。

使用字典时注意点

如果你想要在 NSDictionary 中使用你的对象来作为 key 值,你需要实现对应的哈希方法和判等方法,而且你也需要实现-copyWithZone:方法。做这样的技巧已经超出了本文的内容,但你应该意识到在某些情况下你需要做更多事情。

总结

在 Cocoa 编程中已经为你提供了哈希方法和判等方法的默认实现,这对于许多对象而言是有用的,但是如果你想要为你自己的对象即使在内存地址是不相同的情况下,也想要通过判等结果返回 YES 来指明他们是相等的,那么你就必须要做一点额外的工作。幸运的是,这实现起来并不困难,并且一旦你实现了他们,你自定义的类将可以用在许多 Cocoa 的集合类中。