• [Effective Java]第三章 对所有对象都通用的方法


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

    8、            覆盖equals时请遵守通用约定

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

     

    在覆盖equals方法时,你必须要遵守它的通用约定。下面是约定的内容,来自Object的规范:

    l  自反性:对于任何非null的引用值xx.equals(x)必须返回true。如果自已不等于自己的话,将其放入集合中后,该集合的contains方法将告诉你,集合中不包括你刚添加的实例。

    l  对称性:对于任何非null的引用值xy,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true。这就要求不同类的实例如果在逻辑值相同的情况下,要求这两个实例所对应的类的equals方法比较逻辑要相同,不然的话,对称性将不再满足。

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

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

    l  非空性:对于任何非null的引用值xx.equals(null)必须返回false

     

    实现高质量的equals方法:

    1、  使用==操作符检查“参数是否为这个对象的引用”。如果是,则返回true,这只不过是一种性能优化,如果比较操作有可能很昂贵,就值得这么做。

    2、  使用instanceof操作符检查“参数是否为正确的类型”。如果不是,则返回false。一般说来所谓“正确的类型”是指定equals所在的那个类。有些情况下,是指该类所实现的某个接口。如果类实现的接口改进了equals约定,允许在实现了该接口的类之间进行比较,那么就使用接口。集合接口如SetListMapMap.Entry具有这样的特性。注,这步会过滤掉null,因为 null instanceof XX 一定会返回false。另外,要注意的是,如果你只与自己本身类型的类相比,则可以使用if(getClass() == obj.getClass())来限制为同一个类比较而不希望是父类或其子类(思想来源于《Practice Java》)。

    3、  把参数转换成正确的类型。因为转换之前进行过instanceof测试,所以确保会成功。

    4、  对于该类中的每个“关键”域,检查参数中的域是否与该对象中对应的域相匹配。如果这些测试全部成功,则返回true,否则返回false

    对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用Float.compare方法;对于double域,则使用Double.compare。对于数组域,则要把以上这些指导原则应用到每个元素上,如果数组域中的每个元素都需要比较的话,可以使用1.5版本中发行的Arrays.equals方法。

     

    对于floatdouble域进行特殊的处理是有必要的,因为存在着Float.NaN-0.0f以及类似的double常量,详细信息请参考Float.equals的文档,看看Float.equals源码与文档描述:

    public boolean equals(Object obj) {

    return (obj instanceof Float)

                   && (floatToIntBits(((Float) obj).value) == floatToIntBits(value));

    }

    在比较是否相等时使用是floatToIntBits(float)方法,即将浮点的二进制位看作是整型位后比较的。注意,在大多数情况下,对于 Float 类的两个实例 f1  f2,让 f1.equals(f2) 的值为 true 的条件是当且仅当 f1.floatValue() == f2.floatValue() 的值也为 true。但是也有下列两种例外:

    l  如果 f1  f2 都表示 Float.NaN(规定Float.NaN = 0x7fc00000),那么即使 Float.NaN = = Float.NaN 的值为 falseequals 方法也将返回 true(因为他们所对应的整型位是相同的)。

    l  如果 f1 表示 +0.0f,而 f2 表示 -0.0f,或相反的情况,则 equal 测试返回的值是 false(因为他们所对应的整型位是不同的),即使 0.0f = = -0.0f 的值为 true 也是如此。

     

    另外,来看看Float.compare的源码:

    public static int compare(float f1, float f2) {

        if (f1 < f2)

             return -1;             // Neither val is NaN, thisVal is smaller

         if (f1 > f2)

             return 1;              // Neither val is NaN, thisVal is larger

     

         int thisBits = Float.floatToIntBits(f1);

         int anotherBits = Float.floatToIntBits(f2);

     

         return (thisBits == anotherBits ?  0 : // Values are equal

                 (thisBits < anotherBits ? -1 : // (-0.0, 0.0) or (!NaN, NaN)

                  1));                          // (0.0, -0.0) or (NaN, !NaN)

    }

    compare是从数字上比较两个 Float 对象。在应用到基本 float 值时,有两种方法来比较执行此方法产生的值与执行Java 语言的数字比较运算符(<<===  >= >)产生的那些值之间的区别:

    l  该方法认为 Float.NaN 将等于其自身,且大于其他所有 float 值(包括 Float.POSITIVE_INFINITY)。

    l  该方法认为 0.0f 将大于 -0.0f

     

    请记住,如果是通过Java 语言的数字比较运算符(<<===  >= >)而不是compare方法来比较时,只要其中有一个操作为Float.NaN,那么比较结果就是false

     

    对象引用域的值为null是有可能的,所以,为了避免可能导致的空指针异常,则使用下面的作法: filed = = null ? o.field = = null : filed.equals(o.filed)

    如果fieldo.field通常是相等的对象引用,那么下面的做法就会更快一些:(field == o.field || (field != null && field.equals(o.field)))

     

    5、  当你编写完成了equals方法之后,应该问自己:它是否是对称的、传递的、一致的?当然,equals方法也必须满足其他两个我(自反性和非空性),但是这两种我通常会自动满足。

     

    另一点要注意的是如果该类有除Object以外的父类,则要考虑是否调用父类的equals方法,如super.equals(obj)(思想来源于《Practice Java),因为可能有些逻辑状态在父类中也需要比较。

     

    下面的例子是根据上面的诀窍构建equals方法:

    public final class PhoneNumber {

        private final short areaCode;

        private final short prefix;

        private final short lineNumber;

           // …

        @Override public boolean equals(Object o) {

            if (o == this)

                return true;

            if (!(o instanceof PhoneNumber))

                return false;

            PhoneNumber pn = (PhoneNumber)o;

            return pn.lineNumber == lineNumber

                && pn.prefix  == prefix

                && pn.areaCode  == areaCode;

        }

    }

     

    最后一点要注意的是,在重写equals方法时,参数类型应该为Object,而不应该是某个要比较的具体类,因为这样在调用equals方法时可能为调用成Object里的equals方法,比如外界将比较的对象赋值给一个Object类型的变量时就会有这个问题。

    9、            覆盖equals时总是要覆盖hashCode

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

     

    下面是约定的内容:

    l  在应用程序的执行期间,只要对象的equals方法的比较操作所用到的信息没有被修改,那么对这同一个对象调用多次,hashCode方法都必须始终如一地返回同一个整数。在同一个应用程序的多次执行过程中,每次执行所返回的整数可以不一致(我想可能是因为对象的状态信息被修改过)。

    l  如果两个对象根据equalsObject)方法比较是相等的,那么调用这两个对象中任意一个对象的hashCode方法都必须产生相同的整数结果。

    l  如果两个对象根据equalsObject)方法比较是不相等的,那么调用这两个对象中任意一个对象的hashCode方法,则不一定要产生不同的整数结果。但是程序员应该知道,给不相等的对象产生截然不同的整数结果,有可能提高散列表(has table)的性能。

     

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

     

    一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”,这正是上面约定中第三条含义。理想情况下,散列函数应该把集合中不相等的实例均匀地分布所有可能的散列值上。要完全达到这种理想的情形是非常困难的。但我们如果按照如下的规则来写hashCode函数,则可能比较理想:

    1、  把某个非零的常数值,比如说17(值17是任选的,困此即即使2.a步骤中计算出的散列值为0初始域也会影响到散列值,这样会大大的避免了冲突的可能性,所以这个一般是一个非零的常数值),保存在一个名为resultint的类型变量中。

    2、  对于对象中每个键域f(指equals方法中涉及的每个域),完成以下步骤:

    a.       为该域计算int类型的散列码c

    I、     如果该域是boolean类型,则计算(f ? 1 : 0)

    II、  如果该域是bytecharshort或者int类型,则计算(int)f

    III、              如果该域是long类型,则计算(int)(f ^ (f >>> 32))

    IV、              如果该域是float类型,则计算Float.floatToIntBits(f),即将内存中的浮点数二进制位看作是整型的二进制,并将返回整型结果。

    V、  如果该域是dobule类型,则计算Double.doubleToLongBits(f),然后按照步骤2.a.III

    VI、              如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals方式来比较这个域,则同样为这个域递归地调用hashCode。如果这个域的值为null,则返回0

    VII、           如果该域是一个数组,则要把每一个元素当做单独的域来处理。也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据步骤2.b中的做法把这些散列值组合起来。如果数组的每个元素都需要求,则可以使用1.5版本发行的Arrays.hashCode方法。

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

    result = 31 * result + c;

    步骤2.b中的乘法部分使得散列值依赖于域的顺序,如果一个类包含多个包含多个相似的域,这样的乘法运算就会产生一个更好的散列函数。例如,如果String散列函数省略了这个乘法部分,那么只要组成该字符串的字符是一样的,而不管它们的排列的顺序,则会导致只要有相同字符内容的字符串就会相等的问题,而Stringequals方法是与字符排序顺序有关的。另外,之所以选择31,是因为它是一个奇素数。如果乘数是偶数,并且乘法溢出的话,信息就会全丢失,因为与2相乘等价于移位运算。31还有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i = i ^32 - i =  (i << 5) – i,现代的VM可以自动完成这种优化。

    3、  返回result

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

     

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

     

    不要试图从散列码计算中排除掉一个对象的关键部分来提高性能。虽然这样得到的散列函数运行起来可能更快,但是它的效果不见得会好,可能会导致散列表慢到根本无法使用。

     

    下面看看根据上面hashCode规则的实例:

    public final class PhoneNumber {

        private final short areaCode;

        private final short prefix;

        private final short lineNumber;

        @Override public boolean equals(Object o) {

            if (o == this)

                return true;

            if (!(o instanceof PhoneNumber))

                return false;

            PhoneNumber pn = (PhoneNumber)o;

            return pn.lineNumber == lineNumber

                && pn.prefix  == prefix

                && pn.areaCode  == areaCode;

    }

    @Override public int hashCode() {

        int result = 17;

        result = 31 * result + areaCode;

        result = 31 * result + prefix;

        result = 31 * result + lineNumber;

        return result;

    }

    }

     

    当然,如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象的内部,而不是每次请求的时候都重新计算散列码:

    private volatile int hashCode;  // (See Item 71)

    @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;

    }

    10、      始终要覆盖toString

    toString方法应该返回对象中包含的所有值得关注的信息。建议所有的子类都覆盖这个方法。

     

    在实现toString的时候,必须要做出一个很重要的决定:是否在文档中指定返回值的格式。如果格式化,则易阅读,但你得要一直保持这种格式,因而缺乏灵活性。

    11、      谨慎地覆盖clone

    Cloneable是一个标识性接口,没有任何方法,那么它到底有什么作用?它决定了Object中受保护的clone方法实现的行为:如果一个类实现了CloneableObjectclone方法就返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedException异常。

     

    Object.clone()能够按对象大小创建足够的内存空间,从旧对象到新对象,复制所有的比特位。 这被称为逐位复制。但是,Object.clone()在执行操作前,会先检查此类是否可克隆,即检查 它是否实现了Cloneable接口。如果没有实现此接口,Object.clone()会抛出CloneNotSuppo rtedException异常,说明它不能被克隆。

     

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

    public final class PhoneNumber implements Cloneable {

        private final short areaCode;

        private final short prefix;

    private final short lineNumber;

    // …

    // 注,这里返回的是PhoneNumber而不是Object1.5版本后支持参数有协变:覆盖方法的返回烦劳可以是被覆盖方法的返回类型的子类。这样不用在客户端强转了。

        @Override public PhoneNumber clone() {

            try {

         

                return (PhoneNumber) super.clone();

            } catch(CloneNotSupportedException e) {

                throw new AssertionError();  // Can't happen

            }

    }

    }

     

    如果对象含有引用类型且指向了可变对象,使用上述这种简单的clone实现可能会导致灾难性后果,考虑像上面那样克隆如下类(类来自于第6条):

    public class Stack {

           private Object[] elements;

           private int size = 0;

           private static final int DEFAULT_INITIAL_CAPACITY = 16;

           // …

    }

    假设你希望把这个类做成可克隆的(Cloneable),如果它的clone方法仅仅是返回super.clone(),这样得到的Stack实例,在其size域中具有正确的值,但是它的elements域将引用与原始Stack实例相同的数组。为了使用Stack类中的clone方法正常地工作,它必须拷贝栈的内部信息,最容易的做法是,在elements数组路递归地调用clone

    @Override public Stack clone() {

           try {

                  Stack result = (Stack) super.clone();

                  // 1.5版本后不必将返回的Object类型结果强转成为Object[]类型,自1.5起,在数组上调用clone返回的数组,其编译时类型与被克隆数组的类型相同,1.5前返回的是Object对象。

                  result.elements = elements.clone();

                  return result;

           } catch (CloneNotSupportedException e) {

                  throw new AssertionError();

           }

    }

    注意,上面如果elements域是final的,上述方案就不能正常工作了,因为clone方法是被禁止给elements域赋新值的。这是个根本的问题:clone架构与引用可变对象的final域的正常用法是不相兼容的,可以说是相违背的,除非在原始对象和克隆对象之间可以安全地共享此可变对象(比如使用final修饰的StringBuffer就不可安全共享,如果是不可变对象如String则可安全共享,就可以不必克隆)。为了使类成为可克隆的,可能有必要从某些域中去掉final修饰符。

    另外result.elements = elements.clone();所克隆的也只是elements中所有对象地址罢了,克隆出的数组里的元素还是与原数组指向同一个对象,如果要真真深层次克隆,则还是要对数组循环来一个个调用对象上的clone方法才行,下面就来看看这个问题的相应例子。

    例如,假设你正在为自己设计的一个散列表编写clone方法,它的内部数据包含一个散列桶数组,每个散列桶都指向“键  值”对单向链表的第一个节点,如果桶是空的,则为null,该类如下:

    public class Hashtable implements Cloneable {

    private transient Entry buckets[];

        private static class Entry {

           final Object key;

           Object value;

           Entry next;

           protected Entry(Object key, Object value, Entry next) {

               this.key = key;

               this.value = value;

               this.next = next;

           }

    }

    }

    假设你仅仅递归地克隆这个散列桶数组,就像我们能Stack类所做的那样:

    @Override public HahsTable clone() {

           try{

                  HashTable result = (HashTable)super.clone();

                  result.buckets = buckets.clone();

                  return result;

    } catch (CloneNotSupportedException e) {

                  throw new AssertionError();

           }

    }

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

    public class Hashtable implements Cloneable {

    private transient Entry buckets[];

        private static class Entry {

           final Object key;

           Object value;

           Entry next;

           protected Entry(Object key, Object value, Entry next) {

               this.key = key;

               this.value = value;

               this.next = next;

           }

           Entry deepCopy(){// 递归地深层复制每个链表节点对象

                  return new Entry(key, value, next == null ? null : next.deepCopy());

           }

    }

    @Override public HahsTable 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();

           }

    }

    }

    Entry类中的深度拷贝方法递归地调用它自身,以便拷贝整个链表。虽然这种方法很灵活,因为针对列表中的每个元素,它都要消耗一段空间。如果链表比较长,这很容易导致栈溢出,为了避免发生这种情况,你可以在deepCopy中用迭代代替递归:

    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;

    }

     

    简而言之,所有实现了Cloneable接口的类都应该用一个公有的方法覆盖clone(如果不是公有的且没有覆盖Object中的clone方法,则外界不能克隆该类的实例)。此公有方法首先调用super.clone,然后修正任何需要修正的域。

     

    数组具有clone方法,但我们不能使用反射来调用该方法,但可以拿到数组对象后直接调用。

     

    直接通过调用数组对象的clone方法克隆出的对象是否是深度克隆,则要看这个数组是否是基本类型的数组,如果是则属于深度克隆,否则不是(但是数组对象本身还是被复制了一份的,而不是指向数组同一存储空间了,只是数组里的引用还是指向原来数组指向的对象,如果此时需对元素再次深度克隆,则需要对数组里的每个元素进行单独克隆处理)。

     

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

    12、      考虑实现Comparable接口

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

    public interface Comparable<T>{

           int compareTo(T t);

    }

     

    依赖于比较关系的类包括有序集全类TreeSetTreeMap,以及工具类CollectionsArrays,它们内部包含有搜索和排序算法。

     

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

    public interface Comparator<T> {

               int compare(T o1, T o2);

    }

     

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

     

    如果一个类有多个关键域,那么从最关键的域开始,逐步进行到所有的重要域。如果某个域的比较产生了非零的结果,则整个比较操作结束,并返回该结果。如果最关键的域是相等的,则进一步比较次最关键的域,以此类推。如果所有的域都是相等,则对象就是相等的,并返回零,下面是第9条的PhoneNumber类的compareTo方法:

    public int compareTo(PhoneNumber pn) {

      // Compare area codes

      if (areaCode < pn.areaCode)

          return -1;

      if (areaCode > pn.areaCode)

          return  1;

     

      // Area codes are equal, compare prefixes

      if (prefix < pn.prefix)

          return -1;

      if (prefix > pn.prefix)

          return  1;

     

      // Area codes and prefixes are equal, compare line numbers

      if (lineNumber < pn.lineNumber)

          return -1;

      if (lineNumber > pn.lineNumber)

          return  1;

     

      return 0;  // All fields are equal

    }

    虽然这个方法可行,但可以改进一下,因为compareTo方法的规定并没有指定返回值的大小,而只是指定了返回值的符号:

    public int compareTo(PhoneNumber pn) {

           // Compare area codes

           int areaCodeDiff = areaCode - pn.areaCode;

           if (areaCodeDiff != 0)

                  return areaCodeDiff;

     

           // Area codes are equal, compare prefixes

           int prefixDiff = prefix - pn.prefix;

           if (prefixDiff != 0)

                  return prefixDiff;

     

           // Area codes and prefixes are equal, compare line numbers

           return lineNumber - pn.lineNumber;

    }

    虽然比前面快一点,但用起来要非常小心。除非这些域不会为负数,或都更一般的情况:最小和最大的可能域值之差小于或等于Integer.MAX_VALUE,否则就不要使用这种方法。比如i是一个很大的正整数,而j是一个很大的负整数,那么i-j将会溢出。这不是理论,它已经在实际的系统中导致了失败,所以要格外小心,因为这样的compareTo方法对于大多数的输入值都能正常工作。

  • 相关阅读:
    V8 下的垃圾回收机制
    数据库索引原理
    多线程的实现方法
    网元的概念
    Oracle 数据库实现数据合并:merge
    Linux账号管理
    Linux 进程管理 ps、top、pstree命令
    linux OS与SQL修改时区,系统时间
    数据库的几种模式
    linux上限值网速、限值带宽
  • 原文地址:https://www.cnblogs.com/jiangzhengjun/p/4255581.html
Copyright © 2020-2023  润新知