對於靜態定型語言來說,泛型為必要之惡,編譯器必須獲得足夠的資訊,才能行使執行時期型態檢查,甚至是自動型態推斷(Type inference)的職責。

當提供給編譯器的資訊涉及物件導向之時,型態變異(Variance)的問題,會使得泛型語法可讀性迅速降低,不易令人理解與應用,這時,掌握PECS原則會是一個釐清各自應用場合的方式。

共變性、逆變性

我先前的專欄〈參數多型用於減輕型態負擔〉曾經談過型態變異,簡單來說,在Haskell這種非物件導向為典範的語言,可以從泛型(型態參數化)得到極大的益處,甚至反過來地,許多場合下編譯器雖然可自行推斷出型態,但Haskell的程式碼慣例中,建議可適當寫出型態,因為這有助於開發者掌握型態資訊。而在Java這類物件導向語言,由於型態系統上有繼承的問題,隨之演變而來的,就是支援泛型的同時,必須考慮型態變異的複雜問題。

如〈參數多型用於減輕型態負擔〉談過的,Java目前在定義支援泛型的型態時,沒有方式可以進行共變性(Covariance)或逆變性(Contravariance)的定義,也就是說,如果Banana繼承了Fruit,現階段的Java開發者並無法定義一個List,使之具有以下的行為:

List<Fruit> fruits = new ArrayList<Banana>();
List<Banana> basket = new ArrayList<Fruit>();

目前最新的JDK版本下,上面兩行都會引發編譯錯誤!話雖如此,為了能在一定程度上支援共變性與逆變性,Java使用了「?」通配字元(Wild card),搭配extends及super關鍵字,令以下行為可以通過編譯:

List<? extends Fruit> fruits = new ArrayList<Banana>();
List<? super Banana> basket = new ArrayList<Fruit>();

泛型的可讀性低,而從這邊可以看出,泛型的資訊,多半設定為給編譯器,而不是給開發者閱讀;為了簡化泛型,避免其語法冗長,更是充斥著各式符號。往往地,若離開語言一段時間後回頭來看泛型語法,第一時間往往是腦袋一片空白,「? extends」、「? super」?這什麼鬼東西?重點是哪些場合用前者?哪些場合又是後者呢?

Producer extends, Consumer super

因此,開發者必須先瞭解為什麼需要去支援共變性?假設有個fruits變數,實際上,可能接受ArrayList等Fruit子型態的清單,現在想要寫個forEach方法取得各個Fruit的資訊時,在參數或變數型態上,就會需要共變性的支援,例如,就方才的List<? extends Fruit> fruits來說,才可以撰寫fruits.forEach(out::println),在這個情況下,fruits相當於水果供應商,只負責提供水果給out.println。>,或者是arraylist

而且,fruits也只能當個供應商,它不能收水果,也就是fruits.add(banana)這類的動作,會引發編譯錯誤,因為Java的泛型語法只用在編譯時期檢查,編譯器就只能就編譯時期看到的型態來檢查,因而造成以上限制。

那麼,我們為什麼需要支援逆變性?如果有個FruitSeller;,有個可以賣香蕉的sell(basket)方法,內部實作必須將Banana實例放入basket清單,那basket的型態是什麼呢?List不可行,如果傳入了一個專門來買香蕉的List<Banana>實例,就會因為Java不支援共變性而編譯失敗,List<? extends Fruit>也不行,因為這型態會限定傳入的必須是Fruit的生產者,因而不能有basket.add(banana)的動作。

此時,就需要使用List<? super Banana>的逆變性支援了。這麼一來,basket就可以接受List<Banana>或List<Fruit>,而且可以呼叫它們的add()來新增香蕉或水果了。那麼,這個basket可以提供水果嗎?像是呼叫basket.get()方法?問題就在於,怎麼知道basket裏裝的是什麼?隨意亂搞(轉型)的話,很容易就會發生ClassCastException了,因此,basket這時就單純只能當個消費者。

也就是說,「? extends」用於只供讀取的「生產者」,而「? super」用於只供寫入的「消費者」,而這些就是Joshua Bloch在《Effective Java》裡面,所提到的Producer extends, Consumer super,簡寫為PECS原則。

Kotlin的out與in

Kotlin是個靜態定型語言,具有物件導向典範,面對泛型,同樣也必須處理型態變異問題。Kotlin可以在定義型態時,決定其是否支援共變性,例如,若想要有個PList支援共變性,可以宣告class PList<out T>,如此fruits: PList<Fruit>就可以接受PList<Banana>的實作物件,而且還多了個限制,T就只能作為傳回值型態,不能作為參數型態,簡單來說,PList不能有接受T作為參數的方法定義了。

從PECS原則來看這限制,就容易理解為什麼有此限制,而且,這使得一個型態何時該支援共變性的考量,變得清晰,因為支援共變性的型態必須擔任生產者的職責,而out這個標註名稱也清楚地說明了,PList型態只產出T。

類似地,如果要Kotlin在定義型態時支援逆變性,可以使用in來標註,例如class CList(in T),如此basket: CList<Banana>就可以接受CList<Fruit>的實作物件,對應的限制就是,T只能作為方法的參數型態,這意謂著CList只會消費T而不供應T,也就是一個型態是否支援逆變性,就在於考量該型態是否要作為消費者。

對於一個既有不支援共變性或逆變性的型態,Kotlin也有著對應「? extends」與「? super」的方案,例如,Array類別同時具有get與set方法,就不能定義其支援共變性或逆變性,然後,在必要的場合,可以使用fruits: Array<out Fruit>來達到「? extends」的效果,使用basket: Array<in Banana>來達到「? super」的效果。

從職責來理解變異性

共變性或逆變性這樣的名詞,只是單純地就型態系統上的繼承關係來區分,名詞本身就有著一層神祕感了,Scala支援在定義型態時,可標註支援共變性或逆變性,然而使用了+與-符號,對於既有不支援共變性或逆變性的型態,也使用了「_ <: Fruit」或「_ >: Banana」,與Java的「? extends」與「? super」相比,語法上的魔幻不遑多讓。

泛型的本意是為了簡化開發者的程式撰寫,而不是使之複雜,面對Java這類語言,從職責來理解變異性及各自的應用場合,會是比較好的作法;Kotlin的out與in(據稱這字眼學自C#),進一步讓泛型語法不單只是提供給編譯器的一堆符號,因此不要將它當成是單純地變異性標註,在使用時,應清楚out與in各代表著生產者與消費者的職責。

根據新的JDK改進提案(JDK Enhancement Proposals)——JEP 300(https://goo.gl/G31iAi),未來Java可能支援共變性與逆變性的宣告,例如<covariant R>或<contravariant T>,不過,covariant或contravariant關鍵字都太冗長了,提案中也談到,可能考慮+、-,或者是out、in,目標可能放在Java 10中推出(https://goo.gl/S7bBvV)。

無論如何,未來Java開發者又多了一個必須理解的語法與概念了(就算是Kotlin,官方文件在提及泛型時,也得先從Java的泛型開始,重頭解釋變異性),無論屆時語法為何,或者是現階段的「? extends」與「? super」方案,從職責來思考與理解變異性都是必要的,謹記得,Producer extends, Consumer super!

作者簡介


Advertisement

更多 iThome相關內容