編寫高質量程式碼:改善Java程式的151個建議(第6章:列舉和註解___建議88~92)

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

建議88:用列舉實現工廠方法模式更簡潔

  工廠方法模式(Factory Method Pattern)是" 建立物件的介面,讓子類決定例項化哪一個類,並使一個類的例項化延遲到其它子類"。工廠方法模式在我們的開發中經常會用到。下面以汽車製造為例,看看一般的工廠方法模式是如何實現的,程式碼如下:

 1 //抽象產品
 2 interface Car{
 3     
 4 }
 5 //具體產品類
 6 class FordCar implements Car{
 7     
 8 }
 9 //具體產品類
10 class BuickCar implements Car{
11     
12 }
13 //工廠類
14 class CarFactory{
15     //生產汽車
16     public static Car createCar(Class<? extends Car> c){
17         try {
18             return c.newInstance();
19         } catch (InstantiationException | IllegalAccessException e) {
20             e.printStackTrace();
21         }
22         return null;
23     }
24 }

  這是最原始的工廠方法模式,有兩個產品:福特汽車和別克汽車,然後通過工廠方法模式來生產。有了工廠方法模式,我們就不用關心一輛車具體是怎麼生成的了,只要告訴工廠" 給我生產一輛福特汽車 "就可以了,下面是產出一輛福特汽車時客戶端的程式碼: 

    public static void main(String[] args) {
        //生產車輛
        Car car = CarFactory.createCar(FordCar.class);
    }

  這就是我們經常使用的工廠方法模式,但經常使用並不代表就是最優秀、最簡潔的。此處再介紹一種通過列舉實現工廠方法模式的方案,誰優誰劣你自行評價。列舉實現工廠方法模式有兩種方法:

(1)、列舉非靜態方法實現工廠方法模式

  我們知道每個列舉項都是該列舉的例項物件,那是不是定義一個方法可以生成每個列舉項對應產品來實現此模式呢?程式碼如下:

 1 enum CarFactory {
 2     // 定義生產類能生產汽車的型別
 3     FordCar, BuickCar;
 4     // 生產汽車
 5     public Car create() {
 6         switch (this) {
 7         case FordCar:
 8             return new FordCar();
 9         case BuickCar:
10             return new BuickCar();
11         default:
12             throw new AssertionError("無效引數");
13         }
14     }
15 
16 }

  create是一個非靜態方法,也就是隻有通過FordCar、BuickCar列舉項才能訪問。採用這種方式實現工廠方法模式時,客戶端要生產一輛汽車就很簡單了,程式碼如下: 

public static void main(String[] args) {
        // 生產車輛
        Car car = CarFactory.BuickCar.create();
    }

