Java高效编程之二【对所有对象都通用的方法】

时间:2021-08-19 14:00:01

对于所有对象都通用的方法,即Object类的所有非final方法(equals、hashCode、toString、clone和finalize)都有明确的通用约定,都是为了要被改写(override)而设计的。

七、在改写equals的时候请遵循约定

  • 一个类的每个实例实质上都是唯一的。对于代表了实体活动实体而不是值(value)的类,确实是这样的,比如Thread。Object所提供equals实现对于这些类是正确的。
  • 不关心一个类是否提供了“逻辑相等(logical equality)”的测试功能。如java.util.Random改写的equals,用于检查两个Random是否产生随机数序列是否相等,但是设计者并不认为客户会需要或者期望这样的功能。在这样的情况下,从Object继承得到的equals实现就已经足够了。
  • 超类已经改写了equals,从超类继承过来的行为对于子类也是合适的。例如,Set实现都从AbstractSet继承了equals实现,List实现从AbstractList继承了equals实现,Map实现从AbstractMap继承了equals实现。
  • 一个类是私有的,或者是包级私有的,并且可以确定它的equals方法永远也不会被调用。尽管这样,应该也要改写equals方法,以免万一以后会被调用。改写如下
public boolean equals(Object o){
  throw new UnsupportedOperationException();
}

那么什么时候要改写equals呢?

“值类”的情形:程序员在利用equals类比较指向值对象的应用的时候,希望知道它们逻辑上是否相等,而不是是否指向同一个对象。
                    有一种“值类”可以不要求改写equals方法,即类型安全枚举类型。因为类型安全枚举类型保证每一个值至多只存在一个对象,所以对于这样的类而言,Object的equals方法等同于逻辑上的equals方法。

改写equals的时候要遵循如下的通用约定,来自于java.lang.Object的通用规范:

实现了等价关系。自反性、对称性、传递性、一致性,且对于任意的非空引用值x(即要求所有的对象都不为空),x.equals(null)一定返回false。

要想在扩展一个可实例化的类的同时,既要增加行的特性,同时还要保留equals约定,没有一个简单的办法可以做到这一点。

根据十四的建议,组合优先于继承,满足对称性的实现如下:

// 在没有破坏对称性的前提下增加了视图 
public class ColorPoint { 
   private Point point; 
   private Color color; 
 
   public ColorPoint(int x, int y, Color color) { 
      point = new Point(x, y); 
      this.color = color; 
   } 
 
   /** 
     * 返回有色点的点视图 
     */ 
   public Point asPoint() { 
      return point; 
   } 
   public boolean equals(Object o) { 
      if (!(o instanceof ColorPoint)) 
         return false; 
      ColorPoint cp = (ColorPoint)o; 
       return cp.point.equals(point) && cp.color.equals(color); 
   } 
 
   ...  // 余下省略
} 

特例:TimeStamp类,java.util.TimeStamp对java.util.Date进行子类化,并且增加了nanoseconds(纳秒)域,TimeStamp违反了对称性。 可以在一个抽象(abstract)类的子类中增加新的特性,而不会违反equals约定。这一点根据第20条的建议“用类层次(class hierarchies)来代替联合(union)”

为了测试实参与当前对象的相等情况,equals必须首先把实参转换为一种适当的类型,以便可以调用它的访问方法或者访问它的域。在做转换之前,equals方法必须使用instanceof操作符,检查它的实参是否为正确的类型。检查后不必单独做null的检查,因为如果instanceof 的第一个操作位为null,不管第二个操作数是哪种类型,按照instanceof操作符的规定,都返回false。

public boolean equals(Object o){
     if(!(o instanceof MyType))
       return false;
      ……
}

如果漏掉了检查,且传递给equals方法的实参有事错误的类型,那么equals方法将会抛出一个ClassCastException异常,这违反了equals约定。
实现高质量的equals的一个处方:

  1. 使用==操作符检查“实参是否为指向对象的一个引用”。如果是,返回true。如果比较操作符比较耗时,这样做能使性能得到优化。
  2. 使用instanceof查看“实参是否为正确的类型”。
  3. 把实参转化为正确的类型。注:使用强制类型转换。
  4. 对于该类中每一个“关键(significant)”域,检查实参中的域与当前对象中的域值是否相匹配。注:先比较最有可能不一致的域。
  5. 自检:是否是对称的、传递的、一致的。

