Effective Java 第三版——34. 使用枚舉類型替代整型常量


Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必很多人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到現在已經將近8年的時間,但隨着Java 6,7,8,甚至9的發布,Java語言發生了深刻的變化。
在這里第一時間翻譯成中文版。供大家學習分享之用。

Effective Java, Third Edition

Java支持兩種引用類型的特殊用途的系列:一種稱為枚舉類型的類和一種稱為注解類型的接口。 本章討論使用這些類型系列的最佳實踐。

34. 使用枚舉類型替代整型常量

枚舉是其合法值由一組固定的常量組成的一種類型,例如一年中的季節,太陽系中的行星或一副撲克牌中的套裝。 在將枚舉類型添加到該語言之前,表示枚舉類型的常見模式是聲明一組名為int的常量,每個類型的成員都有一個常量:

// The int enum pattern - severely deficient!
public static final int APPLE_FUJI         = 0;
public static final int APPLE_PIPPIN       = 1;
public static final int APPLE_GRANNY_SMITH = 2;
public static final int ORANGE_NAVEL  = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD  = 2;

這種被稱為int枚舉模式的技術有許多缺點。 它沒有提供類型安全的方式,也沒有提供任何表達力。 如果你將一個Apple傳遞給一個需要Orange的方法,那么編譯器不會出現警告,還會用==運算符比較Apple與Orange,或者更糟糕的是:

// Tasty citrus flavored applesauce!
int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

請注意,每個Apple常量的名稱前綴為APPLE_,每個Orange常量的名稱前綴為ORANGE_。 這是因為Java不為int枚舉組提供名稱空間。 當兩個int枚舉組具有相同的命名常量時,前綴可以防止名稱沖突,例如在ELEMENT_MERCURYPLANET_MERCURY之間。

使用int枚舉的程序很脆弱。 因為int枚舉是編譯時常量[JLS,4.12.4],所以它們的int值被編譯到使用它們的客戶端中[JLS,13.1]。 如果與int枚舉關聯的值發生更改,則必須重新編譯其客戶端。 如果沒有,客戶仍然會運行,但他們的行為將是不正確的。

沒有簡單的方法將int枚舉常量轉換為可打印的字符串。 如果你打印這樣一個常量或者從調試器中顯示出來,你看到的只是一個數字,這不是很有用。 沒有可靠的方法來迭代組中的所有int枚舉常量,甚至無法獲得int枚舉組的大小。

你可能會遇到這種模式的變體,其中使用了字符串常量來代替int常量。 這種稱為字符串枚舉模式的變體更不理想。 盡管它為常量提供了可打印的字符串,但它可以導致初級用戶將字符串常量硬編碼為客戶端代碼,而不是使用屬性名稱。 如果這種硬編碼的字符串常量包含書寫錯誤,它將在編譯時逃脫檢測並導致運行時出現錯誤。 此外,它可能會導致性能問題,因為它依賴於字符串比較。

幸運的是,Java提供了一種避免int和String枚舉模式的所有缺點的替代方法,並提供了許多額外的好處。 它是枚舉類型[JLS,8.9]。 以下是它最簡單的形式:

public enum Apple  { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }

從表面上看,這些枚舉類型可能看起來與其他語言類似,比如C,C ++和C#,但事實並非如此。 Java的枚舉類型是完整的類,比其他語言中的其他語言更強大,其枚舉本質本上是int值。

Java枚舉類型背后的基本思想很簡單:它們是通過公共靜態final屬性為每個枚舉常量導出一個實例的類。 由於沒有可訪問的構造方法,枚舉類型實際上是final的。 由於客戶既不能創建枚舉類型的實例也不能繼承它,除了聲明的枚舉常量外,不能有任何實例。 換句話說,枚舉類型是實例控制的(第6頁)。 它們是單例(條目 3)的泛型化,基本上是單元素的枚舉。

枚舉提供了編譯時類型的安全性。 如果聲明一個參數為Apple類型,則可以保證傳遞給該參數的任何非空對象引用是三個有效Apple值中的一個。 嘗試傳遞錯誤類型的值將導致編譯時錯誤,因為會嘗試將一個枚舉類型的表達式分配給另一個類型的變量,或者使用==運算符來比較不同枚舉類型的值。

