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

來源:互聯網
上載者:User

標籤:出版   用途   ons   水果   更改   依賴   將不   編碼   ram   

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

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類型,則可以保證傳遞給該參數的任何非Null 物件引用是三個有效Apple值中的一個。 嘗試傳遞錯誤類型的值將導致編譯時間錯誤,因為會嘗試將一個枚舉類型的運算式分配給另一個類型的變數,或者使用==運算子來比較不同枚舉類型的值。

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

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

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

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

// Enum type with data and behaviorpublic 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.912739Weight on VENUS is 167.434436Weight on EARTH is 185.000000Weight on MARS is 70.226739Weight on JUPITER is 467.990696Weight on SATURN is 197.120111Weight on URANUS is 167.398264Weight 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 - questionablepublic 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 implementationspublic 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 datapublic 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.0000002.000000 - 4.000000 = -2.0000002.000000 * 4.000000 = 8.0000002.000000 / 4.000000 = 0.500000

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

// Implementing a fromString method on an enum typeprivate static final Map<String, Operation> stringToEnum =        Stream.of(values()).collect(            toMap(Object::toString, e -> e));// Returns Operation for string, if anypublic 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 - questionableenum 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 patternenum 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 methodpublic 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常量的優點是令人信服的。 枚舉更具可讀性,更安全,更強大。 許多枚舉不需要顯式構造方法或成員,但其他人則可以通過將資料與每個常量關聯並提供行為受此資料影響的方法而受益。 使用單一方法關聯多個行為可以減少枚舉。 在這種相對罕見的情況下,更喜歡使用常量特定的方法來枚舉自己的值。 如果一些(但不是全部)枚舉常量共用共同行為,請考慮策略枚舉模式。

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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.