Android 中通用的 equals 与 hashCode 实现

Java 项目的数据模型类常常需要实现 equals 与 hashCode 方法,以保证包含这些类实例的各种标准集合操作正常。而 Android 系统因为 Java 版本问题,实现起来也有其他注意的要点。

前提

equals 方法用于判断两个同一类的实例是否逻辑上相等,如果不提供自定义实现,那么一般默认的实现是判断两个实例是否为同一个对象(即 == 操作符)。 hashCode 方法用于进行 hash 操作,为不同的实例输出不同的哈希代码。而只要实现了自定义的 equals 方法,就要自己定义 hashCode 方法。所以大部分时候只要考虑 equals 的实现要求。

实际上,并不是每一个类都要实现 equals 方法,一般以下的情况不需要实现 equals 方法:

  • 类的每个实例本质上都是唯一的,如:Thread
  • 不关心任意两个实例是否“逻辑相等”
  • 父类 equals 适用于子类
  • 类是私有的或包级私有的,equals 方法不会被调用
  • 如果“值类”能确保每个值对应一个对象,比如枚举类 这种时候基类 Object 的 equals 与 hashCode 方法就足够了。

其他情况下,尤其是要存入标准集合中的“值类”则需要实现 equals 与 hashCode 方法。

约定

实现自定义 equals 时要遵守的约定(对应同一个类的实例 x 和 y):

  • 自反性:非空 x 的 x.equals(x) 为 true
  • 对称性:非空 x 和 y,当且仅当 x.equals(y) 为 true 时,y.equals(x) 为 true
  • 传递性:非空 x、y 和 z,如果 x.equals(y) 和 y.equals(z) 为 true,则 x.equals(z) 为 true
  • 一致性:非空 x 和 y,如果它们比较所用的信息没有更改,则 equals 结果也不能改变
  • 非空性:非空 x 必有 x.equals(null) 为 false

对于某些约定又有特别要注意的情况是:

  • 传递性:父类如果可实例化,且子类增加了新的值域,则无法保留 equals 约定
    • 如果一定要保证约定,可以考虑使用聚合代替继承关系,并在聚合类中提供公有的视图方法返回对应原有类实例。
    • Java 类库中的 java.sql.Timestamp 扩展了 java.util.Date,就违反了 equals 约定,注意不要混用它们
    • 抽象父类则不必有此顾虑
  • 一致性:equals 不应该依赖不可靠的资源,比如:Java 类库的 java.net.URL 的 equals 依赖对 URL 中主机 IP 地址的比较,不确保结果不变,违反了约定
  • 非空性:如果实例 x 为 null,则不应该抛出 NullPointerException,而是返回 false

hashCode 要遵守的约定:

  • 应用程序执行期间,equals 所用的信息没有修改,则多次调用 hashCode 返回信息必须相同。同一应用程序多次执行的返回结果可以不同。
  • 两个对象 equals 为 true,则 hashCode 也必须相等
  • 两个对象 equals 为 false,则 hashCode 不一定要不同,但不同的结果更好,更利于提高散列表的性能

最佳实践原则

设计高质量 equals 的要点有:

  1. 使用 == 操作符检查是否为相同引用,是则返回 true(实际是对重型对象的优化)
  2. 使用 instanceof 检查是否为正确类型,否则返回 false
  3. 把参数转换为正确的类型
  4. 对于类中比较需要的关键域,依次检查是否相等
    • 不是 floatdouble 的基本类型使用 ==
    • floatdouble 使用 Float.compareDouble.compare
    • 如果数组所有元素都很关键,则使用 Arrays.equals
    • 如果为其他一般对象,则有递归调用 equals 方法,同时注意:
      • 如果可能为空,则对于参数 o 有:field == null ? o.field == null : field.equals(o.field)
      • 如果域常常是相同的对象引用,则更高效的方式为:field == o.field || (field != null && field.equals(o.field))
      • 如果对象极为复杂,可考虑使用与它的值对应的替代值进行比较
    • 先比较可能不等的域,或者比较开销较低的域,最好两者兼备
    • 不要比较无关比较逻辑的域
    • 不要比较可以由其他域推导出的域
  5. 编写完成后,进行测试

另外,还有注意有些操作是要极力避免的:

  • 不要让 equals 处理过于负责的相等逻辑,复杂的相等判断属于业务代码
  • 不要把参数改成其他类型(可以标明 @Override 注解,IDE 会智能提醒)