(2)、通過抽象方法生成產品

  列舉型別雖然不能繼承,但是可以用abstract修飾其方法,此時就表示該列舉是一個抽象列舉,需要每個列舉項自行實現該方法,也就是說列舉項的型別是該列舉的一個子類,我們倆看程式碼:

 1 enum CarFactory {
 2     // 定義生產類能生產汽車的型別
 3     FordCar{
 4         public Car create(){
 5             return new FordCar();
 6         }
 7     },
 8     BuickCar{
 9         public Car create(){
10             return new BuickCar();
11         }
12     };
13     //抽象生產方法
14     public abstract Car create();
15 }

  首先定義一個抽象製造方法create,然後每個列舉項自行實現,這種方式編譯後會產生CarFactory的匿名子類,因為每個列舉項都要實現create抽象方法。客戶端呼叫與上一個方案相同,不再贅述。

  大家可能會問,為什麼要使用列舉型別的工廠方法模式呢?那是因為使用列舉型別的工廠方法模式有以下三個優點:

  • 避免錯誤呼叫的發生:一般工廠方法模式中的生產方法(也就是createCar方法),可以接收三種型別的引數:型別引數(如我們的例子)、String引數(生產方法中判斷String引數是需要生產什麼產品)、int引數(根據int值判斷需要生產什麼型別的的產品),這三種引數都是寬泛的資料型別,很容易發生錯誤(比如邊界問題、null值問題),而且出現這類錯誤編譯器還不會報警,例如:
    public static void main(String[] args) {
        // 生產車輛
        Car car = CarFactory.createCar(Car.class);
    }

  Car是一個介面,完全合乎createCar的要求,所以它在編譯時不會報任何錯誤,但一執行就會報出InstantiationException異常,而使用列舉型別的工廠方法模式就不存在該問題了,不需要傳遞任何引數,只需要選擇好生產什麼型別的產品即可。

  • 效能好,使用簡潔:列舉型別的計算時以int型別的計算為基礎的,這是最基本的操作,效能當然會快,至於使用便捷,注意看客戶端的呼叫,程式碼的字面意思就是" 汽車工廠,我要一輛別克汽車,趕快生產"。
  • 降低類間耦合:不管生產方法接收的是Class、String還是int的引數,都會成為客戶端類的負擔,這些類並不是客戶端需要的,而是因為工廠方法的限制必須輸入的,例如Class引數,對客戶端main方法來說,他需要傳遞一個FordCar.class引數才能生產一輛福特汽車,除了在create方法中傳遞引數外,業務類不需要改Car的實現類。這嚴重違背了迪米特原則(Law of Demeter  簡稱LoD),也就是最少知識原則:一個物件應該對其它物件有最少的瞭解。

  而列舉型別的工廠方法就沒有這種問題了,它只需要依賴工廠類就可以生產一輛符合介面的汽車,完全可以無視具體汽車類的存在。

建議89:列舉項的數量限制在64個以內

  為了更好地使用列舉,Java提供了兩個列舉集合:EnumSet和EnumMap,這兩個集合使用的方法都比較簡單,EnumSet表示其元素必須是某一列舉的列舉項,EnumMap表示Key值必須是某一列舉的列舉項,由於列舉型別的例項數量固定並且有限,相對來說EnumSet和EnumMap的效率會比其它Set和Map要高。

      雖然EnumSet很好用,但是它有一個隱藏的特點,我們逐步分析。在專案中一般會把列舉用作常量定義,可能會定義非常多的列舉項,然後通過EnumSet訪問、遍歷,但它對不同的列舉數量有不同的處理方式。為了進行對比,我們定義兩個列舉,一個數量等於64,一個是65(大於64即可,為什麼是64而不是128,512呢,一會解釋),程式碼如下: 

 1 //普通列舉項,數量等於64
 2 enum Const{
 3     A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,
 4     AA,BB,CC,DD,EE,FF,GG,HH,II,JJ,KK,LL,MM,NN,OO,PP,QQ,RR,SS,TT,UU,VV,WW,XX,YY,ZZ,
 5     AAA,BBB,CCC,DDD,EEE,FFF,GGG,HHH,III,JJJ,KKK,LLL
 6 }
 7 //大列舉,數量超過64
 8 enum LargeConst{
 9     A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,
10     AA,BB,CC,DD,EE,FF,GG,HH,II,JJ,KK,LL,MM,NN,OO,PP,QQ,RR,SS,TT,UU,VV,WW,XX,YY,ZZ,
11     AAAA,BBBB,CCCC,DDDD,EEEE,FFFF,GGGG,HHHH,IIII,JJJJ,KKKK,LLLL,MMMM
12 }

  Const的列舉項數量是64,LagrgeConst的列舉項數量是65,接下來我們希望把這兩個列舉轉換為EnumSet,然後判斷一下它們的class型別是否相同,程式碼如下: 

 1 public class Client89 {
 2     public static void main(String[] args) {
 3         EnumSet<Const> cs = EnumSet.allOf(Const.class);
 4         EnumSet<LargeConst> lcs = EnumSet.allOf(LargeConst.class);
 5         //列印出列舉數量
 6         System.out.println("Const的列舉數量:"+cs.size());
 7         System.out.println("LargeConst的列舉數量:"+lcs.size());
 8         //輸出兩個EnumSet的class
 9         System.out.println(cs.getClass());
10         System.out.println(lcs.getClass());
11     }
12 }

  程式很簡單,現在的問題是:cs和lcs的class型別是否相同?應該相同吧,都是EnumSet類的工廠方法allOf生成的EnumSet類,而且JDK API也沒有提示EnumSet有子類。我們來看看輸出結果:

  

  很遺憾,兩者不相等。就差一個元素,兩者就不相等了?確實如此,這也是我們重點關注列舉項數量的原因。先來看看Java是如何處理的,首先跟蹤allOf方法,其原始碼如下:  

