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

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

  列舉和註解都是在Java1.5中引入的,雖然它們是後起之秀,但其功效不可小覷,列舉改變了常量的宣告方式,註解耦合了資料和程式碼。

建議83:推薦使用列舉定義常量

  常量宣告是每一個專案都不可或缺的,在Java1.5之前,我們只有兩種方式的宣告:類常量和介面常量,若在專案中使用的是Java1.5之前的版本,基本上都是如此定義的。不過,在1.5版本以後有了改進,即新增了一種常量宣告方式:列舉宣告常量,看如下程式碼:

enum Season {
        Spring, Summer, Autumn, Winter;
    }

 這是一個簡單的列舉常量命名,清晰又簡單。順便提一句,JLS(Java Language  Specification,Java語言規範)提倡列舉項全部大寫,字母之間用下劃線分割,這也是從常量的角度考慮的(當然,使用類似類名的命名方式也是比較友好的)。

  那麼列舉常量與我們經常使用的類常量和靜態常量相比有什麼優勢?問得好,列舉的優點主要表現在四個方面:

1.列舉常量簡單:簡不簡單,我們來對比一下兩者的定義和使用情況就知道了。先把Season列舉翻寫成介面常量,程式碼如下:   

1 interface Season {
2     int SPRING = 0;
3     int SUMMER = 1;
4     int AUTUMN = 2;
5     int WINTER = 3;
6 }

  此處定義了春夏秋冬四個季節,型別都是int,這與Season列舉的排序值是相同的。首先對比一下兩者的定義,列舉常量只需定義每個列舉項,不需要定義列舉值,而介面常量(或類常量)則必須定義值,否則編譯不通過,即使我們不需要關注其值是多少也必須定義;其次,雖然兩者被引用的方式相同(都是 “類名 . 屬性”,如Season.SPRING),但是列舉表示的是一個列舉項,字面含義是春天,而介面常量確是一個int型別,雖然其字面含義也是春天,但在運算中我們勢必要關注其int值。

2.列舉常量屬於穩態型

  例如我們要描述一下春夏秋冬是什麼樣子,使用介面常量應該是這樣寫。  

 1 public void describe(int s) {
 2         // s變數不能超越邊界,校驗條件
 3         if (s >= 0 && s < 4) {
 4             switch (s) {
 5             case Season.SPRING:
 6                 System.out.println("this is spring");
 7                 break;
 8             case Season.SUMMER:
 9                 System.out.println("this is summer");
10                 break;
11                 ......
12             }
13         }
14     }

  很簡單,先使用switch語句判斷哪一個是常量,然後輸出。但問題是我們得對輸入值進行檢查,確定是否越界,如果常量非常龐大,校驗輸入就成了一件非常麻煩的事情,但這是一個不可逃避的過程,特別是如果我們的校驗條件不嚴格,雖然編譯能照樣通過,但是執行期就會產生無法預知的後果。

  我們再來看看列舉常量是否能夠避免校驗的問題,程式碼如下:

 1 public void describe(Season s){
 2         switch(s){
 3         case Spring:
 4             System.out.println("this is "+Season.Spring);
 5             break;
 6         case Summer:
 7             System.out.println("this is summer"+Season.Summer);
 8             break;
 9                   ......
10         }
11     }
12     

  不用校驗,已經限定了是Season列舉,所以只能是Season類的四個例項,即春夏秋冬4個列舉項,想輸入一個int型別或其它型別?門都沒有!這是我們最看重列舉的地方:在編譯期間限定型別,不允許發生越界的情況。

3.列舉具有內建方法

  有一個簡單的問題:如果要列出所有的季節常量,如何實現呢?介面常量或類常量可以通過反射來實現,這沒錯,只是雖然能實現,但會非常繁瑣,大家可以自己寫一個反射類實現此功能(當然,一個一個地動手列印出輸出常量,也可以算是列出)。對於此類問題可以非常簡單的解決,程式碼如下: 

1 public void query() {
2         for (Season s : Season.values()) {
3             System.out.println(s);
4         }
5     }

  通過values方法獲得所有的列舉項,然後列印出來即可。如此簡單,得益於列舉內建的方法,每個列舉都是java.lang.Enum的子類,該基類提供了諸如獲得排序值的ordinal方法、compareTo比較方法等,大大簡化了常量的訪問。

4.列舉可以自定義的方法

  這一點似乎並不是列舉的優點,類常量也可以有自己的方法呀,但關鍵是列舉常量不僅可以定義靜態方法,還可以定義非靜態方法,而且還能夠從根本上杜絕常量類被例項化。比如我們要在常量定義中獲得最舒服季節的方法,使用常量列舉的程式碼如下: 

1 enum Season {
2         Spring, Summer, Autumn, Winter;
3         public static Season getComfortableSeason(){
4             return Spring;
5         }
6     }

  我們知道,每個列舉項都是該列舉的一個例項,對於我們的例子來說,也就表示Spring其實是Season的一個例項,Summer也是其中一個例項,那我們在列舉中定義的靜態方法既可以在類(也就是列舉Season)中引用,也可以在例項(也就是列舉項Spring、Summer、Autumn、Winter)中引用,看如下程式碼:

public static void main(String[] args) {
        System.out.println("The most comfortable season is "+Season.getComfortableSeason());
    }

  那如果使用類常量要如何實現呢?程式碼如下: 

1 class Season {
2     public final static int SPRING = 0;
3     public final static int SUMMER = 1;
4     public final static int AUTUMN = 2;
5     public final static int WINTER = 3;
6     public static  int getComfortableSeason(){
7         return SPRING;
8     }
9 }

  想想看,我們怎麼才能列印出"The most comfortable season is Spring" 這句話呢?除了使用switch和if判斷之外沒有其它辦法了。

  雖然列舉在很多方面比介面常量和類常量好用,但是有一點它是比不上介面常量和類常量的,那就是繼承,列舉型別是不能繼承的,也就是說一個列舉常量定義完畢後,除非修改重構,否則無法做擴充套件,而介面常量和類常量則可以通過繼承進行擴充套件。但是,一般常量在專案構建時就定義完畢了,很少會出現必須通過擴充套件才能實現業務邏輯的場景。

注意: 在專案中推薦使用列舉常量代替介面常量或類常量。

建議84:使用建構函式協助描述列舉項

 一般來說,我們經常使用的列舉項只有一個屬性,即排序號,其預設值是從0、1、2......,這一點我們很熟悉,但是除了排序號之外,列舉還有一個(或多個)屬性:列舉描述,他的含義是通過列舉的建構函式,宣告每個列舉項(也就是列舉的例項)必須具有的屬性和行為,這是對列舉項的描述或補充,目的是使列舉項描述的意義更加清晰準確。例如有這樣一段程式碼: 

 1 public enum Season {
 2     Spring("春"), Summer("夏"), Autumn("秋"), Winter("冬");
 3     private String desc;
 4 
 5     Season(String _desc) {
 6         desc = _desc;
 7     }
 8     //獲得列舉描述
 9     public String getDesc() {
10         return desc;
11     }
12 }

  其列舉選項是英文的,描述是中文的,如此設計使其表述的意義更加精確,方便了多個作者共同引用該常量。若不考慮描述的使用(即訪問getDesc方法),它與如下介面定義的描述很相似: 

interface Season{
    //
    int SPRING =0;
    //
    int SUMMER =1;
    //......
}

  比較兩段程式碼,很容易看出使用列舉項描述是一個很好的解決辦法,非常簡單、清晰。因為是一個描述(Description),那我們在開發時就可以賦予更多的含義,比如可以通過列舉建構函式宣告業務值,定義可選項,新增屬性等,看如下程式碼:

 1 enum Role {
 2     Admin("管理員", new LifeTime(), new Scope()), User("普通使用者", new LifeTime(), new Scope());
 3     private String name;
 4     private LifeTime lifeTime;
 5     private Scope scope;
 6     /* setter和getter方法略 */
 7 
 8     Role(String _name, LifeTime _lifeTime, Scope _scope) {
 9         name = _name;
10         lifeTime = _lifeTime;
11         scope = _scope;
12     }
13 
14 }
15 
16 class LifeTime {
17 }
18 class Scope {
19 }

  這是一個角色定義類,描述了兩個角色:管理員和普通使用者,同時它還通過建構函式對這兩個角色進行了描述:

  • name:表示的是該角色的中文名稱
  • lifeTime:表示的是該角色的生命週期,也就是多長時間該角色失效
  • scope:表示的該角色的許可權範圍

  大家可以看出,這樣一個描述可以使開發者對Admin和User兩個常量有一個立體多維度的認知,有名稱,有周期,還有範圍,而且還可以在程式中方便的獲得此類屬性。所以,推薦大家在列舉定義中為每個列舉項定義描述,特別是在大規模的專案開發中,大量的常量定義使用列舉項描述比在介面常量或類常量中增加註釋的方式友好的多,簡潔的多。

建議85:小心switch帶來的空指標異常

  使用列舉定義常量時。會伴有大量switch語句判斷,目的是為了每個列舉項解釋其行為,例如這樣一個方法: 

 1 public static void doSports(Season season) {
 2         switch (season) {
 3         case Spring:
 4             System.out.println("春天放風箏");
 5             break;
 6         case Summer:
 7             System.out.println("夏天游泳");
 8             break;
 9         case Autumn:
10             System.out.println("秋天是收穫的季節");
11             break;
12         case Winter:
13             System.out.println("冬天滑冰");
14             break;
15         default:
16             System.out.println("輸出錯誤");
17             break;
18         }
19     }

  上面的程式碼傳入了一個Season型別的列舉,然後使用switch進行匹配,目的是輸出每個季節的活動,現在的問題是這段程式碼又沒有問題:

  我們先來看看它是如何被呼叫的,因為要傳遞進來的是Season型別,也就是一個例項物件,那當然允許為空了,我們就傳遞一個null值進去看看程式碼又沒有問題,如下:

public static void main(String[] args) {
        doSports(null);
    }

  似乎會列印出“輸出錯誤”,因為switch中沒有匹配到指定值,所以會列印出defaut的程式碼塊,是這樣的嗎?不是,執行後的結果如下:

Exception in thread "main" java.lang.NullPointerException
    at com.book.study85.Client85.doSports(Client85.java:8)
    at com.book.study85.Client85.main(Client85.java:28)

  竟然是空指標異常,也就是switch的那一行,怎麼會有空指標呢?這就與列舉和switch的特性有關了,此問題也是在開發中經常發生的。我們知道,目前Java中的switch語句只能判斷byte、short、char、int型別(JDk7允許使用String型別),這是Java編譯器的限制。問題是為什麼列舉型別也可以跟在switch後面呢?

  因為編譯時,編譯器判斷出switch語句後跟的引數是列舉型別,然後就會根據列舉的排序值繼續匹配,也就是或上面的程式碼與以下程式碼相同:  

 1 public static void doSports(Season season) {
 2         switch (season.ordinal()) {
 3         case season.Spring.ordinal():
 4             System.out.println("春天放風箏");
 5             break;
 6         case season.Summer.ordinal():
 7             System.out.println("夏天游泳");
 8             break;
 9             //......
10         }
11     }

  看明白了吧,switch語句是先計算season變數的排序值,然後與列舉常量的每個排序值進行對比,在我們的例子中season是null,無法執行ordinal()方法,於是就報空指標異常了。問題清楚了,解決很簡單,在doSports方法中判斷輸入引數是否為null即可。

建議86:在switch的default程式碼塊中增加AssertionError錯誤

  switch後跟列舉型別,case後列出所有的列舉項,這是一個使用列舉的主流寫法,那留著default語句似乎沒有任何作用,程式已經列舉了所有的可能選項,肯定不會執行到defaut語句,看上去純屬多餘嘛!錯了,這個default還是很有作用的。以我們定義的日誌級別來說明,這是一個典型的列舉常量,如下所示: 

enum LogLevel{
        DEBUG,INFO,WARN,ERROR
    }

  一般在使用的時候,會通過switch語句來決定使用者設定的日誌級別,然後輸出不同級別的日誌程式碼,程式碼如下: 

 1 switch(LogLevel)
 2 
 3     {
 4     case:DEBUG:
 5         //.....
 6     case:INFO:
 7         //......
 8     case:WARN:
 9         //......
10     case:ERROR:
11         //......
12     }

  由於把所有的列舉項都列舉完了,不可能有其它值,所以就不需要default程式碼快了,這是普遍認識,但問題是我們的switch程式碼與列舉之間沒有強制約束關係,也就是說兩者只是在語義上建立了聯絡,並沒有一個強制約束,比如LogLevel的列舉項發生變化了,增加了一個列舉項FATAL,如果此時我們對switch語句不做任何修改,編譯雖不會出問題,但是執行期會發生非預期的錯誤:FATAL型別的日誌沒有輸出。

  為了避免出現這類錯誤,建議在default後直接丟擲一個AssertionError錯誤,其含義就是“不要跑到這裡來,一跑到這裡就會出問題”,這樣可以保證在增加一個列舉項的情況下,若其它程式碼未修改,執行期馬上就會出錯,這樣一來就很容易找到錯誤,方便立即排除。

  當然也有其它方法解決此問題,比如修改IDE工具,以Eclipse為例,可以把Java-->Compiler--->Errors/Warnings中的“Enum  type constant not covered on 'switch' ”設定為Error級別,如果不判斷所有的列舉項就不能編譯通過。

