第三章 对象的通用方法
10. 覆盖 equals 方法时应遵守的约定
不覆盖equals方法的情况
-
类的每个实例本质上都是唯一的。例如Thread类,它是活动实体类而不是值类
-
该类不需要提供逻辑相等测试。例如
java.util.regex.Pattern
可以覆盖equals方法来检查两个Pattern实例是否表示完全相同的正则表达式,但是这个类的设计人员认为客户端不需要这个功能,所以没有覆盖 -
父类已经覆盖了equals方法,父类的行为也适合于这个类。例如大多数Set的equals从AbstractSet继承,List从AbstractList继承,Map从AbstractMap继承
-
类是私有的并且你确信它的equals方法永远不会被调用。保险起见,你可以按如下方式覆盖equals方法,以确保它不会被意外调用
@Override public boolean equals(Object o) { throw new AssertionError(); // Method is never called }
覆盖equals方法的时机
当一个类有一个逻辑相等的概念,而这个概念不同于仅判断对象的同一性(相同对象的引用),并且父类没有覆盖 equals。对于值类通常是这样。值类只是表示值的类,例如 Integer 或 String。程序员希望发现它们在逻辑上是否等价,而不是它们是否引用相同的对象。覆盖 equals 方法不仅是为了满足程序员的期望,它还使实例能够作为 Map 的键或 Set 元素时,具有可预测的、理想的行为。
单例模式的值类不需要覆盖equals方法。例如,枚举类型就是单例值类。逻辑相等就是引用相等。
覆盖equals方法的规范
-
反身性:对于任何非空的参考值x,
x.equals(x)
必须返回true -
对称性:
x.equals(y)
与y.equals(x)
的值要么都为true要么为false -
传递性:对于非空引用x,y,z,如果
x.equals(y)
返回true,y.equals(z)
返回true,那么x.equals(z)
也返回true -
一致性:对于任何非空的引用值 x 和 y,
x.equals(y)
的多次调用必须一致地返回 true 或一致地返回 false,前提是不修改 equals 中使用的信息。 -
非空性:对于非空引用x,
x.equals(null)
返回false,不需要显示判断是否为null,因为equals方法需要将参数转为成相同类型,转换之前会使用instanceof运算符来检查类型是否正确,如果为null,也会返回false@Override public boolean equals(Object o) { if (!(o instanceof MyType)) return false; MyType mt = (MyType) o; ... }
高质量构建equals方法的步骤
1、使用==检查参数是否是this对象的引用,如果是,返回true,这是一种性能优化,如果比较的开销很大,这种优化很有必要
2、使用instanceof运算符检查参数是否有正确类型。
3、将参数转换为正确的类型。因为在转换前进行了instanceof判断,所以肯定可以强转成功
4、对类中的每个有意义的字段检查是否和参数的相应字段匹配
对于不是float和double的基本类型字段,使用==比较;对应对象引用字段,递归调用equals方法;对于float字段,使用静态方法Float.compare(float,float)
方法;对于double字段,使用Double.compare(double,double)
。float和double字段的特殊处理是由于Float.NaN
,-0.0f 和类似的双重值的存在;对于数组字段,使用Arrays.equals
方法。
一些对象引用字段可能允许null出现。为了避免可能出现NullPointerException,请使用Objects.equals(Object,Object)
来检查对象的字段是否相等。
对于某些类,字段比较非常复杂,如果是这样,可以存储字段的规范形式,以便equals方法进行开销较小的比较。这种技术最适合于不可变类;如果对象可以修改,则必须使规范形式保持最新
equals 方法的性能可能会受到字段比较顺序的影响。为了获得最佳性能,你应该首先比较那些更可能不同、比较成本更低的字段。不能比较不属于对象逻辑状态的字段,例如用于同步操作的锁字段。派生字段可以不比较,这样可以提高性能,但是如果派生字段包括了对象的所有信息,比如说多边形面积,可以从边和顶点计算得出,那么先比较面积,面积不一样就肯定不是同一个对象,这样可以减小开销
一些警告
- 覆盖equals方法时,也要覆盖hashCode方法
- 考虑任何形式的别名都不是一个好主意。例如,File类不应该尝试将引用同一文件的符号链接等同起来
- 不要用别的类型替换equals方法的Object类型
11. 当覆盖equals方法时,总是覆盖hashCode方法
Object类中hashCode方法的规范
- 应用程序执行期间对对象重复调用hashCode方法,它必须返回相同的值,前提是不修改equals方法中用于比较的信息。这个值不需要在应用程序的不同执行之间保持一致
- 如果
equals(Object)
方法返回true,那么在这两个对象上调用hashCode方法必须产生相同的整数结果 - 如果
equals(Object)
方法返回false,hashCode方法的值不需要一定不同,但是,不同对象的hashCode不一样可以提高散列表性能
当没有覆盖hashCode方法时,将违反第二条规范:逻辑相等的对象必须有相等的散列码。两个不同的对象在逻辑上是相等的,但它们的hashCode一般不相等。例如用Item-10中的PhoneNumber类实例作为HashMap的键
Map<PhoneNumber, String> m = new HashMap<>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");
此时,你可能期望m.get(new PhoneNumber(707, 867,5309))
返回「Jenny」,但是它返回 null。因为PhoneNumber类没有覆盖hashCode方法,插入到HashMap和从HashMap中获取的实例具有不相同的散列码,这违法了hashCode方法规范。因此,get方法查找电话号码的散列桶与put方法存储电话号码的散列桶不同。
解决这个问题有一个最简单但很糟糕的实现
// The worst possible legal hashCode implementation - never use!
@Override
public int hashCode() { return 42; }
它确保了逻辑相等的对象具有相同的散列码。同时它也很糟糕,因为每个对象的散列码都相同了。每个对象都分配到一个存储桶中,散列表退化成链表。
散列算法设计步骤
一个好的散列算法大概率为逻辑不相等对象生成不相同的散列码。理想情况下,一个散列算法应该在所有int值上均匀合理分布所有不相等对象。实现理想情况很困难,但实现一个类似的并不难,这里有一个简单的方式:
1、声明一个名为 result 的int变量,并将其初始化为对象中第一个重要字段的散列码c,如步骤2.a中计算的那样
2、对象中剩余的重要字段f,执行以下操作:
a. 为字段计算一个整数散列码c : 如果字段是基本数据类型,计算Type.hashCode(f)
,其中type是 f 类型对应的包装类 ; 如果字段是对象引用,并且该类的equals方法通过递归调用equals方法来比较字段,则递归调用字段上的hashCode方法。如果需要更复杂的比较,则为该字段计算一个【canonical representation】,并在canonical representation上调用hashCode方法。如果字段的值为空,则使用0(或其它常数,但0是惯用的);如果字段是一个数组,则对数组中每个重要元素都计算散列码,并用2.b步骤逐个组合。如果数组中没有重要元素,则使用常量,最好不是0。如果所有元素都很重要,那么使用Arrays.hashCode
b. 将2.a步骤中计算的散列码合并到result变量,如下所示
result = 31 * result + c;
3、返回result变量
步骤2.b中的乘法说明result依赖字段的顺序,如果类具有多个类似字段,那么乘法会产生更好的hash性能。例如字符串hash算法中如果省略乘法,那么不同顺序的字符串都会有相同的散列码。
选择31是因为它是奇素数。如果是偶数,乘法运算就会溢出,信息就会丢失,因为乘法运算等同于移位。使用素数的好处不太明显,但它是传统用法。31有一个很好的特性,可以用移位和减法来代替乘法,从而在某些体系结构上获得更好的性能:31 * i == (i <<5) – i
。现代虚拟机自动进行这种优化。
根据前面的步骤,给PhoneNumber类写一个hashCode方法
// Typical hashCode method
@Override
public int hashCode() {
int result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
return result;
}
因为这个方法返回一个简单的确定的计算结果,它的唯一输入是 PhoneNumber 实例中的三个重要字段,所以很明显,相等的 PhoneNumber 实例具有相等的散列码。实际上,这个方法是 PhoneNumber 的一个非常好的 hashCode 方法实现,与 Java 库中的 hashCode 方法实现相当。它很简单,速度也相当快,并且合理地将不相等的电话号码分散到不同的散列桶中。
虽然这个Item里的方法可以提供一个相当不错的散列算法,但它不是最先进的,对于大多数用途是足够的,如果需要不太可能产生冲突的散列算法。请参阅 Guava 的 com.google.common.hash.Hashing
Objects类有一个静态方法,它接受任意数量的对象并返回它们的散列码。这个名为hash
的方法允许你编写只有一行代码的hashCode方法,它的质量可以与本Item提供的编写方法媲美。但不幸的是它们运行得很慢,因为它需要创建数组来传递可变数量的参数,如果有参数是原始类型的,则需要进行装箱和拆箱。推荐只在性能不重要的情况下使用这种散列算法。下面是使用这个静态方法编写的PhoneNumber的散列算法
// One-line hashCode method - mediocre performance
@Override
public int hashCode() {
return Objects.hash(lineNum, prefix, areaCode);
}
缓存散列值
如果类是不可变的,并且计算散列码的成本非常高,那么可以考虑在对象中缓存散列码,而不是每次调用重新计算。如果这个类的对象会被用作散列键,那么应该在创建对象时就计算散列码。要不然就在第一次调用时计算散列码
12. 始终覆盖toString方法
虽然 Object 提供 toString 方法的实现,但它返回的字符串通常不是类的用户希望看到的。它由后跟「at」符号(@)的类名和散列码的无符号十六进制表示(例如 PhoneNumber@163b91)组成。toString 的通用约定是这么描述的,返回的字符串应该是「简洁但信息丰富的表示,易于阅读」。虽然有人认为 PhoneNumber@163b91 简洁易懂,但与 707-867-5309 相比,它的信息量并不大。toString 约定接着描述,「建议所有子类覆盖此方法。」好建议,确实!
虽然它不如遵守euals和hashCode约定(Item10和Item11)那么重要,但是提供一个好的toString方法更便于调试。当对象被传递给 println、printf、字符串连接操作符或断言或由调试器打印时,将自动调用 toString 方法。即使你从来没有调用 toString 对象,其他人也可能使用。例如,使用该对象的组件可以在日志错误消息中包含对象的字符串表示。如果你不覆盖 toString,该消息可能完全无用。
13. 明智地覆盖clone方法
Cloneable接口的作用
Cloneable接口的作用是声明类可克隆,但是接口不包含任何方法,类的clone方法继承自Object,并且Object类的clone方法是受保护的,无法跨包调用,虽然可以通过反射调用,但也不能保证对象具有可访问的 clone 方法(如果类没有覆盖clone方法可以通过获取父类Object调用clone方法,但是如果类没实现Cloneable接口调用会抛出CloneNotSupportedException)。
既然 Cloneable 接口不包含任何方法,用它来做什么呢?它决定了 Object 类受保护的 clone 实现的行为:如果一个类实现了 Cloneable 接口,Object 类的 clone 方法则返回该类实例的逐字段拷贝;没实现 Cloneable 接口调用 clone 方法会抛出 CloneNotSupportedException。这是接口非常不典型的一种使用方式,不应该效仿。通常,类实现接口可以表明类能够为其客户端做些什么。在本例中,它修改了父类上受保护的方法的行为。
clone 方法规范
虽然规范没有说明,但是在实践中,实现 Cloneable 接口的类应该提供一个功能正常的 public clone 方法。
clone方法的一般约定很薄弱。下面的内容是从Object规范复制过来的
Creates and returns a copy of this object. The precise meaning of “copy” may depend on the class of the object. The general intent is that, for any object x,the expression
x.clone() != x
will be true, and the expression
x.clone().getClass() == x.getClass()
will be true, but these are not absolute requirements. While it is typically the case that
x.clone().equals(x)
will be true, this is not an absolute requirement.
clone方法创建并返回对象的副本。「副本」的确切含义可能取决于对象的类。通常,对于任何对象 x,表达式 x.clone() != x、x.clone().getClass() == x.getClass() 以及 x.clone().equals(x) 的值都将为 true,但都不是绝对的。(equals方法应覆盖为比较对象中的字段才能得到true,默认实现是比较对象地址,结果永远为false)
按照约定,clone方法返回的对象应该通过调用super.clone() 来获得。如果一个类和它的所有父类(Object类除外)都遵守这个约定,表达式 x.clone().getClass() == x.getClass()
则为 true
按照约定,返回的对象应该独立于被克隆的对象。为了实现这种独立性,可能需要在super.clone() 前修改对象的一个或多个字段
这种机制有点类似于构造方法链,只是没有强制执行:
- 如果一个类的clone方法返回的实例不是通过调用 super.clone() 而是通过调用构造函数获得的,编译器不会报错,但是如果这个类的子类调用super.clone(),由此产生的对象将是错误的,影响子类clone方法正常工作
- 如果覆盖clone方法的类是final修饰的,那么可以忽略这个约定,因为不会有子类
- 如果一个final修饰的类的clone方法不调用super.clone()。该类没有理由实现Cloneable接口,因为它不依赖于Object的clone方法
覆盖clone方法
- 如果类是不可变的,不要提供clone方法
- 如果类的字段都是基本类型或不可变对象的引用,那么直接这个类的clone方法直接调用super.clone()即可
- 如果类的字段包含可变对象的引用,需要递归调用可变对象的深拷贝方法
一些细节
-
和构造函数一样,不要在clone方法中调用可覆盖方法。如果clone方法调用一个在子类中被覆盖的方法,这个方法将在子类修复其在克隆中的状态之前执行,很可能导致克隆和原始对象的破坏。
-
Object的clone方法被声明为抛出CloneNotSupportedException,但是覆盖方法时 try-catch 异常就行,不抛出受检查异常的方法更容易使用
-
设计可继承的类时不要实现 Cloneable接口(如果实现了,子类就必须对外提供clone方法)。你可以选择通过实现一个功能正常的受保护克隆方法来模拟 Object 的行为,该方法声明为抛出 CloneNotSupportedException。这给子类实现 Cloneable 或不实现 Cloneable 的自由。或者,你可以选择不实现一个有效的克隆方法,并通过提供以下退化的克隆实现来防止子类实现它:
// clone method for extendable class not supporting Cloneable @Override protected final Object clone() throws CloneNotSupportedException { throw new CloneNotSupportedException(); }
-
如果你编写了一个实现了 Cloneable 接口的线程安全类,请记住它的 clone 方法必须正确同步,就像其他任何方法一样。
总结
回顾一下,所有实现 Cloneable 接口的类都应该使用一个返回类型为类本身的公有方法覆盖 clone。这个方法应该首先调用 super.clone(),然后「修复」任何需要「修复」的字段。通常,这意味着复制任何包含对象内部「深层结构」的可变对象,并将克隆对象对这些对象的引用替换为对其副本的引用。虽然这些内部副本通常可以通过递归调用 clone 来实现,但这并不总是最好的方法。如果类只包含基本数据类型的字段或对不可变对象的引用,那么很可能不需要修复任何字段。这条规则也有例外。例如,表示序列号或其他唯一 ID 的字段需要修复,即使它是基本数据类型或不可变的。
搞这么复杂真的有必要吗?答案是否定的。如果你扩展了一个已经实现了 Cloneable 接口的类,那么除了实现行为良好的 clone 方法之外,你别无选择。否则,最好提供对象复制的替代方法。一个更好的对象复制方法是提供一个复制构造函数或复制工厂。复制构造函数是一个简单的构造函数,它接受单个参数,其类型是包含构造函数的类,例如
// Copy constructor
public Yum(Yum yum) { ... };
复制工厂与复制构造函数的静态工厂(Item-1)类似:
// Copy factory
public static Yum newInstance(Yum yum) { ... };
复制构造函数方法及其静态工厂变体与克隆方法相比有许多优点:
- 它们不依赖于易发生风险的语言外对象创建机制(Object的clone方法是native的);
- 他们不要求无法强制执行的约定(clone方法一定要先调用super.clone());
- 它们与 final 字段不冲突(clone方法不能修改final字段);
- 它们不会抛出不必要的 checked 异常;
- 而且不需要强制类型转换。
- 可以提供类型转换构造函数
考虑到与 Cloneable 相关的所有问题,新的接口不应该继承它,新的可扩展类不应该实现它。通常,复制功能最好由构造函数或工厂提供。这个规则的一个明显的例外是数组,最好使用 clone 方法来复制数组。
14. 考虑实现 Comparable 接口
与本章讨论的其它方法不同,compareTo 方法不是 Object 中声明的,而是 Comparable 接口中的唯一方法。一个类实现Comparable,表明实例具有自然顺序(字母或数字或时间顺序)。Java 库中的所有值类以及所有枚举类型(Item-34)都实现了 Comparable接口
compareTo方法约定
compareTo 方法的一般约定类似于 equals 方法:
将一个对象与指定对象进行顺序比较。当该对象小于、等于或大于指定对象时,对应返回一个负整数、零或正整数。如果指定对象的类型阻止它与该对象进行比较,则抛出 ClassCastException
在下面的描述中, sgn(expression)
表示数学中的符号函数,它被定义为:根据传入表达式的值是负数、零或正数,对应返回-1、0或1。
- 实现类必须确保所有 x 和 y 满足
sgn(x.compareTo(y)) == -sgn(y.compareTo(x))
(这意味着 x.compareTo(y) 当且仅当 y.compareTo(x) 抛出异常时才抛出异常) - 实现类还必须确保关系是可传递的:
(x.compareTo(y) > 0 && y.compareTo(z) > 0)
意味着x.compareTo(z) > 0
- 最后,实现类必须确保
x.compareTo(y) == 0
时,所有的 z 满足sgn(x.compareTo(z)) == sgn(y.compareTo(z))
- 强烈建议
(x.compareTo(y)== 0) == (x.equals(y))
成立,但这不是必需的。一般来说,任何实现 Comparable 接口并违法此条件的类都应该清除地注明这一事实。推荐的表述是“Note: This class has a natural ordering that is inconsistent with equals.”
与equals方法不同,equals方法入参是Object类型,而compareTo方法不需要和不同类型的对象比较:当遇到不同类型的对象时,允许抛出ClassCastException
就像违反 hashCode 约定的类可以破坏依赖 hash 的其他类一样,违反 compareTo 约定的类也可以破坏依赖 Comparable 的其他类。依赖 Comparable 的类包括排序集合 TreeSet 和 TreeMap,以及实用工具类 Collections 和 Arrays,它们都包含搜索和排序算法。
compareTo的约定和equals约定有相同的限制:反身性、对称性和传递性。如果要向实现 Comparable 的类中添加值组件,不要继承它;编写一个不相关的类,其中包含第一个类的实例。然后提供返回所包含实例的「视图」方法。这使你可以自由地在包含类上实现你喜欢的任何 compareTo 方法,同时允许它的客户端在需要时将包含类的实例视为被包含类的实例。
compareTo 约定的最后一段是一个强烈建议而不是要求,它只是简单地说明了 compareTo 方法所施加地同等性检验通常应该与 equals 方法返回相同的结果。如果一个类的 compareTo 方法强加了一个与 equals 不一致的顺序,那么这个类仍然可以工作,但是包含该类元素的有序集合可能无法遵守集合接口(Collection、Set 或 Map)的一般约定。这是因为这些接口的一般约定是根据 equals 方法定义的,但是有序集合使用 compareTo 代替了 equals 实施同等性建议,这是需要注意的地方
例如,考虑 BigDecimal 类,它的 compareTo 方法与 equals 不一致。如果你创建一个空的 HashSet 实例,然后添加 new BigDecimal(“1.0”) 和 new BigDecimal(“1.00”),那么该 HashSet 将包含两个元素,因为添加到该集合的两个 BigDecimal 实例在使用 equals 方法进行比较时结果是不相等的。但是,如果你使用 TreeSet 而不是 HashSet 执行相同的过程,那么该集合将只包含一个元素,因为使用 compareTo 方法比较两个 BigDecimal 实例时结果是相等的。(有关详细信息,请参阅 BigDecimal 文档。)
编写 compareTo 方法
-
递归调用引用字段的 compareTo 方法。如果引用字段没有实现 Comparable,或者需要一个非标准的排序,那么应使用比较器
-
基本字段类型使用包装类的静态 compare 方法比较
-
从最重要字段开始比较
-
考虑使用
java.util.Comparator
接口的比较器构造方法