計算機程式的思維邏輯 (23) – 列舉的本質

swiftma發表於2019-02-28

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (23) – 列舉的本質

前面系列,我們介紹了Java中表示和運算元據的基本資料型別、類和介面,本節探討Java中的列舉型別。

所謂列舉,是一種特殊的資料,它的取值是有限的,可以列舉出來的,比如說一年就是有四季、一週有七天,雖然使用類也可以處理這種資料,但列舉型別更為簡潔、安全和方便。

下面我們就來介紹列舉的使用,同時介紹其實現原理。

基礎

基本用法

定義和使用基本的列舉是比較簡單的,我們來看個例子,為表示衣服的尺寸,我們定義一個列舉型別Size,包括三個尺寸,小/中/大,程式碼如下:

public enum Size {
    SMALL, MEDIUM, LARGE
}
複製程式碼

列舉使用enum這個關鍵字來定義,Size包括三個值,分別表示小、中、大,值一般是大寫的字母,多個值之間以逗號分隔。列舉型別可以定義為一個單獨的檔案,也可以定義在其他類內部。

可以這樣使用Size:

Size size = Size.MEDIUM
複製程式碼

Size size宣告瞭一個變數size,它的型別是Size,size=Size.MEDIUM將列舉值MEDIUM賦值給size變數。

列舉變數的toString方法返回其字面值,所有列舉型別也都有一個name()方法,返回值與toString()一樣,例如:

Size size = Size.SMALL;
System.out.println(size.toString());
System.out.println(size.name());
複製程式碼

輸出都是SMALL。

列舉變數可以使用equals和==進行比較,結果是一樣的,例如:

Size size = Size.SMALL;
System.out.println(size==Size.SMALL);
System.out.println(size.equals(Size.SMALL));
System.out.println(size==Size.MEDIUM);
複製程式碼

上面程式碼的輸出結果為三行,分別是true, true, false。

列舉值是有順序的,可以比較大小。列舉型別都有一個方法int ordinal(),表示列舉值在宣告時的順序,從0開始,例如,如下程式碼輸出為1:

Size size = Size.MEDIUM;
System.out.println(size.ordinal());
複製程式碼

另外,列舉型別都實現了Java API中的Comparable介面,都可以通過方法compareTo與其他列舉值進行比較,比較其實就是比較ordinal的大小,例如,如下程式碼輸出為-1,表示SMALL小於MEDIUM:

Size size = Size.SMALL;
System.out.println(size.compareTo(Size.MEDIUM));
複製程式碼

列舉變數可以用於和其他型別變數一樣的地方,如方法引數、類變數、例項變數等,列舉還可以用於switch語句,程式碼如下所示:

static void onChosen(Size size){
    switch(size){
    case SMALL:
        System.out.println("chosen small"); break;
    case MEDIUM:
        System.out.println("chosen medium"); break;
    case LARGE:
        System.out.println("chosen large"); break;
    }
}
複製程式碼

在switch語句內部,列舉值不能帶列舉型別字首,例如,直接使用SMALL,不能使用Size.SMALL。

列舉型別都有一個靜態的valueOf(String)方法,可以返回字串對應的列舉值,例如,以下程式碼輸出為true:

System.out.println(Size.SMALL==Size.valueOf("SMALL"));
複製程式碼

列舉型別也都有一個靜態的values方法,返回一個包括所有列舉值的陣列,順序與宣告時的順序一致,例如:

for(Size size : Size.values()){
    System.out.println(size);
}
複製程式碼

螢幕輸出為三行,分別是SMALL, MEDIUM, LARGE。

列舉的好處

Java是從JDK 5才開始支援列舉的,在此之前,一般是在類中定義靜態整形變數來實現類似功能,程式碼如下所示:

class Size {
    public static final int SMALL = 0;
    public static final int MEDIUM = 1;
    public static final int LARGE = 2;
}
複製程式碼

列舉的好處是比較明顯的:

  • 定義列舉的語法更為簡潔。
  • 列舉更為安全,一個列舉型別的變數,它的值要麼為null,要麼為列舉值之一,不可能為其他值,但使用整形變數,它的值就沒有辦法強制,值可能就是無效的。
  • 列舉型別自帶很多便利方法(如values, valueOf, toString等),易於使用。

基本實現原理

列舉型別實際上會被Java編譯器轉換為一個對應的類,這個類繼承了Java API中的java.lang.Enum類。

Enum類有兩個例項變數name和ordinal,在構造方法中需要傳遞,name(), toString(), ordinal(), compareTo(), equals()方法都是由Enum類根據其例項變數name和ordinal實現的。

values和valueOf方法是編譯器給每個列舉型別自動新增的,上面的列舉型別Size轉換後的普通類的程式碼大概如下所示:

public final class Size extends Enum<Size> {
    public static final Size SMALL = new Size("SMALL",0);
    public static final Size MEDIUM = new Size("MEDIUM",1);
    public static final Size LARGE = new Size("LARGE",2);
    
    private static Size[] VALUES =
            new Size[]{SMALL,MEDIUM,LARGE};
    
    private Size(String name, int ordinal){
        super(name, ordinal);
    }
    
    public static Size[] values(){
        Size[] values = new Size[VALUES.length];
        System.arraycopy(VALUES, 0,
                values, 0, VALUES.length);
        return values;
    }
    
    public static Size valueOf(String name){
        return Enum.valueOf(Size.class, name);
    }
}
複製程式碼

解釋幾點:

  • Size是final的,不能被繼承,Enum表示父類,是泛型寫法,我們後續文章介紹,此處可以忽略。
  • Size有一個私有的構造方法,接受name和ordinal,傳遞給父類,私有表示不能在外部建立新的例項。
  • 三個列舉值實際上是三個靜態變數,也是final的,不能被修改。
  • values方法是編譯器新增的,內部有一個values陣列保持所有列舉值。
  • valueOf方法呼叫的是父類的方法,額外傳遞了引數Size.class,表示類的型別資訊,型別資訊我們後續文章介紹,父類實際上是回過頭來呼叫values方法,根據name對比得到對應的列舉值的。

一般列舉變數會被轉換為對應的類變數,在switch語句中,列舉值會被轉換為其對應的ordinal值。

可以看出,列舉型別本質上也是類,但由於編譯器自動做了很多事情,它的使用也就更為簡潔、安全和方便

典型場景

用法

以上列舉用法是最簡單的,實際中列舉經常會有關聯的例項變數和方法,比如說,上面的Size例子,每個列舉值可能有關聯的縮寫和中文名稱,可能需要靜態方法根據縮寫返回對應的列舉值,修改後的Size程式碼如下所示:

public enum Size {
    SMALL("S","小號"),
    MEDIUM("M","中號"),
    LARGE("L","大號");
    
    private String abbr;
    private String title;
    
    private Size(String abbr, String title){
        this.abbr = abbr;
        this.title = title;
    }

    public String getAbbr() {
        return abbr;
    }

    public String getTitle() {
        return title;
    }
    
    public static Size fromAbbr(String abbr){
        for(Size size : Size.values()){
            if(size.getAbbr().equals(abbr)){
                return size;
            }
        }
        return null;
    }
}
複製程式碼

以上程式碼定義了兩個例項變數abbr和title,以及對應的get方法,分別表示縮寫和中文名稱,定義了一個私有構造方法,接受縮寫和中文名稱,每個列舉值在定義的時候都傳遞了對應的值,同時定義了一個靜態方法fromAbbr根據縮寫返回對應的列舉值。

需要說明的是,列舉值的定義需要放在最上面,列舉值寫完之後,要以分號(;)結尾,然後才能寫其他程式碼。

這個列舉定義的使用與其他類類似,比如說:

Size s = Size.MEDIUM;
System.out.println(s.getAbbr());

s = Size.fromAbbr("L");
System.out.println(s.getTitle());
複製程式碼

以上程式碼分別輸出: M, 大號

實現原理

加了例項變數和方法後,列舉轉換後的類與上面的類似,只是增加了對應的變數和方法,修改了構造方法,程式碼不同之處大概如下所示:

public final class Size extends Enum<Size> {
  public static final Size SMALL =        
          new Size("SMALL",0, "S", "小號");     
  public static final Size MEDIUM =       
          new Size("MEDIUM",1,"M","中號");      
  public static final Size LARGE =        
          new Size("LARGE",2,"L","大號");       
                                          
  private String abbr;                    
  private String title;                   
                                          
  private Size(String name, int ordinal,  
          String abbr, String title){         
      super(name, ordinal);               
      this.abbr = abbr;                   
      this.title = title;                 
  }
  //... 其他程式碼
}  
複製程式碼

說明

每個列舉值經常有一個關聯的標示(id),通常用int整數表示,使用整數可以節約儲存空間,減少網路傳輸。一個自然的想法是使用列舉中自帶的ordinal值,但ordinal並不是一個好的選擇。

為什麼呢?因為ordinal的值會隨著列舉值在定義中的位置變化而變化,但一般來說,我們希望id值和列舉值的關係保持不變,尤其是表示列舉值的id已經儲存在了很多地方的時候。

比如說,上面的Size例子,Size.SMALL的ordinal的值為0,我們希望0表示的就是Size.SMALL的,但如果我們增加一個表示超小的值XSMALL呢?

public enum Size {
    XSMALL, SMALL, MEDIUM, LARGE
}
複製程式碼

這時,0就表示XSMALL了。

所以,一般是增加一個例項變數表示id,使用例項變數的另一個好處是,id可以自己定義。比如說,Size例子可以寫為:

public enum Size {
    XSMALL(10), SMALL(20), MEDIUM(30), LARGE(40);
    
    private int id;
    private Size(int id){
        this.id = id;
    }
    public int getId() {
        return id;
    }
}
複製程式碼

高階用法

列舉還有一些高階用法,比如說,每個列舉值可以有關聯的類定義體,列舉型別可以宣告抽象方法,每個列舉值中可以實現該方法,也可以重寫列舉型別的其他方法。

比如說,我們看改後的Size程式碼(這個程式碼實際意義不大,主要展示語法):

public enum Size {
    SMALL {
        @Override
        public void onChosen() {
            System.out.println("chosen small");
        }
    },MEDIUM {
        @Override
        public void onChosen() {
            System.out.println("chosen medium");
        }
    },LARGE {
        @Override
        public void onChosen() {
            System.out.println("chosen large");
        }
    };
    
    public abstract void onChosen();
} 
複製程式碼

Size列舉型別定義了onChosen抽象方法,表示選擇了該尺寸後執行的程式碼,每個列舉值後面都有一個類定義體{},都重寫了onChosen方法。

這種寫法有什麼好處呢?如果每個或部分列舉值有一些特定的行為,使用這種寫法比較簡潔。對於這個例子,上面我們介紹了其對應的switch語句,在switch語句中根據size的值執行不同的程式碼。

switch的缺陷是,定義swich的程式碼和定義列舉型別的程式碼可能不在一起,如果新增了列舉值,應該需要同樣修改switch程式碼,但可能會忘記,而如果使用抽象方法,則不可能忘記,在定義列舉值的同時,編譯器會強迫同時定義相關行為程式碼。所以,如果行為程式碼和列舉值是密切相關的,使用以上寫法可以更為簡潔、安全、容易維護。

這種寫法內部是怎麼實現的呢?每個列舉值都會生成一個類,這個類繼承了列舉型別對應的類,然後再加上值特定的類定義體程式碼,列舉值會變成這個子類的物件,具體程式碼我們就不贅述了。

列舉還有一些其他高階用法,比如說,列舉可以實現介面,也可以在介面中定義列舉,使用相對較少,本文就不介紹了。

小結

本節介紹了列舉型別,介紹了基礎用法、典型場景及高階用法,不僅介紹瞭如何使用,還介紹了實現原理,對於列舉型別的資料,雖然直接使用類也可以處理,但列舉型別更為簡潔、安全和方便。

我們之前提到過異常,但並未深入討論,讓我們下節來探討。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (23) – 列舉的本質

相關文章