編寫高質量程式碼:改善Java程式的151個建議(第3章:類、物件及方法___建議41~46)

阿赫瓦里發表於2016-09-14

建議41:讓多重繼承成為現實

  在Java中一個類可以多重實現,但不能多重繼承,也就是說一個類能夠同時實現多個介面,但不 能同時繼承多個類。但有時候我們確實需要繼承多個類,比如希望擁有多個類的行為功能,就很難使用單繼承來解決問題了(當然,使用多繼承是可以解決的)。幸 運的是Java中提供的內部類可以曲折的解決此問題,我們來看一個案例,定義一個父親、母親介面,描述父親強壯、母親溫柔的理想情形,程式碼如下: 

public interface Father {
    public int strong();
}

interface Mother {
    public int kind();
}

  其中strong和kind的返回值表示強壯和溫柔的指數,指數越高強壯和溫柔也就越高,這與遊戲中設定人物的屬性是一樣的,我們繼續開看父親、母親這兩個實現:  

class FatherImpl implements Father {
    // 父親的強壯指數為8
    @Override
    public int strong() {
        return 8;
    }

}

class MotherImpl implements Mother {
    // 母親的溫柔指數為8
    @Override
    public int kind() {
        return 8;
    }

}

  父親的強壯指數為8,母親的溫柔指數也為8,門當戶對,那他們生的兒子和女兒一定更優秀了,我們看看兒子類,程式碼如下:

class Son extends FatherImpl implements Mother {

    @Override
    public int strong() {
        // 兒子比父親強壯
        return super.strong() + 1;
    }

    @Override
    public int kind() {
        return new MotherSpecial().kind();
    }

    private class MotherSpecial extends MotherImpl {
        @Override
        public int kind() {
            // 兒子的溫柔指數降低了
            return super.kind() - 1;
        }
    }
}

  兒子繼承自父親,變得比父親更強壯了(覆寫父類strong方法),同時兒子也具有母親的優點,只是 溫柔指數降低了。注意看,這裡構造了MotherSpecial類繼承母親類,也就是獲得了母親類的行為和方法,這也是內部類的一個重要特性:內部類可以 繼承一個與外部類無關的類,保證了內部類的獨立性,正是基於這一點,多重繼承才會成為可能。MotherSpecial的這種內部類叫做成員內部類(也叫 作例項內部類,Instance Inner Class),我們再來看看女兒類,程式碼如下:

class Daughter extends MotherImpl implements Father {

    @Override
    public int strong() {
        return new FatherImpl() {
            @Override
            public int strong() {
                //女兒的強壯指數降低了
                return super.strong() - 2;
            }
        }.strong();
    }

}

  女兒繼承了目前的溫柔指數,同時又覆寫了父親的強壯指數,不多解釋。注意看覆寫的strong方法,這裡是建立了一個匿名內部類(Anonymous Inner Class)來覆寫父類的方法,以完成繼承父親行為的功能。

  多重繼承指的是一個類可以同時從多與一個的父親那裡繼承行為與特徵,按照這個定義,我們的兒子類、女兒類都實現了從父親和母親那裡繼承所有的功能,應該屬於多重繼承,這完全歸功於內部類,大家在需要用到多重繼承的時候,可以思考一下內部類。

   在現實生活中,也確實存在多重繼承的問題,上面的例子說後人繼承了父親也繼承了母親的行為和特徵,再比如我國的特產動物"四不像"(學名麋鹿),其外 形" 似鹿非鹿,似馬非馬,似牛非牛,似驢非驢 ",這你想要用單繼承實現就比較麻煩了,如果用多繼承則可以很好地解決此問題:定義鹿、馬、牛、驢 四個類,然後建立麋鹿類的多個內部類,繼承他們即可。   

建議42:讓工具類不可例項化

  Java專案中使用的工具類非常多,比如JDK自己的工具類 java.lang.Math、java.util.Collections等都是我們經常用到的。工具類的方法和屬性都是靜態的,不需要生成例項即可訪 問,而且JDK也做了很好的處理,由於不希望被初始化,於是就設定了建構函式private的訪問許可權,表示出了類本身之外,誰都不能產生一個例項,我們 來看一下java.lang.Math程式碼:

public final class Math {