最后的告诫:

  • 当你改写equals的时候,总是要改写hashCode(见八)。
  • 不要企图让equals方法过于聪明。
  • 不要使equals方法过于依赖不可靠的资源。如,java.net.URL,当把主机名转换成ip地址的时候需要访问网络,当不能保证每次都会产生相同的结果。
  • 不要将equals声明中的Object对象替换为其他的类型。如下替换,可能导致出现问题很久都发现不了。  
public boolean equals(MyClass o){
         ……
}

因为Object.equals的实参类型为Object,而这个方法并没有改写override(override)Object.equals,相反它重载(overload)了Object.equals(见第二十六),在原有equals的基础上提供了一个“强类型化(strongly typed)”的equals方法,通常不推荐这样做。

八、改写的equals方法总是要改写hashCode

在每一个改写了equals的的方法中,你必须也要改写hashCode方法。如果不这样做,就会违反Object.hashCode的通用约定,从而导致该类无法与所有基于散列值(hash)集合类地在一起正常运作,这样的集合包括HashMap、HashSet和HashTable,它们存储散列键(hash keys)。

三条通用约定:

  • 在一个应用程序执行期间,无论调用多少次hashCode返回的结果都是同一个整数。在同一个应用程序的多次执行过程中,这个整数可以不相同。
  • 相等的对象必须具有相等的散列码(hash Code)。 
  • 不等的对象必须产生不相等的散列码。

理想情况下,一个散列函数应该把一个集合中不相等的实例均匀分布到可能的散列值上。

简单的处方如下:

  1. 把一个非零变量值,比如说23,保存在一个叫result的int类型的变量中。(23这个值任选,但最好不要选择0)
  2. 对于该对象中的一个关键域(字段)f(指equals方法中考虑的每一个域),完成以下步骤:
    a. 为每个域字段计算int类型的散列码:
    i. 若是 boolean, 则计算 (f ? 0 : 1).
    ii. 若是 byte, char, short, or int,则计算(int)f.
    iii.若是 long,则计算(int)(f ^ (f >>> 32)).
    iv. 若是 float,则计算Float.floatToIntBits(f).
    v. 若是 double,则计算Double.doubleToLongBits(f),然后按照步骤2.a.iii对long计算hash值.
    vi. 若该域是一个引用对象,并且equals方法通过递归调用方式来比较这个域,则递归调用这个域的hashCode. 
       如果这个域的值为null,则返回0.
    vii. 如果域是一个数组,则把每一个元素当做一个单独的域来处理。也就是说递归的应用上述规则,对于每一个重要的元素计算一个散列码,然后根据2.b把这些散列组合起来。

           b. 按照下面的公式把步骤a中计算得到的散列码c组合到result中: 
                result = 37*result + c;
         3. 返回result

         4. 写好方法之后,思考是否满足“相等的实例具有相等的散列码”

 在相等的比较中没有用到的任何域,要将它们排除在外,而且是必须要求 。

下面是带有hashCode方法的PhoneNumber类:

public final class PhoneNumber { 
    private final short areaCode; 
    private final short exchange; 
    private final short extension; 
 
    public PhoneNumber(int areaCode, int exchange, 
                       int extension) { 
        rangeCheck(areaCode,   999, "area code"); 
  rangeCheck(exchange,   999, "exchange"); 
        rangeCheck(extension, 9999, "extension"); 
            this.areaCode  = (short) areaCode; 
            this.exchange  = (short) exchange; 
            this.extension = (short) extension; 
        } 
 
        private static void rangeCheck(int arg, int max, 
                                       String name) { 
            if (arg < 0 || arg > max) 
               throw new IllegalArgumentException(name +": " + arg); 
       } 
 
       public boolean equals(Object o) { 
           if (o == this) 
               return true; 
           if (!(o instanceof PhoneNumber)) 
               return false; 
           PhoneNumber pn = (PhoneNumber)o; 
           return pn.extension == extension && 
                  pn.exchange  == exchange  && 
                  pn.areaCode  == areaCode; 
       } 
 
       // hashCode方法 
      public int hashCode() { 
    int result = 23; 
    result = 37*result + areaCode;
    result = 37*result + exchange;
    result = 37*result + extension
    return result; 
   } 
       ... // 余下省略
} 

如果一个类是非可变的,并且计算散列码的代价也比较大,那么你应该把散列键缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果你觉得这种类型的大多数对象会被用做散列键(hash keys),那么你应该是在实例被创建的时候计算散列码。否则,可以选择“延迟初始化”散列码,一直等到hashCode第一次被调用的时候才初始化。(见四十八)

// 延迟初始化, 缓存 hashCode 
private volatile int hashCode = 0;  // (见四十八) 
 