1  public static <E extends Enum<E>> EnumSet<E> allOf(Class<E> elementType) {
2         //生成一個空EnumSet
3         EnumSet<E> result = noneOf(elementType);
4         //加入所有的列舉項
5         result.addAll();
6         return result;
7     }

  allOf通過noneOf方法首先生成了一個EnumSet物件,然後把所有的列舉都加進去,問題可能就出在EnumSet的生成上了,我們來看看noneOf的原始碼:  

 1   public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
 2         //獲得所有的列舉項
 3         Enum[] universe = getUniverse(elementType);
 4         if (universe == null)
 5             throw new ClassCastException(elementType + " not an enum");
 6         //列舉數量小於等於64
 7         if (universe.length <= 64)
 8             return new RegularEnumSet<>(elementType, universe);
 9         else 
10             //列舉數量大於64
11             return new JumboEnumSet<>(elementType, universe);
12     }

  看到這裡,恍然大悟,Java原來是如此處理的:當列舉項數量小於等於64時,建立一個RegularEnumSet例項物件,大於64時則建立一個JumboEnumSet例項物件。

  為什麼要如此處理呢?這還要看看這兩個類之間的差異,首先看RegularEnumSet類,原始碼如下:

 1 class RegularEnumSet<E extends Enum<E>> extends EnumSet<E> {
 2     private static final long serialVersionUID = 3411599620347842686L;
 3     /**
 4      * Bit vector representation of this set.  The 2^k bit indicates the
 5      * presence of universe[k] in this set.
 6      */
 7     //記錄所有的列舉號,注意是long型
 8     private long elements = 0L;
 9    //建構函式
10     RegularEnumSet(Class<E>elementType, Enum[] universe) {
11         super(elementType, universe);
12     }
13 
14    //加入所有元素
15     void addAll() {
16         if (universe.length != 0)
17             elements = -1L >>> -universe.length;
18     }
19     
20    //其它程式碼略
21 }

  我們知道列舉項的排序值ordinal 是從0、1、2......依次遞增的,沒有重號,沒有跳號,RegularEnumSet就是利用這一點把每個列舉項的ordinal對映到一個long型別的每個位置上的,注意看addAll方法的elements元素,它使用了無符號右移操作,並且運算元是負值,位移也是負值,這表示是負數(符號位是1)的"無符號左移":符號位為0,並補充低位,簡單的說,Java把一個不多於64個列舉項對映到了一個long型別變數上。這才是EnumSet處理的重點,其他的size方法、contains方法等都是根據elements方法等都是根據elements計算出來的。想想看,一個long型別的數字包含了所有的列舉項,其效率和效能能肯定是非常優秀的。

  我們知道long型別是64位的,所以RegularEnumSet型別也就只能負責列舉項的數量不大於64的列舉(這也是我們以64來舉例,而不以128,512舉例的原因),大於64則由JumboEnumSet處理,我們看它是怎麼處理的: 

 1 class JumboEnumSet<E extends Enum<E>> extends EnumSet<E> {
 2     private static final long serialVersionUID = 334349849919042784L;
 3 
 4     /**
 5      * Bit vector representation of this set.  The ith bit of the jth
 6      * element of this array represents the  presence of universe[64*j +i]
 7      * in this set.
 8      */
 9    //對映所有的列舉項
10     private long elements[];
11 
12     // Redundant - maintained for performance
13     private int size = 0;
14 
15     JumboEnumSet(Class<E>elementType, Enum[] universe) {
16         super(elementType, universe);
17         //預設長度是列舉項數量除以64再加1
18         elements = new long[(universe.length + 63) >>> 6];
19     }
20 
21       void addAll() {
22         //elements中每個元素表示64個列舉項
23         for (int i = 0; i < elements.length; i++)
24             elements[i] = -1;
25         elements[elements.length - 1] >>>= -universe.length;
26         size = universe.length;
27     }
28 }

  JumboEnumSet類把列舉項按照64個元素一組拆分成了多組,每組都對映到一個long型別的數字上,然後該陣列再放置到elements陣列中,簡單來說JumboEnumSet類的原理與RegularEnumSet相似,只是JumboEnumSet使用了long陣列容納更多的列舉項。不過,這樣的程式看著會不會覺得鬱悶呢?其實這是因為我們在開發中很少使用位移操作。大家可以這樣理解:RegularEnumSet是把每個列舉項對映到一個long型別數字的每個位上,JumboEnumSet是先按照64個一組進行拆分,然後每個組再對映到一個long型別數字的每個位上。

  從以上的分析可知,EnumSet提供的兩個實現都是基本的數字型別操作,其效能肯定比其他的Set型別要好的多,特別是Enum的數量少於64的時候,那簡直就是飛一般的速度。

  注意:列舉項數量不要超過64,否則建議拆分。

