EFFECTIVE JAVA 3RD EDITION — 第三章 对象基本方法

Item 10 : Obey the general contract when overriding equals

遵守重写 equals方法基本规则

以下几种情况最好不要重写 equals 方法:

  • 对象的每个实例都肯定不一样,比如 Thread
  • 没有必要(不关心)是否逻辑上是否相等,比如 Pattern
  • 父类已经重写了 equals 方法
  • 类是私有或者包内私有,并且你确定不会调用 equals 方法,为了规避风险你可以重写方法然后抛出异常
    	@Override
    public boolean equals(Object o) {
        throw new AssertionError(); // Method is never called
    }
    

当有一个逻辑上相等的概念的时候才会重写 equals 方法,而不是单纯的去判断是否同一个引用,
但对于像单例,枚举这种有实例控制(instance control Item 1)的类,逻辑上相等和实际引用是一回事了。

重写 equals 方法需要注意下面几点(均来自 Object 的 equals 方法说明) x,y,z均不为null:

  1. 自反性,x.equals(x) 必须返回true
  2. 对称性,x.equals(y) 返回 true 当且仅当 y.equals(x)
  3. 传递性,x.equals(y), y.equals(z), 返回 true,那么 x.equals(z) 返回 true
  4. 确定性(一致性),x.equals(y) 要么一直返回true 要么一直返回false
  5. 与空对象比较肯定返回 false,x.equals(null)=false

很难扩展一个已经实例化的类,添加一个属性的同时又要保证上面的约束,除非你想放弃面向对象抽象的好处,但是可以这样实现:

@Override
public boolean equals(Object o) {
    if (o == null || o.getClass() != getClass())
        return false;
    Point p = (Point) o;
    return p.x == x && p.y == y;
}

但是如果有子类的话,这个方法就无法得要预期结果,并且也违反了上面的一致性。
Liskov substitution principle 提到一个类型所有重要属性都需要为其子类提供稳定、一致的方法,显然上面的实现没有做到。

一个好的解决方法:Item 18 : favor composition over inheritance 优先构成而不是继承,比如上面可以不用继承父类而是提供一个私有的父类属性:

public class ColorPoint {
    private final Point point;
    private final Color color;
    public ColorPoint(int x, int y, Color color) {
        point = new Point(x, y);
        this.color = Objects.requireNonNull(color);
    }
    /**
    * Returns the point-view of this color point.
    */
    public Point asPoint() {
        return point;
    }
    @Override
    public boolean equals(Object o) {
        if (!(o instanceof ColorPoint))
            return false;
        ColorPoint cp = (ColorPoint) o;
        return cp.point.equals(point) && cp.color.equals(color);
    }
    ... // Remainder omitted
}

Java中 Timestamp 却在继承了 Date 之后扩展了一个 nanoseconds 属性,但是文档中说明了不要混用 Date 和 Timestamp。
继承抽象类可以放心的扩展子类属性而不用担心 equals 方法约束,因为抽象类无法实例化。

不管一个类是否可变都不要写一个依赖不可靠资源的 equals 方法。
比如 java.net.URL 的 equals 方法,就是依赖 ip 来判断是否相等,但是 ip 每次获取到可能是不一样的,所以 equals 方法最好只对内存驻留对象提供,而不是依赖不可靠的资源。

很多 equals 方法都做了 null 处理,比如:

@Override
public boolean equals(Object o) {
    if (o == null)
        return false;
    ...
}

但事实上只需要判断类型即可,因为 null instanceof Type 是肯定返回 false 的。

一个高质量的 equals 方法需要做到:

  1. 使用 == 判断对象引用
  2. 使用 instanceof 判断类型
  3. 强制类型转换,instanceof 使用时已经转换过了,所以肯定是对应类型
  4. 对类的重要部分作判断,全部通过返回 true,否则返回 false
  • 基本数据类型除了 float 和 double 使用 == 比较,引用类型使用 equals 比较
  • float 使用静态方法:Float.compare(float, float),double 使用静态方法:Double.compare(double, double)
    Float 和 Double 的 equals 方法都存在自动拆装箱
  • 数组使用 Arrays.equals

