转载

《Effective Java》Second Edition 第3章 对于所有对象都通用的方法

第3章 对于所有对象都通用的方法

  1. 尽管Object是一个具体类,但是设计它主要是为了扩展。它所有的非final方法(equals、hashCode、toString、clone和finalize)都有明确的通用约定(general contract),因为它们被设计成是要被覆盖(override)的。任何一个类,它在覆盖这些方法的时候,都有责任遵守这些通用约定;如果不能做到这一点,其他依赖于这些约定的类(例如HashMap和HashSet)就无法结合该类一起正常运作。
  2. 而Comparable.compareTo虽然不是Object方法,但是本章也对它进行讨论,因为它具有类似的特征。
  3. Object的equals方法:
public boolean equals(Object obj) {
    return (this == obj);
}

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

  1. 类的每个实例本质上都是唯一的。对于代表活动实体而不是值(value)的类来说确实如此,例如Thread。Object提供的equals实现对于这些类来说正是正确的行为。
  2. 超类已经覆盖了equals,从超类继承过来的行为对于子类也是合适的。例如,大多数的Set实现都从AbstractSet继承equals实现,List实现从AbstractList继承equals实现,Map实现从AbstractMap继承equals实现。

java.util.AbstractSet的equals方法:

public boolean equals(Object o) {
    if (o==this)
        return true;
    if(!(o instanceof Set))
        return false;
    Collection c = (Collection) o;
    if (c.size() != size())
        return false;
    try {
        return containsAll(c);
    } catch (ClassCastException unused) {
        return false;
    } catch (NullPointerException unused) {
        return false;
    }
}

java.util.AbstractMap的equals方法:

public boolean equals(Object o) {
    if (o==this)
        return true;
    if (!(o instanceof Map))
        return false;
    Map<K,V> m = (Map<K,V>) o;
    if (m.size()!=size())
        return false;
    try {
        Iterator<Entry<K,V>> i = entrySet().iterator();
        while(i.hasNext()) {
            Entry<K,V> e = i.next();
            K key = e.getKey();
            V value = e.getValue();
            if (value==null) {
                if(!(m.get(key)==null && m.containsKey(key)))
                    return false;
            } else {
                if(!value.equals(m.get(key)))
                    return false;
            }
        }
    } catch (ClassCastException unused) {
        return false;
    } catch (NullPointerException unused) {
        return false;
    }
    return true;
}

类是私有的或是包级私有的,可以确定它的equals方法永远不会被调用。在这种情况下,无疑是应该覆盖equals方法的,以防它被意外调用:

@override public boolean equals(Object o) {
    throw new AssertionError(); //Method is never called
}

这通常属于“值类(value class)”的情形。值类仅仅是一个表示值的类,例如Integer或者Date。程序员在利用equals方法来比较值对象的引用时,希望知道它们在逻辑上是否相等,而不是想了解它们是否指向同一个对象。

有一种“值类”不需要覆盖equals方法,即用实例受控确保“每个值至多只存在一个对象”的类。枚举类型就属于这种类。对于这样的类而言,逻辑相同与对象等同是一回事,因此Object的equals方法等同于逻辑意义上的equals方法。

对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用Float.compare方法;对于double域,则使用Double.compare。对float和double域进行特殊的处理是有必要的,因为存在着Float.NaN、-0.0f以及类似的double常量;详细信息请参考Float.equals的文档。对于数组域,则要把以上这些指导原则应用到每个元素上。如果数组域中的每个元素都很重要,就可以使用发行版本1.5中新增的其中一个Arrays.equals方法。

有些对象引用域包含null可能是合法的,所以,为了避免可能导致NullPointerException异常,则使用下面的习惯用法来比较这样的域:

(field==null ? o.field==null : field.equals(o.field))

域的比较顺序可能会影响到equals方法的性能。为了获得最佳的性能,应该最先比较最有可能不一致的域,或者是开销最低的域,最理想的情况是两个条件同时满足的域。你不应该去比较那些不属于对象逻辑状态的域,例如用于同步操作的Lock域。

覆盖equals时总要覆盖hashCode。

把任何一种别名形式考虑到等价的范围内,往往不会是个好主意。例如,File类不应该试图把指向同一个文件的符号链接(symbolic link)当作相等的对象来看。所幸File类没有这样做。