建議90:小心註解繼承

  Java從1.5版本開始引入註解(Annotation),其目的是在不影響程式碼語義的情況下增強程式碼的可讀性,並且不改變程式碼的執行邏輯,對於註解始終有兩派爭論,正方認為註解有益於資料與程式碼的耦合,"在有程式碼的周邊集合資料";反方認為註解把程式碼和資料混淆在一起,增加了程式碼的易變性,消弱了程式的健壯性和穩定性。這些爭論暫且擱置,我們要說的是一個我們不常用的元註解(Meta-Annotation):@Inheruted,它表示一個註解是否可以自動繼承,我們開看它如何使用。

  思考一個例子,比如描述鳥類,它有顏色、體型、習性等屬性,我們以顏色為例,定義一個註解來修飾一下,程式碼如下:

 1 import java.lang.annotation.ElementType;
 2 import java.lang.annotation.Inherited;
 3 import java.lang.annotation.Retention;
 4 import java.lang.annotation.RetentionPolicy;
 5 import java.lang.annotation.Target;
 6 
 7 @Retention(RetentionPolicy.RUNTIME)
 8 @Target(ElementType.TYPE)
 9 @Inherited
10 public @interface Desc {
11     enum Color {
12         White, Grayish, Yellow
13     }
14 
15     // 預設顏色是白色的
16     Color c() default Color.White;
17 }

  該註解Desc前增加了三個註解:Retention表示的是該註解的保留級別,Target表示的是註解可以標註在什麼地方,@Inherited表示該註解會被自動繼承。註解定義完畢,我們把它標註在類上,程式碼如下: 

 1 @Desc(c = Color.White)
 2 abstract class Bird {
 3     public abstract Color getColor();
 4 }
 5 
 6 // 麻雀
 7 class Sparrow extends Bird {
 8     private Color color;
 9 
10     // 預設是淺灰色
11     public Sparrow() {
12         color = Color.Grayish;
13     }
14 
15     // 建構函式定義鳥的顏色
16     public Sparrow(Color _color) {
17         color = _color;
18     }
19 
20     @Override
21     public Color getColor() {
22         return color;
23     }
24 }
25 
26 // 鳥巢,工廠方法模式
27 enum BirdNest {
28     Sparrow;
29     // 鳥類繁殖
30     public Bird reproduce() {
31         Desc bd = Sparrow.class.getAnnotation(Desc.class);
32         return bd == null ? new Sparrow() : new Sparrow(bd.c());
33     }
34 }

  上面程式宣告瞭一個Bird抽象類,並且標註了Desc註解,描述鳥類的顏色是白色,然後編寫一個麻雀Sparrow類,它有兩個建構函式,一個是預設的建構函式,也就是我們經常看到的麻雀是淺灰色的,另外一個建構函式是自定義麻雀的顏色,之後又定義了一個鳥巢(工廠方法模式),它是專門負責鳥類繁殖的,它的生產方法reproduce會根據實現類註解資訊生成不同顏色的麻雀。我們編寫一個客戶端呼叫,程式碼如下:   

