《Effective Java》笔记10-14:所有对象的通用方法

第10条:覆盖 equals 时请遵守通用约定

如果没有遵守通用约定,可能会导致错误,最容易避免这类问题的方法就是不覆盖 equals 方法,在这种情况下,类的每个实例都只与它自身相等。如果满足以下任何一个条件,则不要覆盖 equals 方法

  1. 类的每一个实例本质上都是唯一的。
    如 Thread,他本身并不具备更多逻辑比较的必要性。
  2. 不关心类是否提供了 “逻辑相等” 的测试功能。
    如 Random 类,开发者在使用过程中并不关心两个 Random 对象是否可以生成同样随机数的值,对于一些工具类亦是如此,如 NumberFormat 和 DateFormat 等。
  3. 超类已经覆盖了 equals,从超类继承过来的行为对于子类也是合适的。
    如 Set 实现都从 AbstractSet 中继承了 equals 实现。
  4. 类是私有的或是包级别私有的,(并且您)可以确定它的 equals 方法永远不会被调用。(这里翻译有点问题

什么时候应该覆盖 equals

如果类具有自己特定的 “逻辑相等” 概念(不同于对象等同概念),而且超类还没有覆盖 equals 以实现期望的行为,这时我们就需要覆盖 equals 方法,这通常属于 “值类” 的情形,例如 Integer 或者是 Data,程序员在利用 equals 方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。

通用约定

重写 equals 的时候就必须要遵守它的通用约定。

equals 方法实现了等价关系 (equivalence relation):

自反性

自反性 (reflexive) 对于任何非 null 的引用值 x,x.equals(x) 必须返回 true。

对称性

对称性 (symmetric) 对于任何非 null 的引用值 x 和 y,当且仅当 y.equals(x) 返回 true 时,x.equals(y)必须返回 true。

// 违反例子
public final class CaseInsensitiveString {
    private final String s;
    public CaseInsensitiveString(String s) {
        this.s = s;
    }
    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase((CaseInsensitiveString)o).s);
        if (o instanceof String)
            return s.equalsIgnoreCase((String)o);
        return false;
    }
}

CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

cis.equals(s) 返回 true,而 s.equals(cis) 返回 false。

为解决这个问题,可以把与 String 互操作的这段代码从 equals 方法中去掉。

@Override
public boolean equals(Object o) {
    return (o instanceof CaseInsensitiveString) &&
        ((CaseInsensitiveString)o).s.equalsIgnoreCase(s));
}

传递性

传递性 (transitive) 对于任何非 null 的引用值,x,y,z,如果 x.equals(y) 为 true,并且 y.equals(z)也返回 true,那么 x.equals(z) 也必须返回 true。

我们无法在扩展可实例化的类的同时,即增加新的值组件,同时又保留 equals 约定。为解决这个问题,可以使用复合。也可以只要不能直接创建超类的实例,这个问题也不会发生。

java.sql.Timestamp 对 java.util.Date 进行了扩展,Timestamp 的 equals 实现违发了对称性,Timestamp 有个免责声明,不要混合使用 Date 和 Timestamp 对象,只要不混合在一起,就不会有问题。Timestamp 类的这种行为是错误的。

public class Point {
    private final int x;
    private final int y;
    public Point(int x,int y) {
        this.x = x;
        this.y = y;
    }
    @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;
    }
}

class MyTest {
    private static final Set<Point> unitCircle;
    static {
        unitCircle = new HashSet<Point>();
        unitCircle.add(new Point(1,0));
        unitCircle.add(new Point(0,1));
        unitCircle.add(new Point(-1,0));
        unitCircle.add(new Point(0,-1));
    }
    public static boolean onUnitCircle(Point p) {
        return unitCircle.contains(p);
    }
}

// 如果此时我们测试的不是 Point 类本身,而是 ColorPoint(Point 的子类),
// 那么按照目前 Point.equals(getClass 方式) 的实现逻辑,ColorPoint 对象在被传入 onUnitCircle 方法后,将永远不会返回 true,
// 这样的行为违反了 "里氏替换原则"(敏捷软件开发一书中给出了很多的解释),既一个类型的任何重要属性也将适用于它的子类型。因此该类型编写的任何方法,在它的子类型上也应该同样运行的很好。

一致性

一致性 (consistent) 对于任何非 null 的引用值 x 和 y,只要 equals 的比较操作在对象中所用的信息没有被修改,多次调用 x.equals(y) 就会一致地返回 true,或者 false。