具有相同名稱常量的枚舉類型可以和平共存,因為每種類型都有其自己的名稱空間。 可以在枚舉類型中添加或重新排序常量,而無需重新編譯其客戶端,因為導出常量的屬性在枚舉類型與其客戶端之間提供了一層隔離:常量值不會編譯到客戶端,因為它們位於int枚舉模式中。 最后,可以通過調用其toString方法將枚舉轉換為可打印的字符串。

除了糾正int枚舉的缺陷之外,枚舉類型還允許添加任意方法和屬性並實現任意接口。 它們提供了所有Object方法的高質量實現(第3章),它們實現了Comparable(條目 14)和Serializable(第12章),並針對枚舉類型的可任意改變性設計了序列化方式。

那么,為什么你要添加方法或屬性到一個枚舉類型? 對於初學者,可能想要將數據與其常量關聯起來。 例如,我們的Apple和Orange類型可能會從返回水果顏色的方法或返回水果圖像的方法中受益。 還可以使用任何看起來合適的方法來增強枚舉類型。 枚舉類型可以作為枚舉常量的簡單集合,並隨着時間的推移而演變為全功能抽象。

對於豐富的枚舉類型的一個很好的例子,考慮我們太陽系的八顆行星。 每個行星都有質量和半徑,從這兩個屬性可以計算出它的表面重力。 從而在給定物體的質量下,計算出一個物體在行星表面上的重量。 下面是這個枚舉類型。 每個枚舉常量之后的括號中的數字是傳遞給其構造方法的參數。 在這種情況下,它們是地球的質量和半徑:

// Enum type with data and behavior
public enum Planet {
    MERCURY(3.302e+23, 2.439e6),
    VENUS  (4.869e+24, 6.052e6),
    EARTH  (5.975e+24, 6.378e6),
    MARS   (6.419e+23, 3.393e6),
    JUPITER(1.899e+27, 7.149e7),
    SATURN (5.685e+26, 6.027e7),
    URANUS (8.683e+25, 2.556e7),
    NEPTUNE(1.024e+26, 2.477e7);


    private final double mass;           // In kilograms
    private final double radius;         // In meters
    private final double surfaceGravity; // In m / s^2
    // Universal gravitational constant in m^3 / kg s^2
    private static final double G = 6.67300E-11;


    // Constructor
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        surfaceGravity = G * mass / (radius * radius);
    }


    public double mass()           { return mass; }
    public double radius()         { return radius; }
    public double surfaceGravity() { return surfaceGravity; }


    public double surfaceWeight(double mass) {
        return mass * surfaceGravity;  // F = ma
    }
}

編寫一個豐富的枚舉類型比如Planet很容易。 要將數據與枚舉常量相關聯,請聲明實例屬性並編寫一個構造方法,構造方法帶有數據並將數據保存在屬性中。 枚舉本質上是不變的,所以所有的屬性都應該是final的(條目 17)。 屬性可以是公開的,但最好將它們設置為私有並提供公共訪問方法(條目16)。 在Planet的情況下,構造方法還計算和存儲表面重力,但這只是一種優化。 每當重力被SurfaceWeight方法使用時,它可以從質量和半徑重新計算出來,該方法返回它在由常數表示的行星上的重量。

雖然Planet枚舉很簡單,但它的功能非常強大。 這是一個簡短的程序,它將一個物體在地球上的重量(任何單位),打印一個漂亮的表格,顯示該物體在所有八個行星上的重量(以相同單位):

public class WeightTable {
   public static void main(String[] args) {
      double earthWeight = Double.parseDouble(args[0]);
      double mass = earthWeight / Planet.EARTH.surfaceGravity();
      for (Planet p : Planet.values())
          System.out.printf("Weight on %s is %f%n",
                            p, p.surfaceWeight(mass));
      }
}

請注意,Planet和所有枚舉一樣,都有一個靜態values方法,該方法以聲明的順序返回其值的數組。 另請注意,toString方法返回每個枚舉值的聲明名稱,使printlnprintf可以輕松打印。 如果你對此字符串表示形式不滿意,可以通過重寫toString方法來更改它。 這是使用命令行參數185運行WeightTable程序(不重寫toString)的結果:

Weight on MERCURY is 69.912739
Weight on VENUS is 167.434436
Weight on EARTH is 185.000000
Weight on MARS is 70.226739
Weight on JUPITER is 467.990696
Weight on SATURN is 197.120111
Weight on URANUS is 167.398264
Weight on NEPTUNE is 210.208751

直到2006年,在Java中加入枚舉兩年之后,冥王星不再是一顆行星。 這引發了一個問題:“當你從枚舉類型中移除一個元素時會發生什么?”答案是,任何不引用移除元素的客戶端程序都將繼續正常工作。 所以,舉例來說,我們的WeightTable程序只需要打印一行少一行的表格。 什么是客戶端程序引用刪除的元素(在這種情況下,Planet.Pluto)? 如果重新編譯客戶端程序,編譯將會失敗並在引用前一個星球的行處提供有用的錯誤消息; 如果無法重新編譯客戶端,它將在運行時從此行中引發有用的異常。 這是你所希望的最好的行為,遠遠好於你用int枚舉模式得到的結果。

一些與枚舉常量相關的行為只需要在定義枚舉的類或包中使用。 這些行為最好以私有或包級私有方式實現。 然后每個常量攜帶一個隱藏的行為集合,允許包含枚舉的類或包在與常量一起呈現時作出適當的反應。 與其他類一樣,除非你有一個令人信服的理由將枚舉方法暴露給它的客戶端,否則將其聲明為私有的,如果需要的話將其聲明為包級私有(條目 15)。

如果一個枚舉是廣泛使用的,它應該是一個頂級類; 如果它的使用與特定的頂級類綁定,它應該是該頂級類的成員類(條目 24)。 例如,java.math.RoundingMode枚舉表示小數部分的舍入模式。 BigDecimal類使用了這些舍入模式,但它們提供了一種有用的抽象,它並不與BigDecimal有根本的聯系。 通過將RoundingMode設置為頂層枚舉,類庫設計人員鼓勵任何需要舍入模式的程序員重用此枚舉,從而提高跨API的一致性。

// Enum type that switches on its own value - questionable
public enum Operation {
    PLUS, MINUS, TIMES, DIVIDE;

    // Do the arithmetic operation represented by this constant
    public double apply(double x, double y) {
        switch(this) {
            case PLUS:   return x + y;
            case MINUS:  return x - y;
            case TIMES:  return x * y;
            case DIVIDE: return x / y;
        }
        throw new AssertionError("Unknown op: " + this);
    }
}

此代碼有效,但不是很漂亮。 如果沒有throw語句,就不能編譯,因為該方法的結束在技術上是可達到的,盡管它永遠不會被達到[JLS,14.21]。 更糟的是,代碼很脆弱。 如果添加新的枚舉常量,但忘記向switch語句添加相應的條件,枚舉仍然會編譯,但在嘗試應用新操作時,它將在運行時失敗。

幸運的是,有一種更好的方法可以將不同的行為與每個枚舉常量關聯起來:在枚舉類型中聲明一個抽象的apply方法,並用常量特定的類主體中的每個常量的具體方法重寫它。 這種方法被稱為特定於常量(constant-specific)的方法實現:

// Enum type with constant-specific method implementations
public enum Operation {
  PLUS  {public double apply(double x, double y){return x + y;}},
  MINUS {public double apply(double x, double y){return x - y;}},
  TIMES {public double apply(double x, double y){return x * y;}},
  DIVIDE{public double apply(double x, double y){return x / y;}};

  public abstract double apply(double x, double y);
}

如果向第二個版本的操作添加新的常量,則不太可能會忘記提供apply方法,因為該方法緊跟在每個常量聲明之后。 萬一忘記了,編譯器會提醒你,因為枚舉類型中的抽象方法必須被所有常量中的具體方法重寫。

特定於常量的方法實現可以與特定於常量的數據結合使用。 例如,以下是Operation的一個版本,它重寫toString方法以返回通常與該操作關聯的符號:

// Enum type with constant-specific class bodies and data
public enum Operation {
    PLUS("+") {
        public double apply(double x, double y) { return x + y; }
    },
    MINUS("-") {
        public double apply(double x, double y) { return x - y; }
    },
    TIMES("*") {
        public double apply(double x, double y) { return x * y; }
    },
    DIVIDE("/") {
        public double apply(double x, double y) { return x / y; }
    };


    private final String symbol;


    Operation(String symbol) { this.symbol = symbol; }


    @Override public String toString() { return symbol; }


    public abstract double apply(double x, double y);
}

顯示的toString實現可以很容易地打印算術表達式,正如這個小程序所展示的那樣:

public static void main(String[] args) {
    double x = Double.parseDouble(args[0]);
    double y = Double.parseDouble(args[1]);
    for (Operation op : Operation.values())
        System.out.printf("%f %s %f = %f%n",
                          x, op, y, op.apply(x, y));
}

以2和4作為命令行參數運行此程序會生成以下輸出:

2.000000 + 4.000000 = 6.000000
2.000000 - 4.000000 = -2.000000
2.000000 * 4.000000 = 8.000000
2.000000 / 4.000000 = 0.500000

枚舉類型具有自動生成的valueOf(String)方法,該方法將常量名稱轉換為常量本身。 如果在枚舉類型中重寫toString方法,請考慮編寫fromString方法將自定義字符串表示法轉換回相應的枚舉類型。 下面的代碼(類型名稱被適當地改變)將對任何枚舉都有效,只要每個常量具有唯一的字符串表示形式:

// Implementing a fromString method on an enum type
private static final Map<String, Operation> stringToEnum =
        Stream.of(values()).collect(
            toMap(Object::toString, e -> e));

// Returns Operation for string, if any
public static Optional<Operation> fromString(String symbol) {
    return Optional.ofNullable(stringToEnum.get(symbol));
}

請注意,Operation枚舉常量被放在stringToEnum的map中,它來自於創建枚舉常量后運行的靜態屬性初始化。前面的代碼在values()方法返回的數組上使用流(第7章);在Java 8之前,我們創建一個空的hashMap並遍歷值數組,將字符串到枚舉映射插入到map中,如果願意,仍然可以這樣做。但請注意,嘗試讓每個常量都將自己放入來自其構造方法的map中不起作用。這會導致編譯錯誤,這是好事,因為如果它是合法的,它會在運行時導致NullPointerException。除了編譯時常量屬性(條目 34)之外,枚舉構造方法不允許訪問枚舉的靜態屬性。此限制是必需的,因為靜態屬性在枚舉構造方法運行時尚未初始化。這種限制的一個特例是枚舉常量不能從構造方法中相互訪問。

另請注意,fromString方法返回一個Optional<String>。 這允許該方法指示傳入的字符串不代表有效的操作,並且強制客戶端面對這種可能性(條目 55)。

特定於常量的方法實現的一個缺點是它們使得難以在枚舉常量之間共享代碼。 例如,考慮一個代表工資包中的工作天數的枚舉。 該枚舉有一個方法,根據工人的基本工資(每小時)和當天工作的分鍾數計算當天工人的工資。 在五個工作日內,任何超過正常工作時間的工作都會產生加班費; 在兩個周末的日子里,所有工作都會產生加班費。 使用switch語句,通過將多個case標簽應用於兩個代碼片段中的每一個,可以輕松完成此計算:

// Enum that switches on its value to share code - questionable
enum PayrollDay {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    SATURDAY, SUNDAY;

    private static final int MINS_PER_SHIFT = 8 * 60;

    int pay(int minutesWorked, int payRate) {
        int basePay = minutesWorked * payRate;

        int overtimePay;
        switch(this) {
          case SATURDAY: case SUNDAY: // Weekend
            overtimePay = basePay / 2;
            break;
          default: // Weekday
            overtimePay = minutesWorked <= MINS_PER_SHIFT ?
              0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
        }

        return basePay + overtimePay;
    }
}