1 public static void main(String[] args) {
2         Bird bird = BirdNest.Sparrow.reproduce();
3         Color color = bird.getColor();
4         System.out.println("Bird's color is :" + color);
5     }

  現在問題是這段客戶端程式會列印出什麼來?因為採用了工廠方法模式,它最主要的問題就是bird變數到底採用了那個建構函式來生成,是無參建構函式還是有參構造?如果我們單獨看子類Sparrow,它沒有被新增任何註釋,那工廠方法中的bd變數就應該是null了,應該呼叫的是無參構造。是不是如此呢?我們來看執行結果:“Bird‘s  Color  is White ”;

  白色?這是我們新增到父類Bird上的顏色,為什麼?這是因為我們在註解上加了@Inherited註解,它表示的意思是我們只要把註解@Desc加到父類Bird上,它的所有子類都會從父類繼承@Desc註解,不需要顯示宣告,這與Java的繼承有點不同,若Sparrow類繼承了Bird卻不用顯示宣告,只要@Desc註解釋可自動繼承的即可。

  採用@Inherited元註解有利有弊,利的地方是一個註解只要標註到父類,所有的子類都會自動具有父類相同的註解,整齊,統一而且便於管理,弊的地方是單單閱讀子類程式碼,我們無從知道為何邏輯會被改變,因為子類沒有顯示標註該註解。總體上來說,使用@Inherited元註解弊大於利,特別是一個類的繼承層次較深時,如果註解較多,則很難判斷出那個註解對子類產生了邏輯劫持。

建議91:列舉和註解結合使用威力更大

  我們知道註解的寫法和介面很類似,都採用了關鍵字interface,而且都不能有實現程式碼,常量定義預設都是public static final  型別的等,它們的主要不同點是:註解要在interface前加上@字元,而且不能繼承,不能實現,這經常會給我們的開發帶來些障礙。  

  我們來分析一下ACL(Access  Control   List,訪問控制列表)設計案例,看看如何避免這些障礙,ACL有三個重要元素:

  • 資源,有哪些資訊是要被控制起來的。
  • 許可權級別,不同的訪問者規劃在不同的級別中。
  • 控制器(也叫鑑權人),控制不同的級別訪問不同的資源。

  鑑權人是整個ACL的設計核心,我們從最主要的鑑權人開始,程式碼如下:   

interface Identifier{
    //無權訪問時的禮貌語
    String REFUSE_WORD  =  "您無權訪問";
    //鑑權
    public  boolean identify();
}

  這是一個鑑權人介面,定義了一個常量和一個鑑權方法。接下來應該實現該鑑權方法,但問題是我們的許可權級別和鑑權方法之間是緊耦合,若分拆成兩個類顯得有點囉嗦,怎麼辦?我們可以直接頂一個列舉來實現,程式碼如下:

 1 enum CommonIdentifier implements Identifier {
 2     // 許可權級別
 3     Reader, Author, Admin;
 4 
 5     @Override
 6     public boolean identify() {
 7         return false;
 8     }
 9 
10 }

  定義了一個通用鑑權者,使用的是列舉型別,並且實現了鑑權者介面。現在就剩下資源定義了,這很容易定義,資源就是我們寫的類、方法等,之後再通過配置來決定哪些類、方法允許什麼級別的訪問,這裡的問題是:怎麼把資源和許可權級別關聯起來呢?使用XML配置檔案?是個方法,但對我們的示例程式來說顯得太繁重了,如果使用註解會更簡潔些,不過這需要我們首先定義出許可權級別的註解,程式碼如下:

1 @Retention(RetentionPolicy.RUNTIME)
2 @Target(ElementType.TYPE)
3 @interface Access{
4     //什麼級別可以訪問,預設是管理員
5     CommonIdentifier level () default CommonIdentifier.Admin;
6 }

  該註解釋標註在類上面的,並且會保留到執行期。我們定義一個資源類,程式碼如下: 

@Access(level=CommonIdentifier.Author)
class Foo{
    
}

  Foo類只能是作者級別的人訪問。場景都定義完畢了,那我們看看如何模擬ACL實現,程式碼如下:

 1 public static void main(String[] args) {
 2         // 初始化商業邏輯
 3         Foo b = new Foo();
 4         // 獲取註解
 5         Access access = b.getClass().getAnnotation(Access.class);
 6         // 沒有Access註解或者鑑權失敗
 7         if (null == access || !access.level().identify()) {
 8             // 沒有Access註解或者鑑權失敗
 9             System.out.println(access.level().REFUSE_WORD);
10         }
11     }

  看看這段程式碼,簡單,易讀,而且如果我們是通過ClassLoader類來解釋該註解的,那會使我們的開發更簡潔,所有的開發人員只要增加註解即可解決訪問控制問題。注意看加粗程式碼,access是一個註解型別,我們想使用Identifier介面的identity鑑權方法和REFUSE_WORD常量,但註解釋不能整合的,那怎麼辦?此處,可通過列舉型別CommonIdentifier從中間做一個委派動作(Delegate),委派?你可以然identity返回一個物件,或者在Identifier上直接定義一個常量物件,那就是“赤裸裸” 的委派了。

建議92:注意@Override不同版本的區別

  @Override註解用於方法的覆寫上,它是在編譯器有效,也就是Java編譯器在編譯時會根據註解檢查方法是否真的是覆寫,如果不是就報錯,拒絕編譯。該註解可以很大程度地解決我們的誤寫問題,比如子類和父類的方法名少寫一個字元,或者是數字0和字母O為區分出來等,這基本是每個程式設計師都曾將犯過的錯誤。在程式碼中加上@Override註解基本上可以杜絕出現此類問題,但是@Override有個版本問題,我們來看如下程式碼:

 1 interface Foo {
 2     public void doSomething();
 3 }
 4 
 5 class FooImpl implements Foo{
 6     @Override
 7     public void doSomething() {
 8         
 9     }
10 }

 這是一個簡單的@Override示例,介面中定義了一個doSomething方法,實現類FooImpl實現此方法,並且在方法前加上了@Override註解。這段程式碼在Java1.6版本上編譯沒問題,雖然doSomething方法只是實現了介面的定義,嚴格來說並不是覆寫,但@Override出現在這裡可減少程式碼中出現的錯誤。

  可如果在Java1.5版本上編譯此段程式碼可能會出現錯誤:

      The  method doSomeThing()  of type FooImpl must override  a superclass  method 

  注意,這是個錯誤,不能繼續編譯,原因是Java1.5版本的@Override是嚴格遵守覆寫的定義:子類方法與父類方法必須具有相同的方法名、輸出引數、輸出引數(允許子類縮小)、訪問許可權(允許子類擴大),父類必須是一個類,不能是介面,否則不能算是覆寫。而這在Java1.6就開放了很多,實現介面的方法也可以加上@Override註解了,可以避免粗心大意導致方法名稱與介面不一致的情況發生。

  在多環境部署應用時,需呀考慮@Override在不同版本下代表的意義,如果是Java1.6版本的程式移植到1.5版本環境中,就需要刪除實現介面方法上的@Override註解。

相關文章