equals 方法的性能取决于比较的内容,为了性能优先比较最可能不同的或者是性能消耗最小的,或者同时兼顾两者。

不要去比较对象的逻辑状态,比如同步锁。

没有必要比较派生出来的属性,但是比较了有可能会有性能提升,比如有一个面积属性,你就不需要再去比较长和宽了。

尽量避免手动重写 equals 方法,使用IDE自动生成或者AutoValue framework等类似框架。

尽量不要重写 equals 方法,除非必须,确保方法返回的是你想要的结果。

Item 11 : Always override hashCode when you override equals

重写 equals方法时重写 hashCode方法

重写 hashCode 必须遵循:

  • 同一个应用中反复执行 hashCode 方法必须返回相同的值
  • 如果两个对象通过 equals 方法判断是相等的,那么 hashCode 应该返回同样的值
  • 如果两个对象通过 equals 方法判断不相等,hashCode 不一定返回不同的值
    equlas 方法不相等的两个对象返回不同的 hashCode 有助于提升哈希表的性能

重写 hashCode 失败往往违反的是上面第二条。重写一个规范的 hashCode 方法按照下面的流程:

  1. 义一个 int 类型的局部变量 result ,使用 2.a初始化这个值
  2. 每一个在 equals 方法中用到的部分(变量)f 执行下面操作
    a. 为每一个f计算一个 int 值 c:
    如果这个属性是基本类型,那么使用对应包装类型的 hashCode 方法 Type.hashCode(f);
    如果这个属性是引用类型,如果是 null 那么用 0(或者其他常量),不是 null 直接使用引用类的 hashCode 方法;
    如果是数组,如果数组有元素用于 equals 方法,把每个元素当做单独的递归调用2.a, 2.b,如果数组没有一个元素在 equals 方法中使用,那么用0或者其他常量代替;
    b. 使用下面的方式来计算 hashCode : result = 31 * result + c
    为什么用 31 ,31 是个素数,如果是偶数的话容易倍增溢出导致信息丢失,31 * i == (i << 5) – i 可以用位移和一个减法来代替,可以提升性能。
  3. 返回 result

比如:

@Override
public int hashCode() {
    int result = Short.hashCode(areaCode);
    result = 31 * result + Short.hashCode(prefix);
    result = 31 * result + Short.hashCode(lineNum);
    return result;
}

千万避免使用没有用于 equals 方法的变量,步骤2.b 很依赖你使用变量的顺序。

Objects 类提供了一个快速获取 hash 值的静态方法:

@Override
public int hashCode() {
    return Objects.hash(lineNum, prefix, areaCode);
}

性能要求苛刻的场景不推荐使用,内部其实使用的数组来处理,会有自动拆装箱带来的性能损耗,该方法内部调用其实是 Arrays.hashCode :

public static int hashCode(Object a[]) {
        if (a == null)
            return 0;
        int result = 1;
        for (Object element : a)
            result = 31 * result + (element == null ? 0 : element.hashCode());
        return result;
}

其实也是符合上面的重写流程。

如果一个类是不变(单例等等),计算 hashCode 比较消耗性能,考虑缓存 hashCode,比如下面的实现:

private int hashCode; // Automatically initialized to 0
@Override
public int hashCode() {
    int result = hashCode;
    if (result == 0) {
        result = Short.hashCode(areaCode);
        result = 31 * result + Short.hashCode(prefix);
        result = 31 * result + Short.hashCode(lineNum);
        hashCode = result;
    }
    return result;
}
  • 不要尝试排除部分 equals 方法中使用的属性来提升 hashCode 方法性能
  • 不要给一个 hashCode 方法提供详细的规范(或者文档),使用者就没有理由依赖它,你可以灵活的进行改动(换言之就是不要轻易留坑,防止背锅)

Item 12 : Always override toString

总是重写 toString方法

这个比较容易理解也很简单。重写一个好的 toString 方法有助于是你的类看起来更舒服,让系统使用更简单和易于调试。toString 方法应该返回所有对象值得关注的内容。