    /**
     * Don't let anyone instantiate this class.
     */
    private Math() {}
}

  之所以要將"Don't let anyone instantiate this class." 留下來,是因為Math的建構函式設定為了private:我就是一個工具類,我只想要其它類通過類名來訪問,我不想你通過例項物件來訪問。這在平臺型或 框架專案中已經足夠了。但是如果已經告訴你不要這麼做了,你還要生成一個Math物件例項來訪問靜態方法和屬性(Java的反射是如此的發達,修改個構造 函式的訪問許可權易如反掌),那我就不保證正確性了,隱藏問題隨時都有可能爆發!那我們在專案中有沒有更好地限制辦法呢?有,即不僅僅設定成private 許可權,還丟擲異常,程式碼如下: 

class UtilsClazz{
    public UtilsClazz(){
        throw new Error("Don't instantiate "+getClass());
    }
}

  如此,才能保證一個工具類不會例項化,並且保證了所有的訪問都是通過類名來進行的。需要注意的一點是,此工具類最好不要做整合的打算,因為如果子類可以例項化的話,就要呼叫父類的建構函式,可是父類沒有可以被訪問的建構函式,於是問題就會出現。 

  注意:如果一個類不允許例項化,就要保證"平常" 渠道都不能例項它。 

建議43:避免物件的淺拷貝

   我們知道一個類實現了Cloneable介面就表示它具備了被拷貝的能力。如果在覆寫clone()方法就會完全具備拷貝能力。拷貝是在記憶體中執行的, 所以在效能方面比直接通過new生成的物件要快很多,特別是在大物件的生成上,這會使得效能的提升非常顯著。但是物件拷貝也有一個比較容易忽略的問題:淺 拷貝(Shadow Clone,也叫作影子拷貝)存在物件屬性拷貝不徹底的問題。我們來看這樣一段程式碼:  

 1 public class Person implements Cloneable {
 2     public static void main(String[] args) {
 3         // 定義父親
 4         Person f = new Person("父親");
 5         // 定義大兒子
 6         Person s1 = new Person("大兒子", f);
 7         // 小兒子的資訊時通過大兒子拷貝過來的
 8         Person s2 = s1.clone();
 9         s2.setName("小兒子");
10         System.out.println(s1.getName() + " 的父親是 " + s1.getFather().getName());
11         System.out.println(s2.getName() + " 的父親是 " + s2.getFather().getName());
12     }
13     // 姓名
14     private String name;
15     // 父親
16     private Person father;
17 
18     public Person(String _name) {
19         name = _name;
20     }
21 
22     public Person(String _name, Person _parent) {
23         name = _name;
24         father = _parent;
25     }
26 
27     @Override
28     public Person clone() {
29         Person p = null;
30         try {
31             p = (Person) super.clone();
32         } catch (CloneNotSupportedException e) {
33             e.printStackTrace();
34         }
35         return p;
36 
37     }
38     
39   /*setter和getter方法略*/
40 }

   程式中我們描述了這樣一個場景:一個父親,有兩個兒子,大小兒子同根同種,所以小兒子的物件就通過拷貝大兒子的物件來生成,執行輸出結果如下:

    大兒子 的父親是 父親
           小兒子 的父親是 父親

  這很正確,沒有問題。突然有一天,父親心血來潮想讓大兒子去認個乾爹,也就是大兒子的父親名稱需要重新設定一下,程式碼如下:

    public static void main(String[] args) {
        // 定義父親
        Person f = new Person("父親");
        // 定義大兒子
        Person s1 = new Person("大兒子", f);
        // 小兒子的資訊時通過大兒子拷貝過來的
        Person s2 = s1.clone();
        s2.setName("小兒子");
        //認乾爹
        s1.getFather().setName("乾爹");
        System.out.println(s1.getName() + " 的父親是 " + s1.getFather().getName());
        System.out.println(s2.getName() + " 的父親是 " + s2.getFather().getName());
    }

  大兒子重新設定了父親名稱,我們期望的輸出結果是:將大兒子的父親名稱修改為乾爹,小兒子的父親名稱保持不變。執行一下結果如下:

    大兒子 的父親是 乾爹
           小兒子 的父親是 乾爹

  怎 麼回事,小兒子的父親也成了"乾爹"?兩個兒子都木有了,這老子估計被要被氣死了!出現這個問題的原因就在於clone方法,我們知道所有類都繼承自 Object,Object提供了一個物件拷貝的預設方法,即頁面程式碼中 的super.clone()方法,但是該方法是有缺陷的,他提供的是一種淺拷貝,也就是說它並不會把物件的所有屬性全部拷貝一份,而是有選擇的拷貝,它 的拷貝規則如下:

  1. 基本型別:如果變數是基本型別,則拷貝其值。比如int、float等
  2. 對 象:如果變數是一個例項物件,則拷貝其地址引用,也就是說此時拷貝出的物件與原有物件共享該例項變數,不受訪問許可權的控制,這在Java中是很瘋狂的,因 為它突破了訪問許可權的定義:一個private修飾的變數,竟然可以被兩個不同的例項物件訪問,這讓java的訪問許可權體系情何以堪。
  3. String字串:這個比較特殊,拷貝的也是一個地址,是個引用,但是在修改時,它會從字串池(String pool)中重新生成新的字串,原有的字串物件保持不變,在此處我們可以認為String是一個基本型別。

  明白了這三個原則,上面的例子就很清晰了。小兒子的物件是通過大兒子拷貝而來的,其父親是同一個人,也就是同一個物件,大兒子修改了父親的名稱後,小兒子也很跟著修改了——於是,父親的兩個兒子都沒了。其實要更正也很簡單,clone方法的程式碼如下: 

