1. 程式人生 > >Effective Java 3rd 條目17 最小化可變性

Effective Java 3rd 條目17 最小化可變性

一個不可變類簡單地講是例項不可以改變的一個類。在每個例項裡面包含的所有資訊在物件的生命週期裡面是確定的,所以從來不會看到改變。Java平臺庫包含了許多不可變類,包括String、原始裝箱型別和BigInteger與BigDecimal。為此有很多很好的原因:不可變類比可變類更容易設計、實現和使用。它們更不容易出錯而且更加安全。

為了使得一個類不可變,遵循五條規則:

  1. 不要提供修改物件狀態的方法(被稱為設定方法(mutator))。

  2. 確保這個類不會被擴充套件。這阻止了無意的和惡意的子類,通過好像物件狀態已經改變這種方式破解這個類的不可變行為。阻止子類化通常是使得類為final而完成的,但是稍後我們將會討論的另外一種替代方法。

  3. 使得所有域為final。這清楚地以系統強制的方式表達了你的意圖。如果一個新建立物件的引用沒有同步地從一個執行緒被傳遞到另外一個執行緒,那麼確保正確的行為也是有必要的,就像在記憶體模型(memory model)中講清楚的[JLS, 17.5; Goetz06, 16]。

  4. 使得所有域是私有的。這阻止了客戶端獲取由域引用的可變物件,而且阻止了直接修改這些物件。不可變類有包含原始值或者對不可變物件引用的公開final域,這在技術上是允許的,但是這是不推薦的,因為它阻止了在以後釋出中改變內部表示(條目15和16)。

  5. 確保對任何可變元件的排他訪問。如果你的類有引用可變物件的任何域,確保這個類的客戶端不可能獲取這些物件的引用。不要初始化這樣的類到一個客戶端提供的物件引用,或者從一個訪問子返回引用。在構造子、訪問子和readObject方法(條目88)上使用防守性拷貝(defensive copy)

    (條目50)。

在前面條目中的許多例子中的類是不可變的。一個這樣的類是條目11中的PhoneNumber,他有為每個屬性的訪問子,但是沒有相應的設定方法。以下是一個稍微複雜的例子:

// 不可變的複數類
public final class Complex {
    private final double re; 
    private final double im;

    public Complex(double re, double im) { 
        this.re = re; 
        this.im = im; 
    }