public boolean equals(MyClass o) {
    ...
}

问题在于,这个方法并没有覆盖Object.equals,因为它的参数应该是Object类型,相反,它重载(overload)了Object.equals。在原有equals方法的基础上,再提供一个“强类型(strongly typed)”的equals方法,只要这两个方法返回同样的结果,那么这就是可以接受的。在某些特定的情况下,它也许能够稍微改善性能,但是与增加的复杂性相比,这种做法是不值得的。@override注解的用法一致,就如本条目中所示,可以防止犯这种错误。这个equals方法不能编译,错误消息会告诉你到底哪里出了问题:

@Override public boolean equals(MyClass o) {
    ...
}

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

  1. 在应用程序执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。
  2. 如果两个对象根据equals(Object)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生同样的整数结果。
  3. 如果两个对象根据equals(Object)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(hash table)的性能。
  4. 由于PhoneNumber类没有覆盖hashCode方法,从而导致两个相等的实例具有不相等的散列码,违反了hashCode的约定。因此,put方法把电话号码对象存放在一个散列桶(hash bucket)中,get方法却在另一个散列桶中查找这个电话号码。即使这两个实例正好被放到同一个散列桶中,get方法也必定会返回null,因为HashMap有一项优化,可以将与每个项相关联的散列码缓存起来,如果散列码不匹配,也不必检验对象的等同性。

    1. 把某个非零的常量值,比如说17,保存在一个名为result的int类型的变量中。
    2. 对于对象中每个关键域f(指equals方法中涉及的每个域),完成以下步骤: 
      a. 为该域计算int类型的散列码c: 
      i. 如果该域是boolean类型,则计算(f ? 1:0)。 
      ii. 如果该域是byte、char、short或者int类型,则计算(int)f。 
      iii. 如果该域是long类型,则计算(int)(f ^ (f>>>32))。 
      iv. 如果该域是float类型,则计算Float.floatToIntBits(f)。 
      v. 如果该域是double类型,则计算Double.doubleToLongBits(f),然后按照步骤2.a.iii,为得到的long类型值计算散列值。 
      vi. 如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals的方式来比较这个域,则同样为这个域递归地调用hashCode。如果需要更复杂的比较,则为这个域计算一个“范式(canonical representation)”,然后针对这个范式调用hashCode。如果这个域的值为null,则返回0(或者其他某个常数,但通常是0)。 
      vii. 如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤b.2中的做法把这些散列值组合起来。如果数组域中的每个元素都很重要,可以利用发行版本1.5中增加的其中一个Arrays.hashCode方法。 
      b. 按照下面的公式,把步骤2.a中计算得到的散列码c合并到result中: 
      <nobr aria-hidden="true" style="outline: 0px; transition: none; border: 0px; max-width: none; max-height: none; min-width: 0px; min-height: 0px; vertical-align: 0px; line-height: normal; word-break: break-all;">result=31×result+c;</nobr><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>r</mi><mi>e</mi><mi>s</mi><mi>u</mi><mi>l</mi><mi>t</mi><mo>=</mo><mn>31</mn><mo>×</mo><mi>r</mi><mi>e</mi><mi>s</mi><mi>u</mi><mi>l</mi><mi>t</mi><mo>+</mo><mi>c</mi><mo>;</mo></math> 
      返回result。
  5. 必须排除equals比较计算中没有用到的任何域,否则很有可能违反hashCode约定的第二条。

  6. 步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String散列函数省略了这个乘法部分,那么只是字母顺序不同的所有字符串都会有相同的散列码。之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会丢失,因为与2相乘等价于移位运算。31有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i == (i << 5) - i。现代的VM可以自动完成这种优化。
  7. 如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果你觉得这种类型的大多数对象会被用作散列键(hash keys),就应该在创建实例的时候计算散列码。否则,可以选择“延迟初始化”散列码,一直到hashCode被第一次调用的时候才初始化。
  8. 不要试图从散列码计算中排除掉一个对象的关键部分来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。特别是在实践中,散列函数可能面临大量的实例,在你选择忽略的区域之中,这些实例仍然区别非常大。如果是这样,散列函数就会把所有这些实例映射到极少数的散列码上,基于散列的集合将会显示出平方级的性能指标。这不仅仅是个理论问题。在Java 1.2发行版本之前实现的String散列函数至多只检查16个字符,从第一个字符开始,在整个字符串中均匀选取。对于像URL这种层次状名字的大型集合,该散列函数正好表现出了这里所提到的病态行为。
  9. java.lang.String的hashCode方法:
public int hashCode() {
    int h = hash; //hash: cache the hash code for the string
    if (h==0) { //h==0说明是第一次计算哈希值,hash域还没有缓存
        int off = offset; // the offset is the first index of the storage that is used.
        char val[] = value; // the value is used for character storage.
        int len = count;
        for (int i=0;i<len;i++) {
            h = 31*h + val[off++];
        }
        hash = h;
    }
    return h;

第10条:始终要覆盖toString

  1. 在实现toString的时候,必须要做出一个很重要的决定:是否在文档中指定返回值的格式。
  2. 这种表示法可以用于输入和输出,以及用在永久的适合于人类阅读的数据对象中,例如XML文档。
  3. 无论你是否决定指定格式,都应该在文档中明确地表明你的意图。如果你要指定格式,则应该严格地这样去做。

第11条:谨慎地覆盖clone

  1. 其主要的缺陷在于,它缺少一个clone方法,Object的clone方法是受保护的。如果不借助于反射(reflection),就不能仅仅因为一个对象实现了Cloneable,就可以调用clone方法。即使是反射调用也可能会失败,因为不能保证该对象一定具有可访问的clone方法。
  2. 既然Cloneable并没有包含任何方法,那么它到底有什么作用呢?它决定了Object中受保护的clone方法实现的行为:如果一个类实现了Cloneable,Object的clone方法就返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedException异常。这是接口的一种极端非典型的用法,也不值得效仿。通常情况下,实现接口是为了表明类可以为它的客户做些什么。然而,对于Cloneable接口,它改变了超类中受保护的方法的行为。
  3. 如果它的clone方法仅仅返回super.clone(),这样得到的Stack实例,在其size域中具有正确的值,但是它的elements域将引用与原始Stack实例相同的数组。
  4. 为了使Stack类中的clone方法正常地工作,它必须要拷贝栈的内部信息。最容易的做法是,在elements数组中递归地调用clone:
@Override public Stack clone() {
    try {
        Stack result = (Stack) super.clone();
        result.elements = elements.clone();
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

虽然被克隆对象有它自己的散列桶数组,但是,这个数组引用的链表与原始对象是一样的,从而很容易引起克隆对象和原始对象中不确定的行为。为了修正这个问题,必须单独地拷贝并组成每个桶的链表。下面是一种常见的做法:

public class HashTable implements Cloneable {
    private Entry[] buckets = ...;

    private static class Entry {
        final Object key;
        Object value;
        Entry next;
        Entry(Object key, Object value, Entry next) {
            this.key = key;
            this.value = value;
            this.next = next;
        }
        // Iteratively copy the linked list headed by this Entry
        Entry deepCopy() {
            Entry result = new Entry(key, value, next);
            for (Entry p = result; p.next != null; p = p.next)
                p.next = new Entry(p.next.key, p.next.value, p.next.next);
            return result;
        }
    }
    @Override public HashTable clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = new Entry[buckets.length];
            for(int i=0;i<buckets.length;i++) 
                if (buckets[i] != null)
                    result.buckets[i] = buckets[i].deepCopy();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
    ... // Remainder omitted
}

如果专门为了继承而设计的类覆盖了clone方法,覆盖版本的clone方法就应该模拟Object.clone的行为:它应该被声明为protected、抛出CloneNotSupportedException异常,并且该类不应该实现Cloneable接口。这样做可以使子类具有实现或不实现Cloneable接口的自由,就仿佛它们直接扩展了Object一样。

简而言之,所有实现了Cloneable接口的类都应该用一个公有的方法覆盖clone。此公有方法首先调用super.clone,然后修正任何需要修正的域。一般情况下,这意味着要拷贝任何包含内部“深层结构”的可变对象,并用指向新对象的引用代替原来指向这些对象的引用。

拷贝构造器的做法,以及静态工厂方法的变形,都比Cloneable/clone方法具有更多的优势:它们不依赖于某一种很有风险的、语言之外的对象创建机制;它们不要求遵守尚未制定好文档的规范;它们不会与final域的正常使用发生冲突;它们不会抛出不必要的受检查异常(checked exception);它们不需要进行类型转换。虽然你不可能把拷贝构造器或者静态工厂放到接口中,但是由于Cloneable接口缺少一个公有的clone方法,所以它也没有提供一个接口该有的功能。因此,使用拷贝构造器或者拷贝工厂来代替clone方法时,并没有放弃接口的功能特性。

第12条:考虑实现Comparable接口

  1. 一旦类实现了Comparable接口,它就可以跟许多泛型算法(generic algorithm)以及依赖于该接口的集合实现(collection implementation)进行协作。你付出很小的努力就可以获得非常强大的功能。事实上,Java平台类库中的所有值类(value classes)都实现了Comparable接口。
  2. 强烈建议(x.compareTo(y) == 0) == (x.equals(y)),但这并非绝对必要。一般来说,任何实现了Comparable接口的类,若违反了这个条件,都应该明确予以说明。推荐使用这样的说法:“注意:该类具有内在的排序功能,但是与equals不一致。”
  3. 与equals不同的是,在跨越不同类的时候,compareTo可以不做比较:如果两个被比较的对象引用不同类的对象,compareTo可以抛出ClassCastException异常。通常,这正是compareTo在这种情况下应该做的事情,如果类设置了正确的参数,这也正是它所要做的事情。
  4. 就好像违法了hashCode约定的类会破坏其他依赖于散列做法的类一样,违反compareTo约定的类也会破坏其他依赖于比较关系的类。依赖于比较关系的类包括有序集合类TreeSet和TreeMap,以及工具类Collections和Arrays,它们内部包含有搜索和排序算法。
  5. 因此,下面的告诫也同样适用:无法在用新的值组件扩展可实例化的类时,同时保持compareTo约定,除非愿意放弃面向对象的抽象优势。针对equals的权宜之计也同样适用于compareTo方法。如果你想为一个实现了Comparable接口的类增加值组件,请不要扩展这个类;而是要编写一个不相关的类,其中包含第一个类的一个实例。然后提供一个“视图(view)”方法返回这个实例。这样既可以让你自由地在第二个类上实现compareTo方法,同时也允许它的客户端在必要的时候,把第二个类的实例视同第一个类的实例。
  6. 例如,考虑BigDecimal类,它的compareTo方法与equals不一致。如果你创建了一个HashSet实例,并且添加new BigDecimal (“1.0”)和new BigDecimal (“1.00”),这个集合就将包含两个元素,因为新增到集合中的两个BigDecimal实例,通过equals方法来比较时是不相等的。然而,如果你使用TreeSet而不是HashSet来执行同样的过程,集合中将只包含一个元素,因为这两个BigDecimal实例在通过compareTo方法进行比较时是相等的。
  7. 因为Comparable接口是参数化的,而且comparable方法是静态的类型(意思是compareTo方法的参数类型是一定的,就是实现Comparable接口的时候指定的参数类型。而equals方法的参数类型是Object,所以要进行额外的类型检查,instanceof。),因此不必进行类型检查,也不必对它的参数进行类型转换。如果参数的类型不合适,这个调用甚至无法编译。
  8. 比较整数类型基本类型的域,可以使用关系操作符<和>。例如,浮点域用Double.compare或者Float.compare,而不用关系操作符,当应用到浮点值时,它们没有遵守compareTo的通用约定。对于数组域,则要把这些指导原则应用到每个元素上。
  9. 如果一个类有多个关键域,那么,按什么样的顺序来比较这些域是非常关键的。你必须从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果(零代表相等),则整个比较操作结束,并返回该结果。
  10. 这项技巧有时不能正常工作的原因在于,一个有符号的32位的整数还没有大到足以表达任意两个32位整数的差。如果i是一个很大的正整数(int类型),而j是一个很大的负整数(int类型),那么(i-j)将会溢出,并返回一个负值。

原文地址:http://www.jsdblog.com/article/63 

正文到此结束
Loading...