不變對象具有許多能更方便地使用它們的特性,包括不嚴格的同步需求和不必考慮資料訛誤就能自由地共用和快取對象引用。儘管不變性可能未必對於所有類都有意義,但大多數程式中至少有一些類將受益於不可變。在本月的
Java 理論與實踐中,Brian Goetz 說明了不變性的一些長處和構造不變類的一些準則。請在附帶的論壇中與作者和其他讀者分享您關於本文的心得。(也可以單擊文章頂部或底部的“討論”來訪問論壇。)
不變對象是指在執行個體化後其外部可見狀態無法更改的對象。Java 類庫中的 String
、Integer
和 BigDecimal
類就是不變對象的樣本 — 它們表示在對象的生命期內無法更改的單個值。
不變性的長處
如果正確使用不變類,它們會極大地簡化編程。因為它們只能處於一種狀態,所以只要正確構造了它們,就決不會陷入不一致的狀態。您不必複製或複製不變對象,就能自由地共用和快取對它們的引用;您可以快取它們的欄位或其方法的結果,而不用擔心值會不會變成失效的或與對象的其它狀態不一致。不變類通常產生最好的映射鍵。而且,它們本來就是安全執行緒的,所以不必線上程間同步對它們的訪問。
自由快取
因為不變對象的值沒有更改的危險,所以可以自由地快取對它們的引用,而且可以肯定以後的引用仍將引用同一個值。同樣地,因為它們的特性無法更改,所以您可以快取它們的欄位和其方法的結果。
如果對象是可變的,就必須在儲存對其的引用時引起注意。請考慮清單 1 中的代碼,其中排列了兩個由發送器執行的任務。目的是:現在啟動第一個任務,而在某一天啟動第二個任務。
清單 1. 可變的 Date 對象的潛在問題
Date d = new Date(); Scheduler.scheduleTask(task1, d); d.setTime(d.getTime() + ONE_DAY); scheduler.scheduleTask(task2, d);
|
因為 Date
是可變的,所以 scheduleTask
方法必須小心地用防範措施將日期參數複製(可能通過 clone()
)到它的內部資料結構中。不然,task1
和 task2
可能都在明天執行,這可不是所期望的。更糟的是,任務發送器所用的內部資料結構會變成訛誤。在編寫象 scheduleTask()
這樣的方法時,極其容易忘記用防範措施複製日期參數。如果忘記這樣做,您就製造了一個難以捕捉的錯誤,這個錯誤不會馬上顯現出來,而且當它暴露時人們要花較長的時間才會捕捉到。不變的 Date
類不可能發生這類錯誤。
固有的安全執行緒
大多數的安全執行緒問題發生在當多個線程正在試圖並發地修改一個對象的狀態(寫-寫衝突)時,或當一個線程正試圖訪問一個對象的狀態,而另一個線程正在修改它(讀-寫衝突)時。要防止這樣的衝突,必須同步對共用對象的訪問,以便在對象處於不一致狀態時其它線程不能訪問它們。正確地做到這一點會很難,需要大量文檔來確保正確地擴充程式,還可能對效能產生不利後果。只要正確構造了不變對象(這意味著不讓對象引用從建構函式中轉義),就使它們免除了同步訪問的要求,因為無法更改它們的狀態,從而就不可能存在寫-寫衝突或讀-寫衝突。
不用同步就能自由地線上程間共用對不變對象的引用,可以極大地簡化編寫並發程式的過程,並減少程式可能存在的潛在並發錯誤的數量。
在惡意啟動並執行代碼面前是安全的
把對象當作參數的方法不應變更那些對象的狀態,除非文檔明確說明可以這樣做,或者實際上這些方法具有該對象的所有權。當我們將一個對象傳遞給普通方法時,通常不希望對象返回時已被更改。但是,使用可變對象時,完全會是這樣的。如果將 java.awt.Point
傳遞給諸如 Component.setLocation()
的方法,根本不會阻止 setLocation
修改我們傳入的 Point
的位置,也不會阻止 setLocation 儲存對該點的引用並稍後在另一個方法中更改它。(當然,Component
不這樣做,因為它不魯莽,但是並不是所有類都那麼客氣。)現在,Point
的狀態已在我們不知道的情況下更改了,其結果具有潛在危險 — 當點實際上在另一個位置時,我們仍認為它在原來的位置。然而,如果 Point
是不變的,那麼這種惡意的代碼就不能以如此令人混亂而危險的方法修改我們的程式狀態了。
良好的鍵
不變對象產生最好的 HashMap
或 HashSet
鍵。有些可變對象根據其狀態會更改它們的 hashCode()
值(如清單 2 中的 StringHolder
樣本類)。如果使用這種可變對象作為 HashSet
鍵,然後對象更改了其狀態,那麼就會對 HashSet
實現引起混亂 — 如果枚舉集合,該對象仍將出現,但如果用 contains()
查詢集合,它就可能不出現。無需多說,這會引起某些混亂的行為。說明這一情況的清單 2 中的代碼將列印“false”、“1”和“moo”。
清單 2. 可變 StringHolder 類,不適合用作鍵
public class StringHolder { private String string; public StringHolder(String s) { this.string = s; } public String getString() { return string; } public void setString(String string) { this.string = string; } public boolean equals(Object o) { if (this == o) return true; else if (o == null || !(o instanceof StringHolder)) return false; else { final StringHolder other = (StringHolder) o; if (string == null) return (other.string == null); else return string.equals(other.string); } } public int hashCode() { return (string != null ? string.hashCode() : 0); } public String toString() { return string; } ... StringHolder sh = new StringHolder("blert"); HashSet h = new HashSet(); h.add(sh); sh.setString("moo"); System.out.println(h.contains(sh)); System.out.println(h.size()); System.out.println(h.iterator().next()); }
|
何時使用不變類
不變類最適合表示抽象資料類型(如數字、枚舉類型或顏色)的值。Java 類庫中的基本數字類(如 Integer
、Long
和 Float
)都是不變的,其它標準數字類型(如 BigInteger
和 BigDecimal
)也是不變的。表示複數或精度任意的有理數的類將比較適合於不變性。甚至包含許多離散值的抽象類別型(如向量或矩陣)也很適合實現為不變類,這取決於您的應用程式。
Flyweight 模式 不變性啟用了 Flyweight 模式,該模式利用共用使得用對象有效地表示大量細顆粒度的對象變得容易。例如,您可能希望用一個對象來表示文書處理文檔中的每個字元或映像中的每個像素,但這一策略的幼稚實現將會對記憶體使用量和記憶體管理開銷產生高得驚人的花費。Flyweight 模式採用Factory 方法來分配對不變的細顆粒度對象的引用,並通過僅使一個對象執行個體與字母“a”對應來利用共用縮減對象數。有關 Flyweight 模式的更多資訊,請參閱經典書籍 Design Patterns(Gamma 等著;請參閱參考資料)。 |
Java 類庫中不變性的另一個不錯的樣本是 java.awt.Color
。在某些顏色標記法(如 RGB、HSB 或 CMYK)中,顏色通常表示為一組有序的數字值,但把一種顏色當作色彩空間中的一個特異值,而不是一組有序的獨立可定址的值更有意義,因此將 Color
作為不變類實現是有道理的。
如果要表示的對象是多個基本值的容器(如:點、向量、矩陣或 RGB 顏色),是用可變對象還是用不變對象表示?答案是……要看情況而定。要如何使用它們?它們主要用來表示多維值(如像素的顏色),還是僅僅用作其它對象的一組相關特性集合(如視窗的高度和寬度)的容器?這些特性多久更改一次?如果更改它們,那麼各個組件值在應用程式中是否有其自己的含義呢?
事件是另一個適合用不變類實現的好樣本。事件的生命期較短,而且常常會在建立它們的線程以外的線程中消耗,所以使它們成為不變的是利大於弊。大多數 AWT 事件類別都沒有作為嚴格的不變類來實現,而是可以有小小的修改。同樣地,在使用一定形式的訊息傳遞以在組件間通訊的系統中,使訊息對象成為不變的或許是明智的。
編寫不變類的準則
編寫不變類很容易。如果以下幾點都為真,那麼類就是不變的:
- 它的所有欄位都是 final
- 該類聲明為 final
- 不允許
this
引用在構造期間轉義
- 任何包含對可變對象(如數組、集合或類似
Date
的可變類)引用的欄位:
- 是私人的
- 從不被返回,也不以其它方式公開給調用程式
- 是對它們所引用對象的唯一引用
- 構造後不會更改被引用對象的狀態
最後一組要求似乎挺複雜的,但其基本上意味著如果要儲存對數組或其它可變對象的引用,就必須確保您的類對該可變對象擁有獨佔訪問權(因為不然的話,其它類能夠更改其狀態),而且在構造後您不修改其狀態。為允許不變Object Storage Service對數組的引用,這種複雜性是必要的,因為 Java 語言沒有辦法強制不對 final 數組的元素進行修改。註:如果從傳遞給建構函式的參數中初始化數組引用或其它可變欄位,您必須用防範措施將調用程式提供的參數或您無法確保具有獨佔訪問權的其它資訊複製到數組。否則,調用程式會在調用建構函式之後,修改數組的狀態。清單 3 顯示了編寫一個儲存調用程式提供的數組的不變對象的建構函式的正確方法(和錯誤方法)。
清單 3. 對不變對象編碼的正確和錯誤方法
class ImmutableArrayHolder { private final int[] theArray; // Right way to write a constructor -- copy the array public ImmutableArrayHolder(int[] anArray) { this.theArray = (int[]) anArray.clone(); } // Wrong way to write a constructor -- copy the reference // The caller could change the array after the call to the constructor public ImmutableArrayHolder(int[] anArray) { this.theArray = anArray; } // Right way to write an accessor -- don't expose the array reference public int getArrayLength() { return theArray.length } public int getArray(int n) { return theArray[n]; } // Right way to write an accessor -- use clone() public int[] getArray() { return (int[]) theArray.clone(); } // Wrong way to write an accessor -- expose the array reference // A caller could get the array reference and then change the contents public int[] getArray() { return theArray }}
|
通過一些其它工作,可以編寫使用一些非 final 欄位的不變類(例如,String
的標準實現使用 hashCode
值的惰性計算),這樣可能比嚴格的 final 類執行得更好。如果類表示抽象類別型(如數字類型或顏色)的值,那麼您還會想實現 hashCode()
和 equals()
方法,這樣對象將作為 HashMap
或 HashSet
中的一個鍵工作良好。要保持安全執行緒,不允許 this
引用從建構函式中轉義是很重要的。
偶爾更改的資料
有些資料項目在程式生命期中一直保持常量,而有些會頻繁更改。常量資料顯然符合不變性,而狀態複雜且頻繁更改的對象通常不適合用不變類來實現。那麼有時會更改,但更改又不太頻繁的資料呢?有什麼方法能讓有時更改的資料獲得不變性的便利和安全執行緒的長處呢?
util.concurrent
包中的 CopyOnWriteArrayList
類是如何既利用不變性的能力,又仍允許偶爾修改的一個良好樣本。它最適合於支援事件監聽程式的類(如使用者介面組件)使用。雖然事件監聽程式的列表可以更改,但通常它更改的頻繁性要比事件的產生少得多。
除了在修改列表時,CopyOnWriteArrayList
並不變更基本數組,而是建立新數組且廢棄舊數組之外,它的行為與 ArrayList
類非常相似。這意味著當調用程式獲得迭代器(迭代器在內部儲存對基本數組的引用)時,迭代器引用的數組實際上是不變的,從而可以無需同步或冒並發修改的風險進行遍曆。這消除了在遍曆前複製列表或在遍曆期間對列表進行同步的需要,這兩個操作都很麻煩、易於出錯,而且完全使效能惡化。如果遍曆比插入或除去更加頻繁(這在某些情況下是常有的事),CopyOnWriteArrayList
會提供更佳的效能和更方便的訪問。
結束語
使用不變對象比使用可變對象要容易得多。它們只能處於一種狀態,所以始終是一致的,它們本來就是安全執行緒的,可以被自由地共用。使用不變對象可以徹底消除許多容易發生但難以檢測的編程錯誤,如無法線上程間同步訪問或在儲存對數組或對象的引用前無法複製該數組或對象。在編寫類時,問問自己這個類是否可以作為不變類有效地實現,總是值得的。您可能會對回答常常是肯定的而感到吃驚。