public Person clone() {
        Person p = null;
        try {
            p = (Person) super.clone();
            p.setFather(new Person(p.getFather().getName()));
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return p;

    }

  然後再執行,小兒子的父親就不會是乾爹了,如此就實現了物件的深拷貝(Deep Clone),保證拷貝出來的物件自成一體,不受"母體"影響,和new生成的物件沒有什麼區別。

  注意:淺拷貝只是Java提供的一種簡單拷貝機制,不便於直接使用。

建議44:推薦使用序列化物件的拷貝

    上一建議說了物件的淺拷貝問題,試下Cloneable介面就具備了拷貝能力,那我們開思考這樣一個問題:如果一個專案中有大量的物件是通過拷貝生成 的,那我們該如何處理呢?每個類都系而一個clone方法,並且還有深拷貝?想想這是何等巨大的工作量呀!是否有更好的方法呢?

  其實,可以通過序列化方式來處理,在記憶體中通過位元組流的拷貝來實現,也就是把母物件寫到一個位元組流中,再從位元組流中將其讀出來,這樣就可以重建一個物件了,該新物件與母物件之間不存在引用共享的問題,也就相當於深拷貝了一個物件,程式碼如下:  

 1 import java.io.ByteArrayInputStream;
 2 import java.io.ByteArrayOutputStream;
 3 import java.io.IOException;
 4 import java.io.ObjectInputStream;
 5 import java.io.ObjectOutputStream;
 6 import java.io.Serializable;
 7 
 8 public final class CloneUtils {
 9     private CloneUtils() {
10         throw new Error(CloneUtils.class + " cannot instance ");
11     }
12 
13     // 拷貝一個物件
14     public static <T extends Serializable> T clone(T obj) {
15         // 拷貝產生的物件
16         T cloneObj = null;
17         try {
18             // 讀取物件位元組資料
19             ByteArrayOutputStream baos = new ByteArrayOutputStream();
20             ObjectOutputStream oos = new ObjectOutputStream(baos);
21             oos.writeObject(cloneObj);
22             oos.close();
23             // 分配記憶體空間,寫入原始物件,生成新物件
24             ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
25             ObjectInputStream ois = new ObjectInputStream(bais);
26             // 返回新物件, 並做型別轉換
27             cloneObj = (T) ois.readObject();
28             ois.close();
29         } catch (ClassNotFoundException e) {
30             e.printStackTrace();
31         } catch (IOException e) {
32             e.printStackTrace();
33         }
34         return cloneObj;
35 
36     }
37 
38 }

   此工具類要求被拷貝的物件實現了Serializable 介面,否則是沒辦法拷貝的(當然,使用反射是另一種技巧),上一建議中的例子只是稍微修改一下即可實現深拷貝,程式碼如下

public class Person implements Serializable{
    private static final long serialVersionUID = 4989174799049521302L;
        /*刪除掉clone方法,其它程式碼保持不變*/
}

   被拷貝的類只要實現Serializable這個標誌性介面即可,不需要任何實現,當然serialVersionUID常量還是要加上去的,然後我們就可以通過CloneUtils工具進行物件的深拷貝了,用詞方法進行物件拷貝時需要注意兩點:

  1. 物件的內部屬性都是可序列化的:如果有內部屬性不可序列化,則會丟擲序列化異常,這會讓除錯者很納悶,生成一個物件怎麼回出現序列化異常呢?從這一點考慮,也需要把CloneUtils工具的異常進行細化處理。
  2. 注 意方法和屬性的特殊修飾符:比如final,static變數的序列化問題會被引入物件的拷貝中,這點需要特別注意,同時 transient變數(瞬態變數,不進行序列化的變數)也會影響到拷貝的效果。當然,採用序列化拷貝時還有一個更簡單的方法,即使用Apache下的 commons工具包中SerializationUtils類,直接使用更加簡潔.

建議45:覆寫equals方法時不要識別不出自己

   我們在寫一個JavaBean時,經常會覆寫equals方 法,其目的是根據業務規則判斷兩個物件是否相等,比如我們寫一個Person類,然後根據姓名判斷兩個例項物件是否相同時,這在DAO(Data Access Objects)層是經常用到的。具體操作時先從資料庫中獲得兩個DTO(Data Transfer Object,資料傳輸物件),然後判斷他們是否相等的,程式碼如下: 

public class Person {
    private String name;

    public Person(String _name) {
        name = _name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    
    @Override
    public boolean equals(Object obj) {
        if(obj instanceof Person){
            Person p = (Person) obj;
            return name.equalsIgnoreCase(p.getName().trim());
        }
        return false;
    }

}

  覆寫的equals方法做了多個校驗,考慮到Web上傳遞過來的物件有可能輸入了前後空格,所以用trim方法剪下了一下,看看程式碼有沒有問題,我們寫一個main:

public static void main(String[] args) {
        Person p1= new Person("張三");
        Person p2= new Person("張三  ");
        List<Person> list= new ArrayList<Person>();
        list.add(p1);
        list.add(p2);
        System.out.println("列表中是否包含張三:"+list.contains(p1));    
        System.out.println("列表中是否包含張三:"+list.contains(p2));
    }

  上面的程式碼產生了兩個Person物件(注意p2變數中的那個張三後面有一個空格),然後放到list中,最後判斷list是否包含了這兩個物件。看上去沒有問題,應該列印出兩個true才對,但是結果卻是:

   列表中是否包含張三:true
       列表中是否包含張三:false  

   剛剛放到list中的物件竟然說沒有,這太讓人失望了,原因何在呢?list類檢查是否包含元素時時通過呼叫物件的equals方法來判斷的,也就是說 contains(p2)傳遞進去,會依次執行p2.equals(p1),p2.equals(p2),只有一個返回true,結果都是true,可惜 的是比較結果都是false,那問題出來了:難道

p2.equals(p2)因為false不成?

  還真說對了,p2.equals(p2)確實是false,看看我們的equals方法,它把第二個引數進行了剪下!也就是說比較的如下等式:

"張三  ".equalsIgnoreCase("張三");

  注意前面的那個張三,是有空格的,那結果肯定是false了,錯誤也就此產生了,這是一個想做好事卻辦成了 "壞事" 的典型案例,它違背了equlas方法的自反性原則:對於任何非空引用x,x.equals(x)應該返回true,問題直到了,解決非常簡單,只要把trim()去掉即可。注意解決的只是當前問題,該equals方法還存在其它問題。

建議46:equals應該考慮null值情景

  繼續45建議的問題,我們解決了覆寫equals的自反性問題,是不是就完美了呢?在把main方法重構一下:  

public static void main(String[] args) {
        Person p1= new Person("張三");
        Person p2= new Person(null);
  /*其它部分沒有任何修改,不再贅述*/ }

  很小的改動,大家肯定曉得了執行結果是包"空指標"異常。原因也很簡單:null.equalsIgnoreCase方法自然報錯,此處就是為了說明覆寫equals方法遵循的一個原則---

  對稱性原則:對於任何引用x和y的情形,如果x.equals(y),把麼y.equals(x)也應該返回true。

  解決也很簡單,前面加上非空判斷即可,很簡單,就不貼程式碼了。

相關文章