不要使用 equals 方法依赖于不可靠资源。如,java.net.URL 的 equals 方法依赖于对 URL 中主机 IP 地址的比较,IP 地址可能会更改。

非空性

对于任何非 null 的引用值 x,x.equals(null) 必须返回 false。

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

书中说,这样的测试是不必要的,因为 equals 方法会使用 instanceof 操作符检查其参数是否为正确的类型:if (!(o instanceof MyType)) return false;,如果把 null 传给 equals 方法,类型检查就会返回 false,所以不需要单独的 null 检查。

但是,常见的 IDE 自动生成 equals 方法,都是使用的如下方法:

if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MyType myType = (MyType) o;
...

IDEA 生成 equals 方法

在上图,IDEA 生成 equals 方法,多种选择模版的方法,均是如此。

这种方法,根据上文中传递性的介绍中,可能会损失传递性。

高质量的 equals

  1. 使用 == 操作符检查 "参数是否为这个对象的引用" 如果是,则返回 true。if (this == o) return true;,这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。
  2. 使用 instanceof 操作符检查 "参数是否为正确的类型" 如果不是,则返回 false。
  3. 把参数装换成正确的类型。(这个比较好理解,instanceof 检测后,一般都会强转成所需类型)
  4. 对于该类中的每个『关键』域,检查参数中的域是否与对象中对应的域相配。(比如学生类有学号,班级,姓名这些重要的属性,我们都需要去比对)。
    习惯用法:return (a == b) || (a != null && a.equals(b));(JDK 7 新增的 java.util.Objects 类中 equals 方法就是这么实现的)。
    对应 float 可以使用 Float.compare 方法,double 可以使用 Double.compare 方法,因为这样可以考虑到精度、Double.NaN、Float.NaN 和 -0.0f 等问题。
    对于数组可以使用 Arrays.equals 方法。
  5. 当你编写完成了 equals 方法之后,应该问自己是哪个问题: 它是否是对称的、传递的、一致的?

对于类型为非 floatdouble 的基本类型,使用 == 运算符进行比较;对于对象引用属性,递归地调用 equals 方法;对于 float 基本类型的属性,使用静态 Float.compare(float, float) 方法;对于 double 基本类型的属性,使用 Double.compare(double, double) 方法。由于存在 Float.NaN-0.0f 和类似的 double 类型的值,所以需要对 float 和 double 属性进行特殊的处理;有关详细信息,请参阅 Float.equals 方法的详细文档。 虽然你可以使用静态方法 Float.equalsDouble.equals 方法对 float 和 double 基本类型的属性进行比较,这会导致每次比较时发生自动装箱,引发非常差的性能。 对于数组属性,将这些准则应用于每个元素。 如果数组属性中的每个元素都很重要,请使用其中一个重载的 Arrays.equals 方法。

  某些对象引用的属性可能合法地包含 null。 为避免出现 NullPointerException 异常,请使用静态方法 Objects.equals(Object, Object) 检查这些属性是否相等。

注意

  1. 覆盖 equals 时总要覆盖 hashCode
  2. 不要让 equals 方法过于智能。(比如讲别名的形式考虑到等价范围内)
  3. 不要将 equals 声明中的 Object 对象替换为其他的类型,可以会导致并没有覆盖 Object.equals 方法,只是重载。在方法名的前面加上 @Override 注释标签,可以避免这个问题。

第11条:覆盖 equals 时总要覆盖 hashCode

在每个覆盖了 equals 方法的类中,也必须覆盖 hashCode 方法。如果不这样做的话,就会违反 Object.hashCode 的能用约定,从而导致该类无法结合所有基于散列的集合一起正常动作,这些集合包括 HashMap、HashSet、Hashtable 等。

Object 通用约定:

  • 在应用程序的执行期间,只要对象的 equals 方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode 方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致。
  • 如果两个对象根据 equals 方法比较是相等的,那么调用这两个对象中任意一个对象的 hashCode 方法都必须产生同样的整数结果。(即 equals 相等,那么 hashCode 一定相等,需要注意的是,反过来不一定成立,即 hashCode 相等不代表 equals 相等)
  • 如果两个对象根据 equals 方法比较是不相等的,那么调用这两个对象中任意一个对象的 hashCode 方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的证书结果,有可能提高散列表 (hash table) 的性能。

不重写 hashCode 带来的问题

正如之前提到的, HashMap 会把相同的 hashCode 的对象放在同一个散列桶 (hash bucket) 中, 那么即使 equals 相同而 hashCode 不相等, 那么跟 HashMap 一起使用, 则会得到与预期不相同的结果.

HashMap 查找某个键是否存在时,采用取了优化方式,它们先比较的是两者的 hashcode,如果不同,则直接返回 false(因为放入合希集合的过程中元素的 hashcode 就已计算出并存 Entry 里的 hash 域中了,所以先比较这哈希值很快),否则再比较内容,源码部分如下:if (e.hash == hash && (x == y || x.equals(y)))

如何重写 hashCode

  1. 把某个非零的常数值,比如说 17(值 17 是任选的),保存在一个名为 result 的 int 的类型变量中。

  2. 对于对象中每个键域 f(指 equals 方法中涉及的每个域),完成以下步骤:
    1) 为该域计算 int 类型的散列码 c:

     1. 如果该域是 boolean 类型,则计算 (f ? 1 : 0)。
     2. 如果该域是 byte、char、short 或者 int 类型,则计算 (int)f。
     3. 如果该域是 long 类型,则计算 (int)(f ^ (f >>> 32))。
     4. 如果该域是 float 类型,则计算 Float.floatToIntBits(f),即将内存中的浮点数二进制位看作是整型的二进制,并将返回整型结果。
     5. 如果该域是 dobule 类型,则计算 Double.doubleToLongBits(f),然后按照步骤 2.1.III。
     6. 如果该域是一个对象引用,并且该类的 equals 方法通过递归地调用 equals 方式来比较这个域,则同样为这个域递归地调用 hashCode。如果这个域的值为 null,则返回 0。
     7. 如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤 2.b 中的做法把这些散列值组合起来。如果数组的每个元素都需要求,则可以使用 1.5 版本发行的 Arrays.hashCode 方法。

    2) 按照下面的公式,把步骤 2.1 中计算得到的散列码 c 合并到 result 中:

     result = 31 * result + c;

    步骤 2.2 中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果 String 散列函数省略了这个乘法部分,那么只要组成该字符串的字符是一样的,而不管它们的排列的顺序。

    之所以选择 31,是因为它是一个奇素数。31 还有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i = i ^32 - i = (i << 5) – i,现代的 VM 可以自动完成这种优化。

  3. 返回 result。

  4. 写完了 hashCode 方法后,问问自己 “相等的实例是否都具有相等的散列码”。

在散列码计算的过程中,可以把冗余域排除在外,换句话说,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。但必须排除 equals 比较计算中没有用到的所有域,否则很有可能违反 hashCode 约定的第二条。

不要试图从散列码计算中排除掉一个对象的关键部分来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。如早期版本的 String,为了提高计算哈希值的效率,只是挑选其中 16 个字符参与 hashCode 的计算,这样将会导致大量的 String 对象具有重复的 hashCode,从而极大的降低了哈希集合的存取效率。

@Override public int hashCode() {
    int result = 17;
    result = 31 * result + areaCode;
    result = 31 * result + prefix;
    result = 31 * result + lineNumber;
    return result;
}

对于有些不可变对象,如果需要被频繁的存取于哈希集合,不要试图从哈希码计算中排除重要的属性来提高性能。为了提高效率,可以在对象构造的时候就已经计算出其 hashCode 值,或者使用延迟初始化散列码,直到 hashCode 被第一次调用的时候才初始化,如:

private volatile int hashCode;

@Override public int hashCode() {
  int result = hashCode;
  if (result == 0) {
      result = 17;
      result = 31 * result + areaCode;
      result = 31 * result + prefix;
      result = 31 * result + lineNumber;
      hashCode = result;
  }
  return result;
}

类库

虽然在这个项目的方法产生相当好的哈希函数,但并不是最先进的。 它们的质量与 Java 平台类库的值类型中找到的哈希函数相当,对于大多数用途来说都是足够的。 如果真的需要哈希函数而不太可能产生碰撞,请参阅 Guava 框架的的 com.google.common.hash.Hashing 方法。

Objects 类有一个静态方法,它接受任意数量的对象并为它们返回一个哈希码。这个名为 hash 的方法可以让你编写一行 hashCode 方法,其质量与根据这个项目中的上面编写的方法相当。不幸的是,它们的运行速度更慢,因为它们需要创建数组以传递可变数量的参数,以及如果任何参数是基本类型,则进行装箱和取消装箱。这种哈希函数的风格建议仅在性能不重要的情况下使用:

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

第12条:始终要覆盖 toString

Object 类默认 toString 的实现方法是这样的:

public String toString() {
    return getClass().getName() + '@' + Integer.toHexString(hashCode());
}

它只有类名 +'@'+ 散列值,toString 方法应该返回对象中包含的所有值得关注的信息。建议所有的子类都覆盖这个方法。

尽量不要固定格式,这样会给今后添加新的字段信息带来一定的束缚,因为必须要考虑到格式的兼容性问题

如果指定了格式,可以提供一个相匹配的静态工厂或者构造器,可以利用 toString 返回的字符串作为该类的构造函数参数来实例化该类的对象,如 BigDecimal 和 BigInteger 和绝大多数的基本类型包装类。

第13条:谨慎地覆盖 clone

Object 中 clone 方法的定义是:

protected native Object clone() throws CloneNotSupportedException;

如果需要调用 clone 方法,需先实现 Cloneable 接口,Cloneable 是一个标识性接口,没有任何方法。

如果某个类中每个成员域是一个基本类型的值,或者是指向一个不可变对象的引用,那么我们直接调用 Object 中的 clone 方法就是我们要返回的拷贝对象了,而不需要对这个对象再做进一步的处理:

// 注,这里返回的是 PhoneNumber 而不是 Object,1.5版本后支持参数有协变:覆盖方法的返回值可以是被覆盖方法的返回类型的子类。这样不用在客户端强转了。
@Override
public PhoneNumber clone() {
    try {
        return (PhoneNumber) super.clone();
    } catch(CloneNotSupportedException e) {
        throw new AssertionError();  // Can't happen
    }
}

使用 Object 中的默认 clone 对某个类进行克隆时,任何类型的数组属性成员都只是浅复制,即克隆出来的数组与原来类中的数组指向同一存储空间,其他引用也是这样,只有基本类型才深复制。

代替方法

提供某些其他途径来代替对象拷贝,或干脆不提供这样的能力。一个好的代替方法是“拷贝构造函数”

public MyClass(MyClass myClass){};

// 或另一种微小变形——静态工厂方法
public static MyClass newInstance(MyClass myClass){};

实践中比较实用的 clone 的替代品:

  1. 如果一个类是可序列化的,那么可以将其先序列化,然后再反序列化已得到其副本。如果对象很大,甚至可以序列化到磁盘上。
  2. 如果是JavaBean,可以使用 org.apache.commons.beanutils.BeanUtils.cloneBean 静态方法。经查明该方法是浅复制 https://stackoverflow.com/questions/9264066/beanutils-clonebean-deep-copy
  3. org.apache.commons.lang3.SerializationUtils.clone(T),同样也是序列化原理。

第14条:考虑实现 Comparable 接口

如果一个类实现了 Comparabler 接口,就表明它的实例具有内在的自然排序规则了。事实上,Java 平台类库中的所有值类都实现了 Comparable 接口。如果你正在编写一个值类,它具有非常的内在排序关系,比如按字母顺序、按数值顺序或按年代,那你就应该考虑实现这个接口:

public interface Comparable<T>{
    int compareTo(T t);
}

比较整型基本类型的域,可以使用关系操作符 == 、< 和 >。但浮点域要使用 Double.compare 或者 Float.comprae,而不是用关系操作符。

compareTo 具有和 equals 相似的约定:

  1. 自反性:x.compareTo(x) 一定为 true
  2. 对称性:当且仅当 x.compareTo(y) 为 0;那么 y.compareTo(x) 也必须为 0
  3. 传递性:如果 x.compareTo(y) == 0,sgn(x.compareTo(z)) == sgn(y.compareTo(z)) 必须成立 (sgn 代表表达式)
    如果x.compareTo(y) < 0,y.compareTo(z) < 0;那么 x.compareTo(z) 也必须 < 0
  4. 一致性:对于任意引用值 x 和 y,如果用于 compareTo 比较的对象信息没有被修改的话,那么多次调用 x.compareTo(y) 返回的值是一致的
  5. 强力建议:(x.compareTo(y) == 0) == (x.equals(y)),但这并不是严格要求。一般而言,任何实现了 Comarable 接口的类,若违反了这个条件,应该明确予以说明。推荐这样的说法:“注意:该类具有内在的排序能力,但是与 equals 不一致。”

类似于 equals 方法(传递性),无法在用新的组件扩展可实例化的类时,同时保持 comparTo 约定。

如果想为一个实现了 Comparable 接口的类增加值组件,请不要扩展此类;而是要编写一个新的类,其中包含第一个类的一个实例。然后提供一个“视图(view)”方法返回这个实例。这样既可以让你自由地在第二个类上实现 compareTo 方法,同时也允许它的客户端在必要的时候,把第二个类的实例视同第一个类的实例。

强力建议 (x.compareTo(y) == 0) == (x.equals(y))

推荐 compareTo 方法施加的等同性测试,在通常情况下应该返回和 equals 方法同样的结果,考虑如下情况:

public static void main(String[] args) {
    HashSet<BigDecimal> hs = new HashSet<BigDecimal>();
    BigDecimal bd1 = new BigDecimal("1.0");
    BigDecimal bd2 = new BigDecimal("1.00");
    hs.add(bd1);
    hs.add(bd2);
    System.out.println("The count of the HashSet is " + hs.size());

    TreeSet<BigDecimal> ts = new TreeSet<BigDecimal>();
    ts.add(bd1);
    ts.add(bd2);
    System.out.println("The count of the TreeSet is " + ts.size());
} /* output:
The count of the HashSet is 2
The count of the TreeSet is 1
*/

BigDecimal,这是一个没有满足这一条件的类。

我们实例化 2 个对象:new BigDecimal("1.0") 和 new BigDecimal("1.00"),若把这 2 个对象放入 HashSet 中,HashSet 将有 2 个元素,而放入 TreeSet,则这个 TreeSet 中仅有 1 个元素。原因是,HashSet 是依赖 hashCode 和 equals 来判断的;而 TreeSet 是依赖 compareTo 来判断的

Comparator

如果一个域并没有实现 Comparable 接口,或者你需要使用一个非标准的排序关系,就可以使用一个显式的 Comparable 来代替:

public interface Comparator<T> {
    int compare(T o1, T o2);
}

或者也可以使用已有的 Comparator,如 String.CASE_INSENSITIVE_ORDER

第二版实例

// 本书第二版
public int compareTo(PhoneNumer pn) {
    int areaCodeDiff = areaCode - pn.areaCode;
    if (areaCodeDiff != 0)
        return areaCodeDiff;
    int prefixDiff = prefix - pn.prefix;
    if (prefixDiff != 0)
        return prefixDiff;

    int lineNumberDiff = lineNumber - pn.lineNumber;
    if (lineNumberDiff != 0)
        return lineNumberDiff;
    return 0;
}

第三版

在 Java 7 中,静态比较方法被添加到 Java 的所有包装类中。在 compareTo 方法中使用关系运算符 「<」和「>」是冗长且容易出错的,不再推荐

如果一个类有多个重要的属性,那么比较他们的顺序是至关重要的。从最重要的属性开始,逐步比较所有的重要属性。如果比较结果不是零(零表示相等),则表示比较完成; 只是返回结果。如果最重要的字段是相等的,比较下一个重要的属性,依此类推,直到找到不相等的属性或比较剩余不那么重要的属性。PhoneNumber 类的 compareTo 方法,演示了这种方法:

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 接口提供了一系列比较器方法,可以使比较器流畅地构建。 这些比较器可以用来实现 compareTo 方法,就像 Comparable 接口所要求的那样。 许多程序员更喜欢这种方法的简洁性,尽管它的性能并不出众:在我的机器上排序 PhoneNumber 实例的数组速度慢了大约 10%。 在使用这种方法时,考虑使用 Java 的静态导入,以便可以通过其简单名称来引用比较器静态方法,以使其清晰简洁。 以下是 PhoneNumber 的 compareTo 方法的使用方法:

private static final Comparator<PhoneNumber> COMPARATOR =
        comparingInt((PhoneNumber pn) -> pn.areaCode)
          .thenComparingInt(pn -> pn.prefix)
          .thenComparingInt(pn -> pn.lineNum);

public int compareTo(PhoneNumber pn) {
    return COMPARATOR.compare(this, pn);
}

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 bin07280@qq.com

文章标题:《Effective Java》笔记10-14:所有对象的通用方法

文章字数:5.8k

本文作者:Bin

发布时间:2018-08-08, 22:42:22

最后更新:2019-08-06, 00:42:55

原始链接:http://coolview.github.io/2018/08/08/Effective-Java/%E3%80%8AEffective%20Java%E3%80%8B%E7%AC%94%E8%AE%B010-14%EF%BC%9A%E6%89%80%E6%9C%89%E5%AF%B9%E8%B1%A1%E7%9A%84%E9%80%9A%E7%94%A8%E6%96%B9%E6%B3%95/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录