關於Java中列舉Enum的深入剖析

技術小黑屋發表於2016-11-29

在程式語言中我們,都會接觸到列舉型別,通常我們進行有窮的列舉來實現一些限定。Java也不例外。Java中的列舉型別為Enum,本文將對列舉進行一些比較深入的剖析。

什麼是Enum

Enum是自Java 5 引入的特性,用來方便Java開發者實現列舉應用。一個簡單的Enum使用如下。

// ColorEnum.java
public enum ColorEmun {
    RED,
    GREEN,
    YELLOW
}

public void setColorEnum(ColorEmun colorEnum) {
    //some code here
}

setColorEnum(ColorEmun.GREEN);

為什麼會有Enum

在Enum之前的我們使用類似如下的程式碼實現列舉的功能.

public static final int COLOR_RED = 0;
public static final int COLOR_GREEN = 1;
public static final int COLOR_YELLOW = 2;

public void setColor(int color) {
    //some code here
}
//呼叫
setColor(COLOR_RED)

然而上面的還是有不盡完美的地方

  • setColor(COLOR_RED)與setColor(0)效果一樣,而後者可讀性很差,但卻可以正常執行
  • setColor方法可以接受列舉之外的值,比如setColor(3),這種情況下程式可能出問題

概括而言,傳統列舉有如下兩個弊端

  • 安全性
  • 可讀性,尤其是列印日誌時

因此Java引入了Enum,使用Enum,我們實現上面的列舉就很簡單了,而且還可以輕鬆避免傳入非法值的風險.

列舉原理是什麼

Java中Enum的本質其實是在編譯時期轉換成對應的類的形式。

首先,為了探究列舉的原理,我們先簡單定義一個列舉類,這裡以季節為例,類名為Season,包含春夏秋冬四個列舉條目.

public enum Season {
    SPRING,
    SUMMER,
    AUTUMN,
    WINTER
}

然後我們使用javac編譯上面的類,得到class檔案.

javac Season.java

然後,我們利用反編譯的方法來看看位元組碼檔案究竟是什麼.這裡使用的工具是javap的簡單命令,先列舉一下這個Season下的全部元素.

company javap Season
Warning: Binary file Season contains com.company.Season
Compiled from "Season.java"
public final class com.company.Season extends java.lang.Enum<com.company.Season> {
  public static final com.company.Season SPRING;
  public static final com.company.Season SUMMER;
  public static final com.company.Season AUTUMN;
  public static final com.company.Season WINTER;
  public static com.company.Season[] values();
  public static com.company.Season valueOf(java.lang.String);
  static {};
}

從上反編譯結果可知

  • java程式碼中的Season轉換成了繼承自的java.lang.enum的類
  • 既然隱式繼承自java.lang.enum,也就意味java程式碼中,Season不能再繼承其他的類
  • Season被標記成了final,意味著它不能被繼承

static程式碼塊

使用javap具體反編譯class檔案,得到靜態程式碼塊相關的結果為

static {};
    Code:
       0: new           #4                  // class com/company/Season
       3: dup
       4: ldc           #7                  // String SPRING
       6: iconst_0
       7: invokespecial #8                  // Method "<init>":(Ljava/lang/String;I)V
      10: putstatic     #9                  // Field SPRING:Lcom/company/Season;
      13: new           #4                  // class com/company/Season
      16: dup
      17: ldc           #10                 // String SUMMER
      19: iconst_1
      20: invokespecial #8                  // Method "<init>":(Ljava/lang/String;I)V
      23: putstatic     #11                 // Field SUMMER:Lcom/company/Season;
      26: new           #4                  // class com/company/Season
      29: dup
      30: ldc           #12                 // String AUTUMN
      32: iconst_2
      33: invokespecial #8                  // Method "<init>":(Ljava/lang/String;I)V
      36: putstatic     #13                 // Field AUTUMN:Lcom/company/Season;
      39: new           #4                  // class com/company/Season
      42: dup
      43: ldc           #14                 // String WINTER
      45: iconst_3
      46: invokespecial #8                  // Method "<init>":(Ljava/lang/String;I)V
      49: putstatic     #15                 // Field WINTER:Lcom/company/Season;
      52: iconst_4
      53: anewarray     #4                  // class com/company/Season
      56: dup
      57: iconst_0
      58: getstatic     #9                  // Field SPRING:Lcom/company/Season;
      61: aastore
      62: dup
      63: iconst_1
      64: getstatic     #11                 // Field SUMMER:Lcom/company/Season;
      67: aastore
      68: dup
      69: iconst_2
      70: getstatic     #13                 // Field AUTUMN:Lcom/company/Season;
      73: aastore
      74: dup
      75: iconst_3
      76: getstatic     #15                 // Field WINTER:Lcom/company/Season;
      79: aastore
      80: putstatic     #1                  // Field $VALUES:[Lcom/company/Season;
      83: return
}

其中

  • 0~52為例項化SPRING, SUMMER, AUTUMN, WINTER
  • 53~83為建立Season[]陣列$VALUES,並將上面的四個物件放入陣列的操作.

values方法

values方法的的返回值實際上就是上面$VALUES陣列物件

swtich中的列舉

在Java中,switch-case是我們經常使用的流程控制語句.當列舉出來之後,switch-case也很好的進行了支援.

比如下面的程式碼是完全正常編譯,正常執行的.

