使用重写后的equals覆盖hashCode,使用equalsIgnoreCase检查相等性

9

我目前有一个重写的equals(Object)方法,代码如下:

@Override
public boolean equals(Object o) {
    if (o == this) return true;
    if (! (o instanceof Player)) return false;
    Player p = (Player) o;
    return getFirstName().equalsIgnoreCase(p.getFirstName()) && 
            getLastName().equalsIgnoreCase(p.getLastName());
}

我的hashCode()目前看起来是这样的:

@Override
public int hashCode() {
    int result = 17;
    result = 31 * result + getFirstName().toLowerCase().hashCode();
    result = 31 * result + getLastName().toLowerCase().hashCode();
    return result;
}

我的问题涉及到覆盖了hashCode()方法。我知道如果两个对象被equals(Object)方法认为是相等的,那么它们的hashCode()方法需要返回相同的值。但是我有些担心,在某些情况下这个hashCode()方法会违反合同。

有没有一种可接受的方式在重写equals(Object)方法中使用equalsIgnoreCase(String)方法并生成不违反合同的哈希码?


在hashCode()中,result = 31...应该改为result *= 31...这样就不会丢失已经存在的值。 - Patashu
1
他在方程式中得到了结果,31 * 结果 + (其他内容)。所以它并没有丢失。只是我的个人意见,但我认为你的方法是正确的。你的等于方法看起来很不错。 - Kyle
为什么你的代码会违反合同?你的直觉可能会紧张,不要听它的话 ;) - ddmps
1
我可能有点过于谨慎了,但我不完全确定equalsIgnoreCase()和toLowerCase()方法在特殊字符和不同语言环境下的处理方式。我不认为这会应用于此应用程序,但我正在尽可能做到万无一失,以养成好习惯。 - Jazzer
传统智慧认为,您永远不应该依赖默认的“Locale”,而应始终使用带有显式“Locale”的String.toLowerCase(Locale)。否则,您将遇到“臭名昭著的土耳其语Locale错误”。 - Robert Tupelo-Schneck
5个回答

5
@Override
public int hashCode() {
    int result = 17;
    result = 31 * result + characterwiseCaseNormalize(getFirstName()).hashCode();
    result = 31 * result + characterwiseCaseNormalize(getLastName()).hashCode();
    return result;
}

private static String characterwiseCaseNormalize(String s) {
    StringBuilder sb = new StringBuilder(s);
    for(int i = 0; i < sb.length(); i++) {
        sb.setCharAt(i,Character.toLowerCase(Character.toUpperCase(sb.charAt(i))));
    }
    return sb.toString();
}

这个 hashCode 将与使用 equalsIgnoreCase 定义的 equals 一致。原则上,根据 equalsIgnoreCase 的契约,这似乎依赖于它确实是这种情况。
Character.toLowerCase(Character.toUpperCase(c1))==Character.toLowerCase(Character.toUpperCase(c2))

每当
Character.toLowerCase(c1)==Character.toLowerCase(c2).  

我无法证明这是真的,但OpenJDK的equalsIgnoreCase实现与此方法保持一致;它先检查对应字符是否相等,然后检查它们的大写版本是否相等,最后检查大写版本的小写版本是否相等。

String.compareToIgnoreCase 明确使用了这个方法。 - Robert Tupelo-Schneck
我会支持新的方法,但你应该非常小心。Javadocs甚至警告你:“通常应使用String.toLowerCase()将字符映射为小写。与Character大小写映射方法相比,String大小写映射方法具有几个优点。字符串大小写映射方法可以执行区域设置敏感的映射、上下文敏感的映射和1:M字符映射,而Character大小写映射方法则不能。”此外,这种行为似乎不被规范保证,因此它可能会改变其他方面。小心! - Steven Schlansker
1
我认为基于Character大小写映射方法的String.equalsIgnoreCase()(和String.compareToIgnoreCase())应该具有相同的注意事项。在编写与equals()一致的hashCode()方面,您应该在两者中使用基于Character的大小写映射或基于String的大小写映射。实际上,原问题提出者可能真正想保留他的hashCode()方法并将他的equals()方法更改为使用s1.toLowerCase().equals(s2.toLowerCase())而不是equalsIgnoreCase() - Robert Tupelo-Schneck

2

你说得对。我们可以循环遍历所有单个字符的字符串,并找到配对的s1、s2,这些字符串具有s1.equalsIgnoreCase(s2) && !s1.toLowerCase().equals(s2.toLowerCase())的关系。这里有相当多的配对。例如:

s1=0049   'LATIN CAPITAL LETTER I'
s2=0131   'LATIN SMALL LETTER DOTLESS I'

s1.lowercase = 0069   'LATIN SMALL LETTER I'
s2.lowercase = 0131   itself

这也取决于语言环境:对于s1,土耳其语和阿塞拜疆语使用U+0131作为小写字母(参见http://www.fileformat.info/info/unicode/char/0049/index.htm)。


1

在编写与equals()一致的hashCode()时,应该在两个方法中使用基于Character的大小写映射或基于String的大小写映射。在我的另一个答案中,我展示了如何使用基于Character的大小写映射编写hashCode()的方法;但还有另一种解决方案,即改变equals()方法,使用基于String的大小写映射。(请注意,String.equalsIgnoreCase()使用基于Character的大小写映射。)

@Override
public boolean equals(Object o) {
    if (o == this) return true;
    if (! (o instanceof Player)) return false;
    Player p = (Player) o;
    return getFirstName().toLowerCase().equals(p.getFirstName().toLowerCase()) && 
        getLastName().toLowerCase().equals(p.getLastName().toLowerCase());
}

在某些情况下,实际上,您确实希望对字符串使用一些花哨的Unicode规范化以及大小写折叠。请参见http://userguide.icu-project.org/transforms/normalization。 - Robert Tupelo-Schneck

1

你担心是对的。阅读 equalsIgnoreCase 的合同

如果以下至少有一个条件为真,则认为两个字符 c1 和 c2 相同(通过 == 运算符进行比较):

  • 这两个字符相同(由 == 运算符比较)
  • 应用方法 Character.toUpperCase(char) 对每个字符产生相同的结果
  • 应用方法 Character.toLowerCase(char) 对每个字符产生相同的结果

因此,如果有一个字符在转换为大写后相等,但反之则不相等,那么你就会遇到麻烦。

让我们以德语字符ß为例,当转换为大写时会变成两个字符序列SS。这意味着字符串"ß"和"SS"在"equalsIgnoreCase"时相同,但转换为小写时表示不同!因此,你的方法是错误的。不幸的是,我不确定你是否能设计出一个适当表达你需求的hashCode。

所以以字符ß为例,如果我们有一个名字为“ßilly ßob”的玩家,与另一个名为“SSilly SSob”的玩家进行equalsIgnoreCase比较将使它们在equalsIgnoreCase的眼中相等,但会生成两个不同的哈希码(问题)。 假设这对我的应用程序来说是“可以接受的”,那么我们是否可以使用toUpperCase而不是toLowerCase来生成在equalsIgnoreCase下被视为相等时相等的hashCode? - Jazzer
我相信你也可以找到另一种方式的反例。 - Steven Schlansker
@Jazzer:equalsIgnoreCase 是否定义了一个等价关系,即是否不可能存在三个字符串 x、y 和 z,使得 x.equalsIgnoreCase(y) 和 y.equalsIgnoreCase(z),但是 x.equalsIgnoreCase(z) 不成立?听起来 "ß".equalsIgnoreCase("SS") 会返回 true,"ss".equalsIgnoreCase("SS") 也会返回 true,但是 "ß".equalsIgnoreCase("ss") 会返回 false。如果用不实现等价关系的函数覆盖 equals,即使 hashCode 对于匹配的字符串始终返回匹配值,也会出现问题。 - supercat
"ß".equalsIgnoreCase("SS")是错误的,因为equalsIgnoreCase使用Character.toUpperCaseCharacter.toLowerCase而不是String.toUpperCaseString.toLowerCase。这为equalsIgnoreCase一致的hashCode提供了希望;请参见我的答案。 - Robert Tupelo-Schneck

0

@Override
public boolean equals(Object o) {

    if (o == this) {
      return true;
    }
   
   if (o == null) {
       return false;
   }

   if (! (o instanceof Player)) {
       return false;
   }

    Player p = (Player) o;

    return equalsIgnoreCase(this.getFirstName(), p.getFirstName())
           && 
           equalsIgnoreCase(this.getLastName(), p.getLastName());

}


public boolean equalsIgnoreCase (String s1, String s2) {

   if (s1 == null && s2 == null) {
        return true;
    }
        
    if (s1 != null) {
        return s1.equalsIgnoreCase(s2);
    } else {
        return s2.equalsIgnoreCase(s1);
    }

}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接