public int hashCode() { 
    if (hashCode == 0) { 
        int result = 17; 
        result = 37*result + areaCode; 
        result = 37*result + exchange; 
        result = 37*result + extension; 
        hashCode = result; 
    } 
    return hashCode; 
} 

注意:不要试图从散列码计算中排除掉一个对象的关键部分以提高性能。
String类的散列函数至多只检查16个字符,从第一个字符开始,在整个字符串中均匀选取。

 九 、总是要改写toString                       

 java.lang.Object的toString方法返回的是一个包含类名,以及一个“@”符号,接着是散列码的无符号十六进制表示,例如“PhoneNumber@163b91”,但是根据toString的通用约定指出,toString要返回“间接的,但信息丰富的,并且易于阅读的表达形式”虽然“PhoneNumber@163b91”是间接的,但是和“(408)867-5309”比较起来,他不是信息丰富的。toString的约定还进一步指出,建议所有的子类都要改写这个方法”。改写toString并不是强制要求的,但是提供一个好的toString实现可以使一个类用起来更加愉快。当对象被传递给println、字符串连接符号(+)以及1.4发型版本之后的assert的时候,toString方法会自动调用。

  • 在实际的应用中,toString方法应该返回对象中包含所有令人感兴趣的信息。
  • 不管你是否指定格式,都应该在文档中明确的表明你的意图。
  • 为toString返回值中包含的所有信息,提供一种编程访问途径,总是一个好方法。

下面是PhoneNumber的格式化输出toString方法:(例如把99格式化输出4位就应该是0099,格式化输出三位就是099)

 

public String toString() { 
    return "(" + toPaddedString(areaCode, 3) + ") " + 
            toPaddedString(exchange,  3) + "-" + 
            toPaddedString(extension, 4); 
} 
/** 
 * 将int类型翻译成指定的长度的字符串 
 * 左边不够以0填充, 假设 i >= 0, 
 * 1 <= length <= 10, 且 Integer.toString(i) <= length. 
 */ 
private static String toPaddedString(int i, int length) { 
    String s = Integer.toString(i); 
    return ZEROS[length - s.length()] + s; 
} 

private static String[] ZEROS = 
    {"", "0", "00", "000", "0000", "00000", 
     "000000", "0000000", "00000000", "000000000"};  

十、谨慎的改写clone

Cloneable接口的目的是作为对象的一个mixin接口(mixin interface)(见十六),表明这样的对象允许克隆(cloning),不行的是它并没有成功达到这个目的,其主要缺陷在于缺少一个clone方法,Object的clone方法是被保护的。如果不借助于映像机制reflection(见三十五),则不能仅仅因为一个对象实现了Cloneable,就可以调用clone方法。即使是映像调用也可能会失败,因为并不能够保证该对象一定具有可以访问的clone方法。事实上,对于实现了Cloneable的类,我们总是期望它也提供了一个功能适当的共有clone方法。

如果被克隆对象的每个域包含一个原语类型的值,或者包含一个指向非可变对象的引用,那么被super.clone()返回的对象可能正是你想要的。

public Object clone(){
  try{
        super.clone();      
}catch(CloneUnsupportException e){
        throw new Error("Assertion failure");//不能发生
}
}

如果要把上面的Stack类做成可以clone的,

public class Stack { 
    private Object[] elements; 
    private int size = 0; 
     ……
}

仅仅使用super.clone()就会出现问题,在其size域中有正确的值,到那时它的elements域将引用到与原始Stack实例相同的数据上,修改原始的实例会破坏被克隆对象的数组,反之亦然。很快就会抛出NullPointerException异常。如果调用Stack类唯一的额构造函数,那么这种情况永远不会发生。实际上,clone方法是另一个构造函数;你必须确保它不会伤害到原始的对象,并且正确建立起来被克隆对象的约束关系

public Object clone() throws CloneNotSupportedException { 
    Stack result = (Stack) super.clone(); 
    result.elements = (Object[]) elements.clone(); 
    return result; 
} 

但是,如果elements域是final的,这种方案就不能正常工作,因为clone方法是进制给elements域赋予一个新值的。这是一个基本呢问题,clone结构与指向可变对象的final域的正常用法是不兼容的。除非在原始对象和克隆对象之间可以安全的共享此可变对象,为了使一个类成为可克隆的,可能有必要从某些域中去掉final修饰符。
clone方法浅表复制和深层复制有相似的地方。

如果扩展实现了Cloneable接口的类,就必须要实现一个行为良好的clone方法。否则,最好的做法是,提供某些其他途径来代替对象拷贝,或者干脆不提供这样的能力。

