关于C#:覆盖isEqual:和hash的最佳实践

Best practices for overriding isEqual: and hash

您如何正确地覆盖Objective-C中的isEqual:?"catch"似乎是指如果两个对象相等(由isEqual:方法确定),它们必须具有相同的哈希值。

Cocoa Fundamentals Guide的"内省"部分提供了一个关于如何为名为MyWidget的类重写isEqual:的示例,复制如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- (BOOL)isEqual:(id)other {
    if (other == self)
        return YES;
    if (!other || ![other isKindOfClass:[self class]])
        return NO;
    return [self isEqualToWidget:other];
}

- (BOOL)isEqualToWidget:(MyWidget *)aWidget {
    if (self == aWidget)
        return YES;
    if (![(id)[self name] isEqual:[aWidget name]])
        return NO;
    if (![[self data] isEqualToData:[aWidget data]])
        return NO;
    return YES;
}

它检查指针相等,然后检查类相等,最后使用isEqualToWidget:比较对象,后者只检查namedata属性。这个例子没有说明如何覆盖hash

假设还有其他属性不影响平等,比如age。难道不应该重写hash方法,使只有namedata影响哈希吗?如果是这样,你会怎么做?加上namedata的散列?例如:

1
2
3
4
5
6
- (NSUInteger)hash {
    NSUInteger hash = 0;
    hash += [[self name] hash];
    hash += [[self data] hash];
    return hash;
}

够了吗?有更好的技术吗?如果你有原语,比如int呢?把它们转换成NSNumber得到散列值?或者像NSRect这样的结构?

