隨著函數式程式設計的普及,越來越多函數式的元素出現在命令式的語言,除了函數式風格的API,在語言的新特性提出時,不可變(Immutable)型態的增列,也經常會成為特色之一。然而,在本質上就是命令式的語言中,它們真的不可變嗎?開發者需要的不可變,又是哪種不可變?

Java 8的不可變物件

有很多好的理由,可用來支持不可變物件的存在,開發者最常想到的就是執行緒安全,當然,如果物件真的不可變,就不會有資源共用競爭的問題,也不用擔心傳遞的物件狀態被修改。

在效率上,既然是不可變,也就無需複製物件,必要時可以放心地進行快取,像是Java的Integer等包裏物件。此外,不可變物件本身也是鍵(Key),或者集合中元素的優秀候選,這也是Java開發者經常使用String,作為Map的鍵物件的原因之一。

只是有時候,開發者必須搞清楚物件是不是真的不可變。舉例來說,Java 8或之前版本中,若要建立不可變的Collection、List、Set或Map,方法之一,是使用Collections的unmodifiableXXX方法,那些方法會將既有的Collection、Map等實例包裹,若要在傳回的物件上進行查詢,會委託其內部包裹的實例,若是操作會修改狀態的add、remove 等方法,則丟出UnsupportedOperationException。

就像是unmodifiableCollection所傳回的UnmodifiableCollection,它用來建構的原始碼只是:

static class UnmodifiableCollection implements Collection, Serializable {
final Collection<? extends E> c;
UnmodifiableCollection(Collection<? extends E> c) {
if (c==null)
throw new NullPointerException();
this.c = c;
}

透過unmodifiableXXX方法傳回的物件,真的是不可變嗎?不是!只是如其名稱指出的無法修改(Unmodifiable),也就是,傳回的物件不支援修改操作罷了。

從上列原始碼中可以看出,如果傳遞物件的一方,還是持有原本的Collection、Map等實例,並對其進行修改操作,unmodifiableXXX方法傳回的物件狀態,還是會跟著變動。

Java 9的不可變工廠方法

在即將推出的Java 9中,一個新特性是List、Set、Map上,多了些of方法,根據API文件,裡面說是可傳回不可變物件,而就語法上看來,這比使用unmodifiableXXX方法,要來得簡潔許多,特別是在Map的建立上。以下,是在Java 9附帶的jshell交互式環境中,進行測試的範例:

jshell> List<String> lt = List.of("one", "two", "three")
lt ==> [one, two, three]
jshell> Map<String, Integer> lt = Map.of("one", 1, "two", 2, "three", 3)
lt ==> {two=2, three=3, one=1}

就List.of來說,所傳回來的物件,是java.util套件能見範圍中AbstractImmutableList的子類實例,而AbstractImmutableList繼承了AbstractList類別,在add等會修改狀態的方法被呼叫時,會拋出UnsupportedOperationException;另一方面,這些工廠方法不允許null值,傳給of的元素若為null,會拋出NullPointerException。

有趣的是,傳給of的元素個數不同,傳回的物件型態就不同。當元素個數不大於3時,傳回的會是List0、List1或List2,對於後兩者,會分別在內部使用欄位(Field)參考至指定的元素,指定of更多元素時,會傳回ListN,在內部會使用陣列,逐一淺層複製指定的元素參考。

也就是,List.of實作了防禦性複製(Defensive copying)(https://goo.gl/SbNCde),傳回的物件不支援修改操作,就算持有原本List的一方所新增或移除了List的元素,也不會對List.of傳回的物件造成影響,而Set.of、Map.of也都是以類似的概念實作

guava-libraries的不可變工廠方法

如果開發者使用過guava-libraries,對於Java 9的不可變工廠方法應似曾相識,guava-libraries提供了ImmutableCollection、ImmutableList、ImmutableSet 等實作類別,可透過of方法來建構實例:

List<String> nameList = ImmutableList.of("Duke", "Java", "Oracle");
Set<String> nameSet = ImmutableSet.of("Duke", "Java", "Oracle");
Map<String, Integer> userDB = ImmutableMap.of("Duke", 123, "Java", 456);

在內部原始碼的實作,Java 9與guava-libraries概念類似,實作了防禦性複製,元素上也不允許null值;不過,guava-libraries還提供copyOf方法。

舉例而言,若將ImmutableList傳入ImmutableList.copyOf,方法內部會呼叫其asList方法,在這種情況下,asList只是直接return this罷了,也就是copyOf不會逐一複製每個元素,而是直接共用了資料。

在Java 8或之前版本,我們若想使用不可變群集等物件,guava-libraries會是個可考量的對象,因為有著更豐富的功能,就算是在Java 9中也仍有其適用的場景。那麼,ImmutableList等名稱上有Immutable,是否就真的是不可變物件呢?答案還是No!如果of的來源元素是可變的,例如,有一組Date實例,在建立了ImmutableList實例之後,若開發者透過Date的setTime改變了Date的狀態,ImmutableList狀態也還是跟著改變了。

上述Java 9的不可變實作,也是如此。就Java API文件的說法(https://goo.gl/L1pzqB),它們只是結構上不可變(structurally immutable),開發者不能對傳回的物件,有任何相等性(identity)的假設,像是依賴在equals或hashCode上的操作,就應當避免。

命令式語言中的不可變

在命令式語言中,因為一些理由,通常會預先定義某些不可變型態,像是Java中的String、Integer、BigDecimal等,開發者可以直接取用,如果因為某些需求,想要定義不可變物件,除了上面討論的,還會有哪些考量?在〈Immutable objects〉(https://goo.gl/Rx11hj)中,有些準則可以參考。

例如,技術上來說,類別不能被重新定義,在Java中就是設為final而不能被繼承,因此,嚴格來說,BigDecimal並不符合真正的不可變(雖然繼承它是不被建議的),而從這點來說,可變動型態,基本上,也不會是不可變型態的子型態。

想想看,一個不可變集合,應當也只允許加入不可變的元素,若可變是不可變的子型態,那麼,集合對這個可變元素,是該加入?還是不加入呢?

不可否認地,不可變的特性越來越受到重視,在《Effective Java》中,Joshua Bloch就曾建議過:「除非有很好的理由,不然的話,類別應該不可變,如果沒辦法這麼做,儘可能限制它的可變能力。」

命令式語言畢竟與純函數式語言不同,不可變一開始只是命令式語言中很小的考量,然而,當開發者考慮不可變必須是個特性時,最好想清楚,所要的不可變,到底是哪種不可變?

考量的是執行緒安全?或者單純只是讀取多於寫入?這會是個容器,還是個多維值(像是座標、顏色、像素)?它被使用的頻率……?

如果使用的是程式庫,最好看清楚文件,或從原始碼瞭解細節(某些程度上這是抽象滲漏,不過,命令式語言聲稱的不可變實作,往往值得懷疑),因此,可別看到了ImmutableXXX,就一頭栽入。

作者簡介


Advertisement

更多 iThome相關內容