    public
double realPart() { return re; } public double imaginaryPart() { return im; } public Complex plus(Complex c) { return new Complex(re + c.re, im + c.im); } public Complex minus(Complex c) { return new Complex(re - c.re, im - c.im); } public Complex times(Complex c) { return new Complex(re * c.re - im * c.im, re * c.im + im * c.re); } public Complex dividedBy(Complex c) { double tmp = c.re * c.re + c.im * c.im; return new Complex((re * c.re + im * c.im) / tmp, (im * c.re - re * c.im) / tmp); } @Override public boolean equals(Object o) { if (o == this) return true; if (!(o instanceof Complex)) return false; Complex c = (Complex) o; // 參考47頁找出為什麼使用compare而不是== return Double.compare(c.re, re) == 0 && Double.compare(c.im, im) == 0; } @Override public int hashCode() { return 31 * Double.hashCode(re) + Double.hashCode(im); } @Override public String toString() { return "(" + re + " + " + im + "i)"; } }

這個類表示一個複數(一個有實部和虛部的數)。在標準的Object方法之外,它提供了對實部和虛部的訪問子,而且提供了基本的算術操作:加法、減法、乘法和除法。注意到算術操作是怎麼建立和返回一個新的Complex例項,而不是修改這個例項。這個模式被認為是一個函式式(functional)方式,因為方法返回了應用一個函式到它們運算元的結果而沒有修改它。與它形成對比的是過程式(procedural)或者是命令式(imperative)的方式,這個方式裡面方法應用一個過程到它們的運算元,造成了它的狀態的改變。注意到這個方法名字是介詞(比如加上(plus)),而不是動詞(比如加(add))。這強調了這個事實:這個方法不會改變物件的值。BigInteger和BigDecimal類沒有遵從這個命名慣例,而且這導致了許多使用錯誤。

如果你不熟悉函式式方式,這種方式可能看起來不自然,但是它使不可變成為可能,這有許多優點。不可變物件是簡單的。不可變物件可以一致地在一個狀態,也就是建立時的狀態。如果你想確保所有構造子建立類不變性,那麼保證了這些不變數將會永遠保持正確,就你而言或者對於使用這個類的程式設計師而言,不需要進一步的努力。另外一方面,可變物件可以有隨意複雜的狀態空間。如果文件沒有提供由設定方法執行的狀態轉換的明確描述,那麼可靠地使用一個可變物件是困難的甚至是不可能的。

不可變物件有與生俱來的執行緒安全性;它們不需要同步。多個執行緒並行地訪問它們也不能損壞它們。這無疑是獲得執行緒安全的最容易的方式。因為沒有執行緒能觀察到在一個不可變物件上的另外一個執行緒的影響,所有可以自由地分享不可變物件。所以不可變類應該鼓勵客戶端(只要有可能)複用已經存在的例項。這麼做一個容易的方式是為通常使用的值提供一個公開靜態final的常量。比如,Complex類應該提供這些常量:

public static final Complex ZERO = new Complex(0, 0); 
public static final Complex ONE  = new Complex(1, 0); 
public static final Complex I    = new Complex(0, 1);

這個方法可以進一步。一個不可變類可以提供靜態工廠(條目1),它快取經常被請求的例項,現存有例項的時候避免建立新例項。所有的原始裝箱類和BigInteger也是這麼做的。使用這樣的靜態工廠造成了客戶端分享例項而不是建立新例項,這減少了記憶體佔用和垃圾回收的代價。當設計一個新類時,用靜態工廠替代公開構造子,這個優化給你這樣的靈活性:以後新增緩衝而不會修改客戶端。

不可變物件可以自由地分享這個事實的結果是,你永遠不需要對它們的防禦性拷貝(條目50)。事實上,你永遠根本不需要進行任何拷貝,因為拷貝永遠將會和原件相等,所以,你不需要也不應該在不可變類上提供一個克隆方法或者拷貝構造子(條目13)。在Java平臺的早期,這沒有被很好地理解,所以String方法確實有一個拷貝構造子,但是它應該很少被使用,如果有過的話(條目6)。

不僅你可以分享不可變物件,而且它們可以分享它們的內部構件。比如,BigInteger內部使用一個符號-量值表示法。符號用一個int表示,而量值用一個int隊列表示。negate方法產生一個的相同量值和相反符號的新BigInteger。即使它是可變的,它也沒必要拷貝佇列;新建立的BigInteger和原件一樣指向相同的內部佇列。

不管可變或者不可變,不可變物件為其他物件準備了很棒的構建模組。如果你知道複雜物件的元件物件不會私下改變,那麼維持複雜物件的不可變性更加容易。這個原則的特例是,不可變物件是很好的對映的鍵和集元素:一旦它們在對映或者集中,你不必要擔心它們的值會改變,值改變破壞破壞對映或者集的不可變性。

不可變的物件無償地提供了失敗的原子性(條目76)。它們的狀態從來不會改變,所以暫時的不一致也是不可能的。

不可變類的主要缺點在於,對於每個不同的值,它們需要單獨的物件。建立這些物件可能代價高,特別它們是很大的時候。比如,假設你有一個百萬位元的BigInteger,而且你想改變它的低位位元:

BigInteger moby = ...; 
moby = moby.flipBit(0);

flipBit方法建立了一個新的BigInteger,而且長達百萬位元,僅僅與原件只有一個位元的不同。這個運算需要正比於BigInteger大小的時間和空間。與這個相反的是java.util.BitSet。就像BigInteger,BitSet代表一個任意長度的位元系列,但是不像BigInteger,BitSet是可變的。BitSet提供了一個方法,允許你以常數時間改變一個百萬位元例項的單個位元的狀態:

BitSet moby = ...; 
moby.flip(0);

如果你執行多個步驟的運算,每一步產生一個新的物件,除了最後的結果最終丟棄所有的物件,那麼效能問題被放大。有兩種方法處理這個問題。首先,猜測哪個多個步驟的運算通常將被請求,以基元的方式提供它們。如果一個多步驟運算以基元方式提供,不可變類沒必要每步都建立一個獨立的物件。本質上,不可變類可以任意巧妙。比如,BigInteger有一個包私有的可變“伴隨類(companion class)”,它使用於加速多步驟運算,比如模冪運算。相對於使用BigInteger,使用可變伴隨類更加困難,就像前面列出的所有原因。幸運的是,你不必要使用它:BigInteger的實現者為你已經做了最困難的工作。

如果你能精確預測客戶端想要在你的可變類上執行哪些複雜操作,那麼包私有可變伴隨類方式工作良好。如果不是,那麼你最好的選擇是提供一個公開的可變伴隨類。這個方式在Java平臺庫中的主要例子是String類,它的可變伴隨是StringBuilder(和它的廢棄前身,StringBuffer)。

既然你知道了如果構建一個不可變類,而且你懂得不可變的優缺點,那麼讓我們討論一些設計的替代方案。回憶到,為了保證不變性,一個類一定不能允許自己被子類化。這可以通過讓這個類為final完成,但是有另外一個更加靈活的替代方案。不是讓一個不可變類是final,你可以讓它的所有構造子是私有的或者包私有的,而且新增公開靜態工廠代替公開構造子(條目1)。為了讓這個更加具體,如果你採用這個方法,下面是Complex的看上去的樣子:

// 有靜態工廠而不是構造子的不可變類。
public class Complex {
    private final double re; 
    private final double im;

    private Complex(double re, double im) { 
        this.re = re; 
        this.im = im; 
    }

    public static Complex valueOf(double re, double im) { 
        return new Complex(re, im); 
    }
    ... // 其餘省略
} 

這個方式通常是最好的替代方案。它是最靈活的,因為它允許多個包私有實現類的使用。對於屬於包外的它的客戶端,不可變類是有效的final,因為一個類來自於另外一個包而且缺少公開或者受保護構造子,擴充套件它是不可能的。除了允許多個實現類的靈活性,而且通過增強靜態工廠的物件緩衝能力,這個方法使得在後續的釋出中對這個類進行效能調優是可能的。

當編寫BigInteger和BigDecimal時,不可變類不得不是有效的final,這在當初沒有被廣泛地理解,所以可以覆寫它們的所有方法。不幸的是,為了保持向後相容性這個事實,這不可能被改正。如果你編寫一個類,它的安全性取決於BigInteger或者BigDecimal引數的不可變性,這引數來自不可信的客戶端,那麼你必須檢檢視一下這個引數是否是一個“真的”BigInteger或者BigDecimal,而不是不信任子類的一個例項。如果它是後者,基於假設它可能是可變的,你必須進行防禦性拷貝(條目50):

public static BigInteger safeInstance(BigInteger val) { 
    return val.getClass() == BigInteger.class ?
        val : new BigInteger(val.toByteArray()); 
}

在這個條目的開始的時候,不可變類的規則列表如是說,沒有方法可以修改這個物件,而且它的所有域可以在物件狀態上產生一個外部可見性(externally visible)改變。事實上,這些規則比必需的更加強化了些,而且為了改進效能可以放鬆。事實上,沒有方法可以在物件狀態上產生一個外部可見性變化。然而,一些不變類有一個或者多個非final域,這個域中第一次需要它們時候快取了它們代價高的計算結果。如果再次請求同一個值,那麼返回快取值,這避免了重複計算的代價。這技巧起作用,因為這個物件是不可變的,這保證瞭如果計算是重複的,計算產生相同的結果。

比如,PhoneNumber的hashCode方法(條目11),第一次被呼叫時計算雜湊碼,快取它以備它再次被呼叫。這個技巧,一個懶載入(lazy initialization)(條目83)的例子,也被使用在String上。

應該新增關於系列化的一個告誡。如果你選擇讓你的不可變類實現Serializable,而且它包含了引用可變物件的一個或者多個域,那麼你必須提供一個顯式的readObject或者readResolve方法,或者使用ObjectOutputStream.writeUnshared和ObjectInputStream.readUnshared方法,即使預設的系列化形式是可以接受的。否則,一個攻擊者可以建立你的類的一個可變例項。這個主題在條目88中詳細討論。

概括地講,忍住為每個獲取方法編寫一個設定方法的衝動。類應該是不可變的,除非有很好的理由讓它們是可變的。不可變類提供了許多優點,而且它們的唯一缺點是,在特定情形下有效能問題的可能。你應該總是讓小值物件(比如PhoneNumber和Complex)是不可變的。(在Java平臺庫中有許多類,比如java.util.Date和java.awt.Point,應該是不可變的,但是它們並不是。)你應該認真考慮使得更大的值物件(比如String和BigInteger)也是不可變的。僅僅當你確信取得令人滿意的效能是必需的(條目67)時,你應該為你的不可變類提供一個公開可變伴隨類。

有些類,為它實施不可變性是不切實際的。如果一個類不可能被建立為不可變的,儘量限制他的可變性。減少一個類可以存在的狀態數量,使得思索這個類更加容易,而且減少了錯誤的可能性。所以,讓每個域是final的,除非有迫不得已的理由使得它是非final的。結合這個條目的建議和條目15的建議,你的自然傾向應該是宣告每個域為私有final的,除非有很好的理由不這麼做

構造子應該使用它們已經確定的約束關係建立充分初始化的物件。除了構造子或者靜態方法,不要單獨地提供一個公開的初始化方法,除非有迫不得已的理由這麼做。相似地,不要提供一個“重新初始化”的方法,使得一個類可以重新使用,就像它是使用不同的初始化狀態構建的。這樣的方法通常以增加複雜性為代價提供了很少的(如果有)效能優勢。

CountDownLatch類例證了這些原則。它是可變的,但是它的狀態空間有意地維持很小。你建立了一個物件,使用它一次,然後結束了:倒計數鎖存的基數達到了0,你不應該重複利用它。

應該新增的最後的注意事項,是關於這個條目裡面的Complex類。這個例子用意僅僅在於說明不可變性。這不是一個工業強度的複數實現。為複數的乘法和除法,它使用了標準的公式,而沒有正確地四捨五入,而且對於複數的NaN和無窮[Kahan91, Smith62, Thomas94]提供了糟糕的語義。