Effective-Java学习笔记(二)

第三章 对象的通用方法

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方法

  1. 如果类是不可变的,不要提供clone方法
  2. 如果类的字段都是基本类型或不可变对象的引用,那么直接这个类的clone方法直接调用super.clone()即可
  3. 如果类的字段包含可变对象的引用,需要递归调用可变对象的深拷贝方法

一些细节

  1. 和构造函数一样,不要在clone方法中调用可覆盖方法。如果clone方法调用一个在子类中被覆盖的方法,这个方法将在子类修复其在克隆中的状态之前执行,很可能导致克隆和原始对象的破坏。

  2. Object的clone方法被声明为抛出CloneNotSupportedException,但是覆盖方法时 try-catch 异常就行,不抛出受检查异常的方法更容易使用

  3. 设计可继承的类时不要实现 Cloneable接口(如果实现了,子类就必须对外提供clone方法)。你可以选择通过实现一个功能正常的受保护克隆方法来模拟 Object 的行为,该方法声明为抛出 CloneNotSupportedException。这给子类实现 Cloneable 或不实现 Cloneable 的自由。或者,你可以选择不实现一个有效的克隆方法,并通过提供以下退化的克隆实现来防止子类实现它:

    // clone method for extendable class not supporting Cloneable
    @Override
    protected final Object clone() throws CloneNotSupportedException {
        throw new CloneNotSupportedException();
    }
    
  4. 如果你编写了一个实现了 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) { ... };

复制构造函数方法及其静态工厂变体与克隆方法相比有许多优点:

  1. 它们不依赖于易发生风险的语言外对象创建机制(Object的clone方法是native的);
  2. 他们不要求无法强制执行的约定(clone方法一定要先调用super.clone());
  3. 它们与 final 字段不冲突(clone方法不能修改final字段);
  4. 它们不会抛出不必要的 checked 异常;
  5. 而且不需要强制类型转换。
  6. 可以提供类型转换构造函数

考虑到与 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 方法

  1. 递归调用引用字段的 compareTo 方法。如果引用字段没有实现 Comparable,或者需要一个非标准的排序,那么应使用比较器

  2. 基本字段类型使用包装类的静态 compare 方法比较

  3. 从最重要字段开始比较

  4. 考虑使用 java.util.Comparator接口的比较器构造方法