(脑残:最初是和|=一起写的"按位或"。意味着加法。


从开始

1
2
 NSUInteger prime = 31;
 NSUInteger result = 1;

那么对于每一个原始人来说

1
 result = prime * result + var

对于64位,您可能还需要移位和XOR。

1
 result = prime * result + (int) (var ^ (var >>> 32));

对于对象,使用0表示nil,否则使用其哈希代码。

1
 result = prime * result + [var hash];

对于布尔值,使用两个不同的值

1
 result = prime * result + (var)?1231:1237;

解释和归属

这不是tcurdt的工作,评论要求更多的解释,所以我相信编辑归因是公平的。

该算法已在《有效Java》一书中推广,相关章节可在网上找到。这本书推广了这个算法,它现在是许多Java应用程序(包括Eclipse)中的默认值。然而,它是从一个更老的实现中派生出来的,这个实现有各种各样的原因可以归因于丹·伯恩斯坦或克里斯·托雷克。旧的算法最初是在usenet上浮动的,而某些属性是困难的。例如,在这个Apache代码(搜索它们的名称)中有一些有趣的注释引用了原始源代码。

归根结底,这是一个非常古老、简单的哈希算法。它不是最有效的,甚至在数学上也没有被证明是一个"好"的算法。但它很简单,很多人长期使用,效果很好,有很多历史支持。


我自己也在学习objective-c,所以我不能专门为该语言说话,但是在其他语言中,如果两个实例"相等",它们必须返回相同的哈希值,否则在尝试将它们用作哈希表(或任何字典类型的集合)中的键时,会遇到各种各样的问题。

另一方面,如果两个实例不相等,则它们可能具有或可能不具有相同的哈希值-如果不相等,则是最好的。这是哈希表上的O(1)搜索和O(n)搜索之间的区别-如果所有哈希值冲突,则可能会发现搜索表并不比搜索列表更好。

根据最佳实践,散列应该为其输入返回值的随机分布。这意味着,例如,如果您有一个double,但是大多数值趋向于在0到100之间集群,那么您需要确保这些值返回的散列在整个可能的散列值范围内均匀分布。这将显著提高您的性能。

有许多散列算法,包括这里列出的几个。我尽量避免创建新的哈希算法,因为它可能会带来很大的性能影响,因此使用现有的哈希方法并像在示例中那样进行某种位组合是避免这种情况的好方法。


A simple XOR over the hash values of critical properties is sufficient
99% of the time.

例如:

1
2
3
4
- (NSUInteger)hash
{
    return [self.name hash] ^ [self.data hash];
}

Mattt Thompson在http://nshipster.com/equality/上找到了解决方案(他在文章中也提到了这个问题!)


我发现这个线程非常有用,它提供了我用一个catch实现我的isEqual:hash方法所需的一切。在isEqual:中测试对象实例变量时,示例代码使用:

1
2
if (![(id)[self name] isEqual:[aWidget name]])
    return NO;

当我知道在我的单元测试中对象是相同的时,这个过程反复失败(即返回no),没有错误。原因是,其中一个NSString实例变量为零,所以上面的陈述是:

1
2
if (![nil isEqual: nil])
    return NO;

既然nil会对任何方法作出回应,这是完全合法的,但是

1
[nil isEqual: nil]

返回nil,即no,因此当被测对象和被测对象都有nil对象时,它们将被视为不相等(即,isEqual:将返回no)。

这个简单的修复方法是将if语句更改为:

1
2
if ([self name] != [aWidget name] && ![(id)[self name] isEqual:[aWidget name]])
    return NO;

这样,如果它们的地址相同,不管它们都为零或者都指向同一对象,都会跳过方法调用,但是如果不是零或者它们指向不同的对象,则会适当地调用比较器。

我希望这能让别人少抓几分钟的头。


哈希函数应该创建一个半唯一值,该值不可能与另一个对象的哈希值冲突或匹配。

这里是完整的哈希函数,它可以适应类实例变量。它在64/32位应用程序上使用nsinteger而不是int来实现兼容性。

如果不同对象的结果为0,则会有碰撞哈希的风险。当使用依赖哈希函数的某些集合类时,碰撞哈希可能会导致意外的程序行为。确保在使用前测试哈希函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-(NSUInteger)hash {
    NSUInteger result = 1;
    NSUInteger prime = 31;
    NSUInteger yesPrime = 1231;
    NSUInteger noPrime = 1237;

    // Add any object that already has a hash function (NSString)
    result = prime * result + [self.myObject hash];

    // Add primitive variables (int)
    result = prime * result + self.primitiveVariable;

    // Boolean values (BOOL)
    result = prime * result + self.isSelected?yesPrime:noPrime;

    return result;
}


这帮了我很大的忙!可能是你想要的答案。实现相等和哈希


简单但效率低下的方法是为每个实例返回相同的-hash值。否则,是的,必须仅基于影响相等性的对象实现哈希。如果在-isEqual:中使用不严格的比较(例如不区分大小写的字符串比较),这是很棘手的。对于int,通常可以使用int本身,除非要与nsnumbers进行比较。

不过,不要使用=,它会饱和。用^ =代替。

随机有趣的事实:[[NSNumber numberWithInt:0] isEqual:[NSNumber numberWithBool:NO]],但[[NSNumber numberWithInt:0] hash] != [[NSNumber numberWithBool:NO] hash]。(RDAR://4538282,2006年5月5日开放)


记住,只有当isEqual为真时,才需要提供相等的散列值。当isEqual为假时,散列值不必是不等的,尽管可能是不等的。因此:

保持哈希简单。选择一个最有特色的成员(或少数成员)变量。

例如,对于clplacemark,名称就足够了。是的,有2个或3个完全相同的名称的区别clplacemark,但这些是罕见的。使用哈希。

1
2
3
4
5
@interface CLPlacemark (equal)
- (BOOL)isEqual:(CLPlacemark*)other;
@end

@implementation CLPlacemark (equal)

1
2
3
4
5
6
7
-(NSUInteger) hash
{
    return self.name.hash;
}


@end

请注意,我不需要指定城市、国家等。名称足够了。也许是名字和位置。

哈希应该均匀分布。因此,可以使用插入符号^(XOR符号)组合多个成员变量。

所以有点像

1
hash = self.member1.hash ^ self.member2.hash ^ self.member3.hash

这样散列将均匀分布。

1
Hash must be O(1), and not O(n)

那么在数组中该怎么做呢?

同样,简单。您不必散列数组的所有成员。足够散列第一个元素、最后一个元素、计数,或者一些中间元素,就这样。


等等,这样做当然要简单得多,首先重写- (NSString )description,并提供对象状态的字符串表示(必须在此字符串中表示对象的整个状态)。

然后,只需提供hash的以下实现:

1
2
3
- (NSUInteger)hash {
    return [[self description] hash];
}

这是基于"如果两个字符串对象相等(由isEqualToString:方法确定),则它们必须具有相同的哈希值"的原则。

源:nsstring类引用


我发现这个页面在重写等号和散列类型方法中是一个有用的指南。它包括一个计算散列码的合适算法。该页面面向Java,但很容易将其适应Objto-C/COCOA。


公平和散列契约在Java世界中被很好地指定和彻底研究(见@ MIPARDI的答案),但所有相同的考虑都应适用于Objtovi-C。

Eclipse在Java中生成这些方法是可靠的工作,所以这里是一个用手移植到Objtovi-C的Eclipse示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (BOOL)isEqual:(id)object {
    if (self == object)
        return true;
    if ([self class] != [object class])
        return false;
    MyWidget *other = (MyWidget *)object;
    if (_name == nil) {
        if (other->_name != nil)
            return false;
    }
    else if (![_name isEqual:other->_name])
        return false;
    if (_data == nil) {
        if (other->_data != nil)
            return false;
    }
    else if (![_data isEqual:other->_data])
        return false;
    return true;
}

- (NSUInteger)hash {
    const NSUInteger prime = 31;
    NSUInteger result = 1;
    result = prime * result + [_name hash];
    result = prime * result + [_data hash];
    return result;
}

对于增加属性serialNoYourWidget子类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (BOOL)isEqual:(id)object {
    if (self == object)
        return true;
    if (![super isEqual:object])
        return false;
    if ([self class] != [object class])
        return false;
    YourWidget *other = (YourWidget *)object;
    if (_serialNo == nil) {
        if (other->_serialNo != nil)
            return false;
    }
    else if (![_serialNo isEqual:other->_serialNo])
        return false;
    return true;
}

- (NSUInteger)hash {
    const NSUInteger prime = 31;
    NSUInteger result = [super hash];
    result = prime * result + [_serialNo hash];
    return result;
}

此实现避免了来自Apple的示例isEqual:中的一些子类化陷阱:

  • 苹果的类测试other isKindOfClass:[self class]对于MyWidget的两个不同的子类是不对称的。等式需要对称:如果且仅当b=a时,a=b。通过将测试改为other isKindOfClass:[MyWidget class]可以很容易地解决这一问题,那么所有MyWidget子类将相互比较。
  • 使用isKindOfClass:子类测试可以防止子类使用改进的等同性测试覆盖isEqual:。这是因为等式需要传递:如果a=b和a=c,则b=c。如果一个MyWidget实例与两个YourWidget实例比较相等,则这些YourWidget实例必须比较相等,即使它们的serialNo不同。

第二个问题可以通过只考虑对象是相等的(如果它们属于完全相同的类),因此这里的[self class] != [object class]测试可以解决。对于典型的应用程序类,这似乎是最好的方法。

但是,在某些情况下,isKindOfClass:测试更可取。这对于框架类比应用程序类更为典型。例如,任何NSString都应与具有相同基本字符序列的任何其他NSString进行比较,而不考虑NSStringNSMutableString的区别,也不考虑NSString类群中涉及的私有类。

在这种情况下,isEqual:应该具有定义明确、有文档记录的行为,并且应该明确指出子类不能覆盖这一点。在Java中,可以通过将Errase1和Hash码方法标记为EDCOX1(21)来强制执行"不重写"限制,但Objto-C没有等效项。


这根本不能直接回答你的问题,但我以前用过杂音散列来生成散列:杂音散列

我想我应该解释一下为什么:杂音很快…


我也是一个客观的C新手,但我在这里找到了一篇关于身份和平等的优秀文章。在我看来,您可能只需要保留默认的哈希函数(它应该提供唯一的标识)并实现isequal方法,以便它比较数据值。


奎因错了,这里对杂音散列的引用是无用的。奎因是对的,你想了解哈希背后的理论。杂音将许多理论提炼成一个实现。了解如何将该实现应用到这个特定的应用程序是值得探索的。

这里的一些要点:

tcurdt的示例函数表明"31"是一个很好的乘数,因为它是素数。一个人需要证明,成为质数是一个必要和充分的条件。事实上,31(和7)可能不是特别好的素数,因为31=-1%32。一个奇数乘法器,大约有一半的位被设置,一半的位被清除,可能会更好。(杂音散列乘法常数具有该属性。)

如果在乘法之后,通过移位和异或调整结果值,这种类型的哈希函数可能更强大。乘法往往在寄存器的高端产生大量位交互的结果,在寄存器的低端产生低交互的结果。移位和异或增加了寄存器底端的交互。

将初始结果设置为一个值,其中大约一半的位为零,大约一半的位为一,这也很有用。

注意元素组合的顺序可能很有用。我们应该首先处理布尔值和其他不强分布的元素。

在计算结束时添加几个额外的位置乱阶段可能很有用。

对于这个应用程序来说,杂音散列是否真的很快是一个悬而未决的问题。杂音散列将每个输入字的位预混合。可以并行处理多个输入字,这有助于多个问题的流水线CPU。


结合@tcurdt's answer和@oscar gomez's answer for get property name,我们可以为isequal和hash创建一个简单的嵌入式解决方案:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
NSArray *PropertyNamesFromObject(id object)
{
    unsigned int propertyCount = 0;
    objc_property_t * properties = class_copyPropertyList([object class], &propertyCount);
    NSMutableArray *propertyNames = [NSMutableArray arrayWithCapacity:propertyCount];

    for (unsigned int i = 0; i < propertyCount; ++i) {
        objc_property_t property = properties[i];
        const char * name = property_getName(property);
        NSString *propertyName = [NSString stringWithUTF8String:name];
        [propertyNames addObject:propertyName];
    }
    free(properties);
    return propertyNames;
}

BOOL IsEqualObjects(id object1, id object2)
{
    if (object1 == object2)
        return YES;
    if (!object1 || ![object2 isKindOfClass:[object1 class]])
        return NO;

    NSArray *propertyNames = PropertyNamesFromObject(object1);
    for (NSString *propertyName in propertyNames) {
        if (([object1 valueForKey:propertyName] != [object2 valueForKey:propertyName])
            && (![[object1 valueForKey:propertyName] isEqual:[object2 valueForKey:propertyName]])) return NO;
    }

    return YES;
}

NSUInteger MagicHash(id object)
{
    NSUInteger prime = 31;
    NSUInteger result = 1;

    NSArray *propertyNames = PropertyNamesFromObject(object);

    for (NSString *propertyName in propertyNames) {
        id value = [object valueForKey:propertyName];
        result = prime * result + [value hash];
    }

    return result;
}

现在,在定制类中,您可以轻松地实现isEqual:hash

1
2
3
4
5
6
7
8
9
- (NSUInteger)hash
{
    return MagicHash(self);
}

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

注意,如果您正在创建一个可以在创建后改变的对象,那么如果将对象插入到集合中,哈希值就不能改变。实际上,这意味着散列值必须从初始对象创建点开始固定。有关更多信息,请参阅苹果公司有关nsObject协议哈希方法的文档:

If a mutable object is added to a collection that uses hash values to determine the object’s position in the collection, the value returned by the hash method of the object must not change while the object is in the collection. Therefore, either the hash method must not rely on any of the object’s internal state information or you must make sure the object’s internal state information does not change while the object is in the collection. Thus, for example, a mutable dictionary can be put in a hash table but you must not change it while it is in there. (Note that it can be difficult to know whether or not a given object is in a collection.)

这听上去让我完全不安,因为它可能会有效地降低哈希查找的效率,但我认为最好是谨慎地出错,并遵循文档中的说明。


很抱歉,如果我在这里冒着听起来完全是胡说八道的风险,但是……没有人费心提到要遵循"最佳实践",你绝对不应该指定一个不考虑目标对象拥有的所有数据的equals方法,例如,在实现equals时,应该考虑到聚合到对象的任何数据,而不是它的关联数据。如果您不想考虑,在比较中考虑"年龄",那么您应该编写一个比较器,并使用它来执行比较,而不是IsEqual:。

如果您定义了一个任意执行相等比较的IsEqual:方法,那么一旦您忘记了Equals解释中的"扭曲",您就可能会面临另一个开发人员甚至您自己误用此方法的风险。

因此,尽管这是关于散列的一个很好的问题,但通常您不需要重新定义散列方法,而是应该定义一个特别的比较器。