定义 toString 方法返回格式,特别的对于以值为主的类:

  • 好处在于可以作为一个标准,便于阅读,通常还需要提供一个工厂方法或者构造器来转换 string 和 对象
  • 缺点在于如果你的类被很多人使用,调用者用来持久化数据,一旦改动 toString 方法,代价将是非常大的

所以需要在文档里写明你的意图,是确定好了返回格式,还是该方法返回数据格式可能在将来会有改动(程序员要时刻注意是否留坑)。

不管是否确定返回格式都需要返回数据包含重要信息或者关注的信息,总的来说重写 toString 方法,除非父类已经重写,易于使用和调试,返回数据应该清晰,包含类的描述信息,易于阅读。

Item 13 : Override clone judiciously

正确的重写 clone方法

clone方法需要遵循:

  1. x.clone() != x 返回 true
  2. x.clone().getClass() == x.getClass() 或者 x.clone().equals(x) 返回 true ,这个不是必须的

不要给不变的类提供 clone 方法,防止不必要的类复制。

当类有对象引用时让 clone 方法提供像构造器一样的功能,同时保证对原对象没有影响,在复制过程中保持不变,比如:

@Override
public Stack clone() {
    try {
        Stack result = (Stack) super.clone();
        result.elements = elements.clone();
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

普通容易改变的类如果有 final 属性是不适合使用 clone 方法,除非该属性是在他自己和副本之间安全共享的。

为了让一个类可复制,有必要移除一些属性的 final 修饰。

上面例子虽然简洁的实现了 clone ,但是与 Cloneable 设计想法相悖的,因为上面例子只是一味的一个一个属性的复制来创建一个副本。

和构造器一样,clone 方法在执行过程中不能调用可以被重写的方法,如果调用了那么这个方法有机会修改在 clone 过程中的状态。

Object 的 clone 方法抛出了 CloneNotSupportedException 异常,但是重写的 clone 方法不需要,公共 clone 方法应该忽略这个异常方便使用(item 71)。

一个可继承的类都不应该实现 Cloneable 接口,或者你可以在 clone 方法中抛出异常,比如:

@Override
protected final Object clone() throws CloneNotSupportedException
{
    throw new CloneNotSupportedException();
}

还有一点值得注意,如果一个类是线程安全同时又实现了 Cloneable 接口,clone 方法应该被正确的加上同步锁,
Object 的 clone 方法不是同步的,虽然是正确的但也需要你实现一个同步的 clone 方法。

总的来说实现了 Cloneable 接口的类都都应该重写一个公共的(idea自动生成是 protected)的 clone 方法,返回自身类型,而不是 Object;
方法应该首先调用 super.clone() 方法,然后覆盖一些对象引用和可变属性,而不是仅仅将拷贝对象属性引用到原对象的属性,如果对象结构比较深的话,
可以递归调用相应对象的 clone 方法但不是最好的解决方案。

一个更好的对象复制的实现是提供一个复制构造方法或者工厂方法,比如下面的实现:

public Yum(Yum yum) { ... };
public static Yum newInstance(Yum yum) { ... };

这样实现好处:

  • 不依赖一个有风险的对象创建机制
  • 没有强制要求遵守某些规范
  • 与 final 修饰符不冲突,不影响其使用
  • 不会抛出检查异常
  • 没有强制类型转换
  • 返回类型可选,可以是子类,或者实现类

由于 Cloneable 的局限与不足,新的接口或者扩展类最好不要实现 Cloneable,但是数组最好的复制方式却是 clone 方法。

Item 14 : Consider implementing Comparable

考虑实现 Comparable接口

不像本章到的其他方法定义在 Object ,compareTo 方法是单独定义在 Comparable 接口中的。

类似于 equals 方法,不过 compareTo 方法除了作简单相等比较之外还要求参数顺序,并且该方法是通用的。

如果类需要一些排序功能或者排序敏感那么可以实现 Comparable 接口,同时遵循下面规则:

  • 必须保证 sgn(x.compareTo(y)) == -sgn(y. compareTo(x)) 其实有点类似 equals 方法的反身性
  • 传递性,(x. compareTo(y) > 0 && y.compareTo(z) > 0) 那么 x.compareTo(z) > 0
  • 如果 x.compareTo(y) == 0 那么 sgn(x.compareTo(z)) == sgn(y.compareTo(z)) 对所有的 z 都成立
  • (x.compareTo(y) == 0) == (x.equals(y)) 建议但不是必须的

不像 equals 方法是针对所有对象的,compareTo 方法只是针对同类对象的,如果不是同类对象调用会抛出 ClassCastException,
和违反 equals 方法基本原则一样,违反 compareTo 方法基本原则也会影响依赖比较的类,比如排序和搜索等等功能。

如果你想给一个实现了 Comparable 接口的类扩展属性,或者是继承,写一个不相关的类添加一个类引用即可,然后提供一个查看的方法来返回引用实例。

上面第四点虽然不是强制要求,但是如果 equals 和 compareTo方法不能保持一致的话,compareTo 方法能够正常执行,
但是包含这个类的集合排序结果可能就不是集合接口本身定义的排序顺序了,因为接口定义是就 equals 方法而言的,而集合排序功能是针对 compareTo 方法的,所以这点非常值得注意。

比如 BigDecimal 这个类就是 equals 和 compareTo 方法不是一致的,如果我往一个 HashSet 里放入 new BigDecimal(“1.0”) 和 new BigDecimal(“1.00”),
里面会有两个元素,因为 BigDecimal equals 判断是不等的,
但是如果你用 TreeSet 就只有一个元素,因为 compareTo 比较是相等的。

与重写 equals 方法不同:

  • 由于比较都是同类型的,所以不需要进行类型检查
  • 比较方法是比较顺序而不是是否相等或者是值

该书旧版本提到比较除了 float 和 double 的其他基本数据类型使用大于小于来比较,而 float 和 double 则使用 Double.compare 对应包装类的 compare 方法,
Java7以后所有的包装类都有该方法,使用大于小于和包装类 compare 方法都是不推荐的,容易出错。

具体实现类似重写 hashCode 的流程:
有多个重要属性的时候,比较的先后顺序十分严格,按照重要性能先后比较,如果相等就返回,否则比较下一个,直到找到不等(有序)的属性返回,比如:

public int compareTo(PhoneNumber pn) {
    int result = Short.compare(areaCode, pn.areaCode);
    if (result == 0) {
        result = Short.compare(prefix, pn.prefix);
        if (result == 0)
            result = Short.compare(lineNum, pn.lineNum);
    }
    return result;
}

java 8中 Comparator 提供了一个比较器的集合,可以让比较更加流畅,这些比较器可以用于实现 Comparable 接口,可能会有性能的略微下降,例如下面的实现:

private static final Comparator COMPARATOR =
    comparingInt((PhoneNumber pn) -&gt; pn.areaCode)
    .thenComparingInt(pn -&gt; pn.prefix)
    .thenComparingInt(pn -&gt; pn.lineNum);
public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this, pn);
}

对应的也有 comparingLong 等方法,覆盖了全部的java基本类型,short可以用 comparingInt, double 可以使用 comparingLong。

同时也提供了引用对象的比较器,使用 comparing 方法,提供了两种重载,一个传入一个 key extractor 使用 key 的自然顺序,一个传入一个 key extractor 和 一个比较器。

thenComparing 方法提供了三种重载,具体可查看 Comparator 文档。

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return o1.hashCode() - o2.hashCode();
    }
};

避免上面这种实现,违反了第二条传递性,而使用:

static Comparator<Object> hashCodeOrder = new Comparator<>() {
    public int compare(Object o1, Object o2) {
        return Integer.compare(o1.hashCode(), o2.hashCode());
    }
};

或者:

static Comparator<Object> hashCodeOrder = Comparator.comparingInt(o -> o.hashCode());

总的来说对排序敏感的类可以实现 Comparable接口来有助于排序,搜索和比较,避免使用大于小于,而应该用包装类提供的 compare 方法,或者是 Comparator 中提供的比较。

版权所有丨转载请注明出处:https://minei.me/archives/305.html