函數式編程思想:不變性
英文原文:Functional thinking: Immutability
面向對象的編程通過封裝可變動的部分來構造出可讓人讀懂的代碼,函數式編程則是通過最小化可變動的部分來構造出可讓人讀懂的代碼。
——Michael Feathers,Working with Legacy Code一書的作者,經由Twitter
在這部分內容中,我討論的是函數式編程的基石之一:不變性(immutability )。一個不可變對象的狀態在其構造完成之后就不可改變,換句話說,構造函數是唯一一個你可以改變對象的狀態的地方。如果你想要改變一個不可變對象的話,你不會改變它——而是使用修改后的值來創建一個新的對象,并把你的引用指向它。(String就是構建在Java語言內核中的不可變類的一個典型例子。)不變性是函數式編程的關鍵,因為它與盡量減少變化部分的這一目標相一致,這使得對這些部分的推斷更為容易一些。
在Java中實現不可變類
諸如 Java、Ruby、Perl、Groovy和C#一類的現代面向對象語言都擁有一些內置的便利機制,這些機制使得以受控的方式來修改狀態變得很容易。然而,狀態對于計算來說是如此的基礎,因此你永遠也無法預料它會在哪個地方有泄漏。例如,由于大量變化性機制的存在,因此用面向對象的語言連編寫高性能的、正確的多線程代碼就會很困難。因為Java是針對操縱狀態做了優化的,因此你不得不繞過這樣的一些機制來獲得的不變性的好處。不過一旦你了解了要避免的一些陷阱之后,在Java中構建不可變類這一事情就會變得較為容易起來。
定義不可變類
要把一個Java類構造成不可變的,你必須要:
1. 把所有的域聲明成final的。
在Java中把域定義成final的時候,你必須或是在聲明的時候或是在構造函數中初始化它們。如果你的IDE抱怨你沒有在聲明場合初始化它們的話,別緊張;當你在構造函數中寫入適當的代碼后,它就會意識到你知道你在做什么。
2. 把類聲明成final的,這樣它就不會被重寫。
如果類可以被重寫的話,那它的方法的行為也可以被重寫,因此你最安全的選擇就是不允許子類化。這里提一下,這就是Java的String類使用的策略。
3. 不要提供一個無參數的構造函數。
如果你有一個不可變對象的話,你就必須要在構造函數中設置其將會包含的任何狀態。如果你沒有狀態要設置的話,那要一個對象來干什么?無狀態類的靜態方法一樣會起到很好的作用;因此,你永遠也不應該為一個不可變類提供一個無參數的構造函數。如果你正在使用的框架基于某些原因需要這樣的構造函數的話,看看你能不能通過提供一個私有的無參數構造函數(這是經由反射可見的)來滿足這一要求。
需要注意的一點是,無參數構造函數的缺失違反了JavaBeans的標準,該標準堅持要有一個默認的構造函數。不過JavaBeans無論如何都不可能是不可變的,這是由setXXX方法的工作方式決定了的。
4. 至少提供一個構造函數
如果你沒有提供一個無參數構造函數的話,那么這就是你給對象添加一些狀態的最后機會了!
5. 除了構造函數之外,不再提供任何的可變方法。
你不僅要避免典型的受JavaBeans啟發的setXXX方法,還必須注意不要返回可變的對象引用。對象引用被聲明成final的,這是實情,但這并不意味這你不能改變它所指向的內容。因此,你需要確保你是防御性地拷貝了從getXXX方法中返回的任何對象引用。
“傳統的”不可變類
一個滿足以上需求的不可變類如清單1所示:
清單1. Java中的一個不可變的Address類
private final String name;
private final List streets;
private final String city;
private final String state;
private final String zip;
public Address(String name, List streets,String city, String state, String zip) {
this.name = name;
this.streets = streets;
this.city = city;
this.state = state;
this.zip = zip;
}
public String getName() {
return name;
}
public List getStreets() {
return Collections.unmodifiableList(streets);
}
public String getCity() {
return city;
}
public String getState() {
return state;
}
public String getZip() {
return zip;
}
}
需要注意的一點是,清單1中的Collections.unmodifiableList()方法被用來對streets列表進行一個防御性的拷貝。你應該始終使用集合而不是數組來創建不可變列表,盡管防御性的數組拷貝也是可能的,但這會帶來一些不希望見到的副作用。考慮一下清單2中的代碼:
清單2. 使用了數組而不是集合的Customer類
public final String name;
private final Address[] address;
public Customer(String name, Address[] address) {
this.name = name;
this.address = address;
}
public Address[] getAddress() {
return address.clone();
}
}
如清單3所示,在你嘗試著在從getAddress()方法調用中返回的克隆數組上進行任何操作的時候,清單2中的代碼的問題就暴露出來了:
清單3. 測試展示了正確的但卻是非直觀的結果
return asList(streets);
}
publicstatic Address address(List streets,String city, String state, String zip) {
returnnew Address(streets, city, state, zip);
}
@Test publicvoid immutability_of_array_references_issue() {
Address [] addresses =new Address[] {address(streets("201 E Washington Ave", "Ste 600"), "Chicago", "IL", "60601")};
Customer c =new Customer("ACME", addresses);
assertEquals(c.getAddress()[0].city, addresses[0].city);
Address newAddress =new Address(streets("HackerzRulz Ln"), "Hackerville", "LA", "00000");
// 不起作用,但這種失敗沒有顯現出來
c.getAddress()[0] = newAddress;
// 說明上面的做法沒有改變Customer的address
assertNotSame(c.getAddress()[0].city, newAddress.city);
assertSame(c.getAddress()[0].city, addresses[0].city);
assertEquals(c.getAddress()[0].city, addresses[0].city);
}
在返回一個克隆數組的時候,你保護了底層的數組——但你交還的數組看起來就像是一個普通的數組,即你可以修改這一數組的內容。(即使持有這一數組的變量是final的,因為這只作用在數組引用自身上,而非數組的內容上。)在使用Collections.unmodifiableList() (以及Collections中的用在其他類型上的這一系列方法)時,你接收到的對象引用是沒有做改變的方法可用的。
更清晰的不可變類
你經常會聽到這樣的說法,即你還應該要把不可變域聲明成私有的。在聽過有人以一種不同的但卻是清晰的看法來澄清了一些根深蒂固的臆斷之后,我不再同意這樣的觀點了。在Michael Fogus對Clojure的創建者Rich Hickey所做的訪談中(參見參考資料),Hickey談到了Clojure的許多核心部分都缺少數據隱藏式的封裝。Clojure的這一方面一直都在困擾著我,因為我是如此沉迷在基于狀態的思考方式中。但在那之后我意識到了,如果域是不可變的話,那么就不需要擔心它們被暴露出來。許多我們用在封裝中的保障措施實際上就是要防止改變的發生,一旦我們梳理清楚了這兩個概念,一種更清晰的Java實現就浮現出來了。
考慮一下清單4中的Address類版本:
清單4. 使用了公有不可變域的Address類
private final List streets;
public final String city;
public final String state;
public final String zip;
public Address(List streets, String city, String state, String zip) {
this.streets = streets;
this.city = city;
this.state = state;
this.zip = zip;
}
public final List getStreets() {
return Collections.unmodifiableList(streets);
}
}
只有在你想要隱藏底層表示的時候,為不可變域聲明公有的getXXX()方法才會帶來唯一的好處,但是在這種支持重構的IDE很容易能夠發現這種改變的年代,這種好處也不算是什么好處了。通過把域聲明成公有的并且是不可變的,你就能夠直接在代碼中訪問它們,而又無需擔心在不小心的情況下改變了它們。一開始的時候,使用不可變域似乎有些不自然,如果你有聽過憤怒的猴子這個故事(譯者注:參見譯文結尾處的補充內容)的話,但它們的這種不同是有好處的的:你還不習慣于處理Java中的不可變類,這看起來像是一種新的類型,如果清單5中的用例說明:
清單5. Address類的單元測試
publicvoid address_access_to_fields_but_enforces_immutability() {
Address a =new Address(streets("201 E Randolph St", "Ste 25"), "Chicago", "IL", "60601");
assertEquals("Chicago", a.city);
assertEquals("IL", a.state);
assertEquals("60601", a.zip);
assertEquals("201 E Randolph St", a.getStreets().get(0));
assertEquals("Ste 25", a.getStreets().get(1));
// 編譯器不允許
//a.city = "New York";
a.getStreets().clear();
}
對公有不可變域的訪問避免了一系列getXXX()調用所帶來的可見開銷,還要注意的是,編譯器不會允許你給這些原始類型中的任一個賦值,如果你試著調用street集合上的可變方法的話,你就會收到一個UnsupportedOperationException(方式是在測試的頂部捕獲)。這種代碼風格的使用從視覺上給出了一種強烈的指示:該類是一個不可變類。
不利的一面
這種更清晰的語法的一個可能缺點是需要花一些精力來學習這種新的編程技法,不過我覺得這是值得的:這一過程會促進你在創建類的時候思考不變性,因為類的風格是如此明顯不同,且其刪去了不必要的樣板代碼。不過Java中的這種代碼風格也有著一些缺點(說句公道話,Java的直接目的從來就不是為了迎合不變性):
1. 正如Glenn Vanderburg向我指出的那樣,最大的缺點是這一風格違反了Bertrand Meyer(Eiffel這一編程語言的創建者)所說的統一訪問原則(Uniform Access Principle):模塊提供的所有服務應該是通過一種統一的標記法來使用的,無論服務是通過存儲還是通過計算來實現的,都不能違背這種標記法。換句話說,對域的訪問不應該暴露出其是一個域還是一個返回值的方法。Address類的getStreets()方法與其他域沒有保持統一,這一問題在Java中不可能得到真正的解決;但在其他的一些JVM語言中已解決了,方法是它們實現了不變性。
2. 一些重度依賴反射的框架無法使用這種編程技法來工作,因為他們需要一個默認的構造函數。
3. 因為你是創建了新的對象而不是改變舊有的那些,因此有著大量更新的系統可能就會導致由垃圾收集帶來的效率低下。Clojure一類的語言內置了一些設施,通過使用不可變引用來把這種情況變得更有效率一些,這在這些語言中是默認的做法。
Groovy中的不可變性
可用Groovy來構建公有不可變域版本的Address類,其帶來的是一種非常清晰的實現,如清單6所示:
清單6. 使用Groovy編寫的不可變的Address類
def public final List streets;
def public final city;
def public final state;
def public final zip;
def Address(streets, city, state, zip) {
this.streets = streets;
this.city = city;
this.state = state;
this.zip = zip;
}
def getStreets() {
Collections.unmodifiableList(streets);
}
}
一如既往,Groovy需要的樣板代碼要比Java的少——還有其他方面的一些好處。因為Groovy允許你使用熟悉的get/set語法來創建屬性,因此你可以為對象引用創建真正被保護起來的屬性。考慮一下清單7中給出的單元測試:
清單7: 單元測試展示了Groovy中的統一式的訪問
@Test (expected = ReadOnlyPropertyException.class)
void address_primitives_immutability() {
Address a =new Address(["201 E Randolph St", "25th Floor"], "Chicago", "IL", "60601")
assertEquals "Chicago", a.city
a.city ="New York"
}
@Test (expected=UnsupportedOperationException.class)
void address_list_references() {
Address a =new Address(["201 E Randolph St", "25th Floor"], "Chicago", "IL", "60601")
assertEquals "201 E Randolph St", a.streets[0]
assertEquals "25th Floor", a.streets[1]
a.streets[0] ="404 W Randoph St"
}
}
可以注意到,在這兩個用例中,測試會在異常被拋出時終止,這是因為有語句違反了不可變性合約。不過在清單7中,streets屬性看起來就像是原始類型,但實際上它是經由自己的getStreets()方法而受到保護的。
Groovy的@Immutable注解
這一文章系列所持有的一個基本宗旨就是,函數式語言應該為你處理更多低層面的細節。一個很好的例子就是Groovy的1.7版本增加了@Immutable這一注解,這一注解使得清單6中的編碼方式變得不再重要了。清單8給出了一個使用了這一注解的Client類。
清單8. 不可變的Client類
class Client {
String name, city, state, zip
String[] streets
}
因為用到了@Immutable這一注解,該類有著如下的一些特點:
1. 它是最終的(final)。
2. 屬性自動擁有了私下的、合成了get方法的域。
3. 任何更新屬性的企圖都會導致一個ReadOnlyPropertyException異常。
4. Groovy既創建了有序的構造函數,又創建了基于映射的構造函數。
5. 集合類被封裝在適當的包裝器中,數組(及其他可克隆的對象)被克隆。
6. 缺省的equals、hashcode和toString方法會自動生成。
一句注解提供了這么多的作用!它的行為也正如你所期望的那樣,如清單9所示:
清單9. @Immutable注解正確地處理了預期的情況
void client_object_references_protected() {
def c =new Client([streets: ["201 E Randolph St", "Ste 25"]])
c.streets =new ArrayList();
}
@Test (expected = UnsupportedOperationException)
void client_reference_contents_protected() {
def c =new Client ([streets: ["201 E Randolph St", "Ste 25"]])
c.streets[0] ="525 Broadway St"
}
@Test
void equality() {
def d =new Client(
[name: "ACME", city:"Chicago", state:"IL",
zip:"60601",
streets: ["201 E Randolph St", "Ste 25"]])
def c =new Client(
[name: "ACME", city:"Chicago", state:"IL",
zip:"60601",
streets: ["201 E Randolph St", "Ste 25"]])
assertEquals(c, d)
assertEquals(c.hashCode(), d.hashCode())
assertFalse(c.is(d))
}
試圖重置對象引用的操作帶來了一個ReadOnlyPropertyException異常,試圖改變其中的一個被封裝起來的對象引用所指向的內容,這一操作則是產生了一個UnsupportedOperationException異常。注解還創建了適當的equals和hashcode方法,如最后一個測試中所做的展示——對象內容是相同的,但它們沒有指向同一個引用。
當然,Scala和Clojure都支持并促進了不變性,且都有著清晰的不變性語法,接下來的文章會不時地談到它們所帶來的影響。
不變性的好處
在像函數式編程者那樣思考的方法列表中,擁抱不變性處于列表的較高位置上。盡管用Java來構建不可變對象帶來了更多的前期復雜性,但由這一抽象促成的后期簡易性很容易就補償了這種努力。
不可變類驅散了Java中許多典型的令人煩心的事情。轉向函數式編程的好處之一是這樣的一種情況得以實現,即測試的存在是為了檢查代碼中成功發生了的轉變。換句話說,測試的真正目的是驗證改變——改變越多,就需要越多的測試來確保你的做法是正確的。如果你通過嚴格限制改變來隔離變化發生的地方的話,那么你就為錯誤的發生創建了更小的空間,需要測試的地方就更少。因為變化只會發生構造函數中,因此不變類把編寫單元測試變成了一件微不足道的事情。你不需要一個拷貝構造函數,你永遠也不需要大汗淋漓地去實現一個clone()方法的那些慘不忍睹的細節。把不可變對象用作Map或是Set中的鍵值是一種很不錯的選擇;在被當成鍵來使用時,Java的集合字典中的鍵是不能改變值的,因此,不可變類是非常好用的鍵。
不可變對象也是自動線程安全的,不存在同步問題。它們也不可能因為異常的發生而處于一種未知的或是不期望的狀態中。因為所有的初始化都發生在構造階段,這在Java中是一個原子過程,在擁有對象實例之前發生了異常,Joshua Bloch把這稱作失敗的原子性(failure atomicity:):一旦對象已經構造,這種基于不可變性的成功或是失敗就是一錘定音的了(參見參考資料)。
最后要說一點,不可變類最棒的一個地方是,它們融合到復合(compositon)抽象中的能力是如此之強。在下一篇文章中,我會開始研究復合及其在函數式編程思想領域中的重要性。
補充內容
憤怒的猴子
這個故事我是從Dave Thomas那里聽來的,后來它出現在了我的書The Productive Programmer(參見參考資料)中。我不知這是不是真的(盡管對此很是研究了一番),但管他呢,這個故事很完美地說明了一個觀點。
話說早在1960年代,科學家們就進行了一項實驗,他們把五只猴子關在一個屋子里,屋子里有一把梯子,還有一串掛在屋頂上的香蕉。猴子們很快就發現,它們可以爬上梯子,然后就可以吃到香蕉。接下來,每次只要有猴子靠近梯子的話,科學家們就把整個房間置于冰冷的水中。不久之后,就沒有猴子會走進梯子了。接著,科學家就用一只新的猴子來替換掉其中一只浸過水的猴子,這只新猴子還從未被用到這一實驗中。當該猴子直奔梯子而去時,所有其他的猴子把它揍了一頓,它不明白它們為什么要打它,但它很快就學會了一件事情:不要靠近梯子。科學家逐個地用新猴子替換掉了最初的猴子,直到最后得到這樣一群猴子,其中任何一只都不曾被冷水浸泡過,但都會攻擊任何靠近梯子的其他猴子。
要說明的觀點?那就是,在軟件項目中,許多做法存在的理由是,因為“我們一直就是這樣做的”。
學習資料
1. The Productive Programmer(Neal Ford,O'Reilly Media,2008):Neal Ford的最新著作進一步闡述了這一系列中的許多主題。
2. Clojure:Clojure是一種現代的、運行在JVM上的函數式Lisp語言。
3. Rich Hickey Q&A:Michael Fogus對Clojure的創建者Rich Hickey所做的訪談。
4. Stuart Halloway on Clojure:從這developerWorks播客視頻中了解更多關于Clojure的內容。
5. Scala:Scala是一種現代的、位于JVM之上的函數式語言。
6. The busy Java developer's guide to Scala:在這一developerWorks系列中,Ted Neward深入分析了Scala。
7. Effective Java, 2d ed. (Joshua Bloch,Addison Wesley,2008):閱讀本書了解更多關于失敗的原子性的內容。
8. 瀏覽technology bookstore來查找一些關于這些和另外一些技術主題的書籍。
9. developerWorks Java technology zone:可以找到幾百篇關于Java編程的各個方面的文章。
獲得產品和技術
1. 下載IBM產品評估版本或是瀏覽 IBM SOA Sandbox中的在線使用,動手操作DB2®, Lotus®, Rational®, Tivoli®, and WebSphere®方面的應用開發工具和中間件產品。
討論
1. 加入developerWorks社區,瀏覽開發者驅動的博客、論壇、討論組和wiki,與其他developerWorks用戶建立聯系。