另外一个实现对象拷贝的好办法是提供一个拷贝构造函数(copy constructor)。拷贝构造函数也是一个构造函数,其唯一的参数类型是包含该构造函数的类,例如:

public Yum(Yum yum);

另一种方法是它的一个微小变形:提供一个静态工厂来替代构造函数:

public static Yum newInstance(Yum yum);

综合说起来,拷贝构造函数和静态工厂比Cloneable/clone方法更具有优势。所有的通用集合都提供了一个拷贝构造函数,它的参数类型是Collection或者Map。假设你有一个LinkedList 1,并且希望把它拷贝成一个ArrayList。clone方法没有提供这样的功能,但是用拷贝构造函数很容易实现:new ArrayList(1)。
Cloneable有上述很多安全问题,所以其他的接口不应该扩展(extends)这个接口,并且为了继承而设计的类(见十五)也不应该实现(implement)这个接口。所以专家级的程序员从来不去改写clone方法,也从来不去调用它。

十一、考虑实现comparable接口

CompareTo方法在Object并没有被声明,这点与其他方法不同,它是java.lang.Comparable接口中唯一的方法。一个类实现了Comparable接口,就表明它的实例具有内在的排序关系。若一个数组中的对象实现了Comparable(可以比较的)接口,则对整个数组进行排序就非常简单:Arrays.sort(a),a为数组。

对于存储在集合中的Comparable对象,搜索、计算极值以及自动维护都非常简答。

Java平台的所有值类都是先了Comparable。如果你正在编写一个值类,并且它具有非常明显的内在排序关系,比如按字母表排序、按数值顺序或者按年代排序,那么你几乎总是应该考虑实现这个接口。

compareTo的规范和equals方法具有相似的特征,其规范如下:

  • 满足sgn(x.compareTo(y))==-sgn(y.compareTo(x))
  • 满足比较关系可传递
  • 强烈建议(x.compareTo(y)==0)==(x.equals(y))

 就行违反了hashCode约定的类会破坏其他的依赖于散列做法的类一样,一个违反了compareTo约定的类也会破坏其他依赖于比交换关系的类。依赖于比较关系的类包括有序集合TreeSet和TreeMap,以及工具类Collections和Arrays,他们内部包含有搜索和排序算法。

有序集合TreeSet、TreeMap使用的是compareTo施加的相等测试,而Hash(HashMap、HashSet)类使用的是equals施加的相等测试。例如,BigDecimal类,它的compareTo方法与equals方法不一致。如果你创建了个HashSet,并且加入了一个new BigDecimal("1.0")和一个new BigDecimal("1.00"),这这个集合将包含两个元素,因为他们是通过equals方法来比较它们之间不相等的,BigDecimal在实现的时候equals考虑精度,而compareTo未考虑精度。然而,如果用TreeSet来实现这样的过程,则会发现集合中仅仅包含一个元素,这是因为TreeSet使用的是compareTo比较。

字段的比较本身是顺序比较,而不是相等比较,比较对象的引用字段可以通过递归调用CompareTo来实现。如果一个字段没有实现Comparable接口,或者你需要一个标准的排序关系,那么你可以使用一个显示的Comparator(比较器),或者编写专门的Comparator(实现Comparator接口,重写其中的compare方法和equals方法),或者使用已有的Comparator。譬如针对七中的CaseInsensitiveString类,compareTo方法使用一个已有的Comparator。

public int compareTo(Object o) { 
    CaseInsensitiveString cis = (CaseInsensitiveString)o; 
    return String.CASE_INSENSITIVE_ORDER.compare(s, cis.s); 
} 

Comparable和Comparator两者比较:
相同点:两者都是接口,需要实现,都为为比较对象的实例而生的。

不同点:

  • Comparable在java.lang包下面,Comparator在java.util的包下面。
  • 编写一个对象的时候,如果implements了Comparable接口,就必须重写其compareTo方法。在实现其的类对象上强制进行整体排序,这一顺序被称为类的自然排序。实现了这一接口的对象列表或者是数组,可以通过Collection.sort,Array.sort自动排序。
  • 而comparator相当于一个比较器接口,实现该接口的类要重写其compare方法,或equals方法。它相当于一个比较函数,强制在集合类对象上进行排序。并且该比较器可以传递给一个排序方法,如 Collections.sort(List,Comparator) 、Arrays.sort(Object[],Comparator) ,以实现对排列顺序的精确控制。比较器也可以被用如有序Map和有序Set(TreeMap、TreeSet)的排序,或者是提供在没有自然Comparable的对象集合上的排序。