Java 标准库提供了 Objects.equals(Object, Object) 方法,可以方便的判断两个对象是否逻辑相等,而免去大量的条件判断,最好在判断对象的域的时候使用它。但是,要注意这个方法会调用参数对象的 equals 方法,所以不要对没有定义 equals 方法的域使用,总的来说尽量只对基本类型使用它。 但是对于 Android 项目要特别注意,Objects.equals(Object, Object) 方法是在 api 19(KitKat)中引入的,如果 minSdkVersion 的数值小于 19 的话不能很好的使用,必须加上注解 @RequiresApi(api = Build.VERSION_CODES.KITKAT) 或者 @TargetApi(api = Build.VERSION_CODES.KITKAT) 标明方法的适用范围。 但是,使用注解标明的办法使得方法在 api 19 以下的系统中仍然无法正常运行。参考 Objects.equals(Object, Object) 的源代码,可以得到 Android 系统也可正常使用的方法:

1
2
3
@CheckResult public static boolean equals(@Nullable Object a, @Nullable Object b) {
    return a == b || (a != null && a.equals(b));
}

一般来说,哈希算法没有过于强硬的实现规定,但仍有一套自行设计 hashCode 方法的简单方案:

  1. 声明结果变量 result,令其为非零常数,如:17
  2. 对每个 equals 使用的关键域 f,完成以下步骤: a. 计算其散列码 code:
    • 如果是 boolean 类型,则 code = f ? 1 : 0
    • 如果是 bytecharshortint 类型,则 code = (int) f
    • 如果是 long 类型,则 code = f ^ (f >>> 32)
    • 如果是 float 类型,则 code = Float.floatToIntBits(f)
    • 如果是 double 类型,则先计算 long temp = Double.doubleToLongBits(f) 再将 temp 当作域内容执行 long 类型计算,即 code = temp ^ (temp >>> 32)
    • 如果是对象引用,则递归调用 hashCode;如果对象极为复杂,则考虑对它的替代只使用 hashCode,但替代值必须和它的值对应
    • 如果数组每个元素都很重要,则 code = Arrays.hashCode(f) b. 把散列码 code 合并到结果中:result = 31 * result + code
  3. 返回 result
  4. 测试结果是否符合约定

与 equals 相同,Objects 类中也有工具方法 Objects.hash(Object...) 来辅助计算多个对象的哈希数值综合结果。同时,也类似 Objects.equals(Object, Object) 的使用条件,要保证所有参数都有可用的 hashCode 实现。 另外,在 Android 中同样注意要保证 api 19 以下的版本也可正常运行方法。注意到 Objects.hash(Object...) 实际调用了 Arrays.hashCode(Objcet...) 方法,而此方法并在 api 19 以下仍可正常使用,所以 Android 系统通常应该调用 Arrays.hashCode(Objcet...) 方法。

实际例子

这里使用一个简单的例子来说明 equals 与 hashCode 的基本使用:

 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
public final class User {

    @NonNull private final String mEmail;

    @NonNull private final String mPassword;

    public User(@NonNull String email, @NonNull String password) {
        mEmail = email;
        mPassword = password;
    }

    @NonNull public String getEmail() {
        return mEmail;
    }

    @NonNull public String getPassword() {
        return mPassword;
    }

    @Override public int hashCode() {
        return Arrays.hash(mEmail, mPassword);
    }

    @Override public boolean equals(Object obj) {
        if (this == obj) return true;
        if (!(obj instanceof User)) return false;
        User user = (User) obj;
        return ObjectUtils.equals(mEmail, user.mEmail)
            && ObjectUtils.equals(mPassword, user.mPassword);
    }

    @Override public String toString() {
        return String.format("User with email %s", mEmail);
    }
}

public class ObjectUtils {

    private ObjectUtils() {}

    @CheckResult private static boolean equals(@Nullable Object a, @Nullable Object b) {
        return a == b || (a != null && a.equals(b));
    }
}

这里 @CheckResult 注解保证方法的结果必须被使用,防止其他使用这个工具方法的用户误解它的使用方法。

参考文章

《Effective Java(中文第二版)》 第 3 章:

  • 第 8 条:覆盖 equals 时请遵守通用约定
  • 第 9 条:覆盖 equals 时总要覆盖 hashCode