這段代碼無可否認是簡潔的,但從維護的角度來看是危險的。 假設你給枚舉添加了一個元素,可能是一個特殊的值來表示一個假期,但忘記在switch語句中添加一個相應的case條件。 該程序仍然會編譯,但付費方法會默默地為工作日支付相同數量的休假日,與普通工作日相同。

要使用特定於常量的方法實現安全地執行工資計算,必須為每個常量重復加班工資計算,或將計算移至兩個輔助方法,一個用於工作日,另一個用於周末,並調用適當的輔助方法來自每個常量。 這兩種方法都會產生相當數量的樣板代碼,大大降低了可讀性並增加了出錯機會。

通過使用執行加班計算的具體方法替換PayrollDay上的抽象overtimePay方法,可以減少樣板。 那么只有周末的日子必須重寫該方法。 但是,這與switch語句具有相同的缺點:如果在不重寫overtimePay方法的情況下添加另一天,則會默默繼承周日計算方式。

你真正想要的是每次添加枚舉常量時被迫選擇加班費策略。 幸運的是,有一個很好的方法來實現這一點。 這個想法是將加班費計算移入私有嵌套枚舉中,並將此策略枚舉的實例傳遞給PayrollDay枚舉的構造方法。 然后,PayrollDay枚舉將加班工資計算委托給策略枚舉,從而無需在PayrollDay中實現switch語句或特定於常量的方法實現。 雖然這種模式不如switch語句簡潔,但它更安全,更靈活:

// The strategy enum pattern
enum PayrollDay {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);


    private final PayType payType;


    PayrollDay(PayType payType) { this.payType = payType; }
    PayrollDay() { this(PayType.WEEKDAY); }  // Default


    int pay(int minutesWorked, int payRate) {
        return payType.pay(minutesWorked, payRate);
    }


    // The strategy enum type
    private enum PayType {
        WEEKDAY {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked <= MINS_PER_SHIFT ? 0 :
                  (minsWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked * payRate / 2;
            }
        };


        abstract int overtimePay(int mins, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;


        int pay(int minsWorked, int payRate) {
            int basePay = minsWorked * payRate;
            return basePay + overtimePay(minsWorked, payRate);
        }
    }
}

如果對枚舉的switch語句不是實現常量特定行為的好選擇,那么它們有什么好處呢?枚舉類型的switch有利於用常量特定的行為增加枚舉類型。例如,假設Operation枚舉不在你的控制之下,你希望它有一個實例方法來返回每個相反的操作。你可以用以下靜態方法模擬效果:

// Switch on an enum to simulate a missing method
public static Operation inverse(Operation op) {
    switch(op) {
        case PLUS:   return Operation.MINUS;
        case MINUS:  return Operation.PLUS;
        case TIMES:  return Operation.DIVIDE;
        case DIVIDE: return Operation.TIMES;


        default:  throw new AssertionError("Unknown op: " + op);
    }
}

如果某個方法不屬於枚舉類型,則還應該在你控制的枚舉類型上使用此技術。 該方法可能需要用於某些用途,但通常不足以用於列入枚舉類型。

一般而言,枚舉通常在性能上與int常數相當。 枚舉的一個小小的性能缺點是加載和初始化枚舉類型存在空間和時間成本,但在實踐中不太可能引人注意。

那么你應該什么時候使用枚舉呢? 任何時候使用枚舉都需要一組常量,這些常量的成員在編譯時已知。 當然,這包括“天然枚舉類型”,如行星,星期幾和棋子。 但是它也包含了其它你已經知道編譯時所有可能值的集合,例如菜單上的選項,操作代碼和命令行標志。** 一個枚舉類型中的常量集不需要一直保持不變**。 枚舉功能是專門設計用於允許二進制兼容的枚舉類型的演變。

總之,枚舉類型優於int常量的優點是令人信服的。 枚舉更具可讀性,更安全,更強大。 許多枚舉不需要顯式構造方法或成員,但其他人則可以通過將數據與每個常量關聯並提供行為受此數據影響的方法而受益。 使用單一方法關聯多個行為可以減少枚舉。 在這種相對罕見的情況下,更喜歡使用常量特定的方法來枚舉自己的值。 如果一些(但不是全部)枚舉常量共享共同行為,請考慮策略枚舉模式。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM