Tips
《Effective Java, Third Edition》一書英文版已經出版,這本書的第二版想必不少人都讀過,號稱Java四大名著之一,不過第二版2009年出版,到如今已經將近8年的時間,但隨着Java 6,7,8,甚至9的發佈,Java語言發生了深入的變化。
在這裏第一時間翻譯成中文版。供你們學習分享之用。java
Java支持兩種引用類型的特殊用途的系列:一種稱爲枚舉類型的類和一種稱爲註解類型的接口。 本章討論使用這些類型系列的最佳實踐。程序員
枚舉是其合法值由一組固定的常量組成的一種類型,例如一年中的季節,太陽系中的行星或一副撲克牌中的套裝。 在將枚舉類型添加到該語言以前,表示枚舉類型的常見模式是聲明一組名爲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_MERCURY
和PLANET_MERCURY
之間。安全
使用int枚舉的程序很脆弱。 由於int枚舉是編譯時常量[JLS,4.12.4],因此它們的int值被編譯到使用它們的客戶端中[JLS,13.1]。 若是與int枚舉關聯的值發生更改,則必須從新編譯其客戶端。 若是沒有,客戶仍然會運行,但他們的行爲將是不正確的。app
沒有簡單的方法將int枚舉常量轉換爲可打印的字符串。 若是你打印這樣一個常量或者從調試器中顯示出來,你看到的只是一個數字,這不是頗有用。 沒有可靠的方法來迭代組中的全部int枚舉常量,甚至沒法得到int枚舉組的大小。ide
你可能會遇到這種模式的變體,其中使用了字符串常量來代替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
方法返回每一個枚舉值的聲明名稱,使println
和printf
能夠輕鬆打印。 若是你對此字符串表示形式不滿意,能夠經過重寫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
上的抽象overtimePa
y方法,能夠減小樣板。 那麼只有週末的日子必須重寫該方法。 可是,這與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常量的優勢是使人信服的。 枚舉更具可讀性,更安全,更強大。 許多枚舉不須要顯式構造方法或成員,但其餘人則能夠經過將數據與每一個常量關聯並提供行爲受此數據影響的方法而受益。 使用單一方法關聯多個行爲能夠減小枚舉。 在這種相對罕見的狀況下,更喜歡使用常量特定的方法來枚舉本身的值。 若是一些(但不是所有)枚舉常量共享共同行爲,請考慮策略枚舉模式。