public static void main(String[] args) {
        Season season = Season.SPRING;
        switch(season) {
            case SPRING:
                System.out.println("It's Spring");
                break;

            case WINTER:
                System.out.println("It's Winter");
                break;

            case SUMMER:
                System.out.println("It's Summer");
                break;
            case AUTUMN:
                System.out.println("It's Autumn");
                break;
        }
    }

不過,通常情況下switch-case支援類似int的型別,那麼它是怎麼做到對Enum的支援呢,我們反編譯上述方法看一下位元組碼的真實情況.

public static void main(java.lang.String[]);
    Code:
       0: getstatic     #2                  // Field com/company/Season.SPRING:Lcom/company/Season;
       3: astore_1
       4: getstatic     #3                  // Field com/company/Main$1.$SwitchMap$com$company$Season:[I
       7: aload_1
       8: invokevirtual #4                  // Method com/company/Season.ordinal:()I
      11: iaload
      12: tableswitch   { // 1 to 4
                     1: 44
                     2: 55
                     3: 66
                     4: 77
               default: 85
          }
      44: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      47: ldc           #6                  // String It's Spring
      49: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      52: goto          85
      55: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      58: ldc           #8                  // String It's Winter
      60: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      63: goto          85
      66: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      69: ldc           #9                  // String It's Summer
      71: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      74: goto          85
      77: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      80: ldc           #10                 // String It's Autumn
      82: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      88: return

注意上面程式碼塊有這樣的一段程式碼

8: invokevirtual #4                  // Method com/company/Season.ordinal:()I

事實果真如此,在switch-case中,還是將Enum轉成了int值(通過呼叫Enum.oridinal()方法)

列舉與混淆

在Android開發中,進行混淆是我們在釋出前必不可少的工作,混下後,我們能增強反編譯的難度,在一定程度上保護了增強了安全性.

而開發人員處理混淆更多的是將某些元素加入不混淆的名單,這裡列舉就是需要排除混淆的.

在預設的混淆配置檔案中,已經加入了關於對列舉混淆的處理

# For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

關於為什麼要保留values()方法和valueOf()方法,請參考文章讀懂 Android 中的程式碼混淆 關於列舉的部分

使用proguard優化

使用Proguard進行優化,可以將列舉儘可能的轉換成int。配置如下

-optimizations class/unboxing/enum

確保上述程式碼生效,需要確proguard配置檔案不包含-dontoptimize指令。

當我們使用gradlew打包是,看到類似下面的輸出,即Number of unboxed enum classes:1代表已經將一個列舉轉換成了int的形式。

Optimizing...
  Number of finalized classes:                 0   (disabled)
  Number of unboxed enum classes:              1
  Number of vertically merged classes:         0   (disabled)
  Number of horizontally merged classes:       0   (disabled)

列舉單例

單例模式是我們在日常開發中可謂是最常用的設計模式.

然後要設計好單例模式,無非考慮一下幾點

  • 確保只有唯一例項,不多建立多餘例項
  • 確保例項按需建立.

因此傳統的做法想要實現單例,大致有一下幾種

  • 餓漢式載入
  • 懶漢式synchronize和雙重檢查
  • 利用java的靜態載入機制

相比上述的方法,使用列舉也可以實現單例,而且還更加簡單.

public enum AppManager {
    INSTANCE;

    private String tagName;
    public void setTag(String tagName) {
        this.tagName = tagName;
    }

    public String getTag() {
        return tagName;
    }
}

呼叫起來也更加簡單

AppManager.INSTANCE.getTag();

列舉如何確保唯一例項

因為獲得例項只能通過AppManager.INSTANCE

下面的方式是不可以的

AppManager appManager = new AppManager(); //compile error

關於單例模式,可以閱讀單例這種設計模式瞭解更多。

(Android中)該不該用列舉

既然上面提到了列舉會轉換成類,這樣理論上造成了下面的問題

  • 增加了dex包的大小,理論上dex包越大,載入速度越慢
  • 同時使用列舉,執行時的記憶體佔用也會相對變大

關於上面兩點的驗證,秋百萬已經做了詳細的論證,大家可以參考這篇文章《Android 中的 Enum 到底佔多少記憶體?該如何用?》

關於列舉是否使用的結論,大家可以參考

  • 如果你開發的是Framework不建議使用enum
  • 如果是簡單的enum,可以使用int很輕鬆代替,則不建議使用enum
  • 另外,如果是Android中,可以使用下面介紹的列舉註解來實現。
  • 除此之外,我們還需要對比可讀性和易維護性來與效能進行衡量,從中進行做出折中

在Android中的替代

Android中新引入的替代列舉的註解有IntDef和StringDef,這裡以IntDef做例子說明一下.

public class Colors {
    @IntDef({RED, GREEN, YELLOW})
    @Retention(RetentionPolicy.SOURCE)
    public @interface LightColors{}

    public static final int RED = 0;
    public static final int GREEN = 1;
    public static final int YELLOW = 2;
}
  • 宣告必要的int常量
  • 宣告一個註解為LightColors
  • 使用@IntDef修飾LightColors,引數設定為待列舉的集合
  • 使用@Retention(RetentionPolicy.SOURCE)指定註解僅存在與原始碼中,不加入到class檔案中

比如我們用來標註方法的引數

private void setColor(@Colors.LightColors int color) {
        Log.d("MainActivity", "setColor color=" + color);
}

呼叫的該方法的時候

setColor(Colors.GREEN);

關於Android中的列舉,可以參考探究Android中的註解

以上就是我對Java中enum的一些深入的剖析,歡迎大家不吝賜教。

相關文章