建議87:使用valueOf前必須進行校驗

  我們知道每個列舉項都是java.lang.Enum的子類,都可以訪問Enum類提供的方法,比如hashCode、name、valueOf等,其中valueOf方法會把一個String型別的名稱轉換為列舉項,也就是在列舉項中查詢出字面值與引數相等的列舉項。雖然這個方法簡單,但是JDK卻做了一個對於開發人員來說並不簡單的處理,我們來看程式碼:  

 1 public static void main(String[] args) {
 2         // 注意summer是小寫
 3         List<String> params = Arrays.asList("Spring", "summer");
 4         for (String name : params) {
 5             // 查詢字面值與name相同的列舉項,其中Season是前面例子中列舉Season
 6             Season s = Season.valueOf(name);
 7             if (null != s) {
 8                 // 有列舉項時
 9                 System.out.println(s);
10             } else {
11                 // 沒有該列舉項
12                 System.out.println("無相關列舉項");
13             }
14         }
15     }

  這段程式看起來沒什麼錯吧,其中考慮到從String轉換為列舉型別可能存在著轉換不成功的情況,比如沒有匹配找到指定值,此時ValueOf的返回值應該為空,所以後面又跟著if...else判斷輸出。我們看看執行結果 

Spring
Exception in thread "main" java.lang.IllegalArgumentException: No enum constant com.book.study01.Season.summer
    at java.lang.Enum.valueOf(Unknown Source)
    at com.book.study01.Season.valueOf(Season.java:1)
    at com.book.study85.Client85.main(Client85.java:14)

  報無效的引數異常,也就說我們的summer(注意s是小寫),無法轉換為Season列舉,無法轉換就 不轉換嘛,那也別丟擲IllegalArgumentException異常啊,一但丟擲這個異常,後續的程式碼就不會執行了,這與我們的習慣不符合呀,例如我們從List中查詢一個元素,即使不存在也不會報錯,頂多indexOf方法返回-1。那麼我們來深入分析一下該問題,valueOf方法的原始碼如下: 

 1 public static <T extends Enum<T>> T valueOf(Class<T> enumType, String name) {
 2         //通過反射,從常量列表中查詢
 3         T result = enumType.enumConstantDirectory().get(name);
 4         if (result != null)
 5             return result;
 6         if (name == null)
 7             throw new NullPointerException("Name is null");
 8         //最後丟擲無效引數異常
 9         throw new IllegalArgumentException("No enum constant " + enumType.getCanonicalName() + "." + name);
10     }

  valueOf方法先通過反射從列舉類的常量宣告中查詢,若找到就直接返回,若找不到則丟擲無效引數異常。valueOf的本意是保護編碼中的列舉安全性,使其不產生空列舉物件,簡化列舉操作,但是卻引入了一個我們無法避免的IllegalArgumentException異常。

  大家是否覺得此處的valueOf方法的原始碼不對,這裡要輸入兩個引數,而我們的Season.valueOf只傳遞一個String型別的引數,真的是這樣嗎?是的,因為valueOf(String name)方法是不可見的,是JVM內建的方法,我們只有通過閱讀公開的valueOf方法來了解其執行原理了。

  問題清楚了,有兩個方法可以解決此問題:

  (1)、使用try......catch捕捉異常

    這裡是最直接也是最簡單的方式,產生IllegalArgumentException即可確認為沒有同名的列舉的列舉項,程式碼如下: 

try{
        Season s = Season.valueOf(name);
        //有該列舉項時
        System.out.println(s);
    }catch(Exception e){
        e.printStackTrace();
        System.out.println("無相關列舉項");
    }

  (2)、擴充套件列舉類:由於Enum類定義的方法基本上都是final型別的,所以不希望被覆寫,我們可以學習String和List,通過增加一個contains方法來判斷是否包含指定的列舉項,然後再繼續轉換,程式碼如下。     

 1 enum Season {
 2         Spring, Summer, Autumn, Winter;
 3         // 是否包含指定的列舉項
 4         public static boolean contains(String name) {
 5             // 所有的列舉值
 6             Season[] season = values();
 7             for (Season s : season) {
 8                 if (s.name().equals(name)) {
 9                     return true;
10                 }
11             }
12             return false;
13         }
14     }

  Season列舉具備了靜態方法contains後,就可以在valueOf前判斷一下是否包含指定的列舉名稱了,若包含則可以通過valueOf轉換為列舉,若不包含則不轉換。

相關文章