在Java 17中,彌封類別(sealed)成為正式特性,在定義類別或介面時,可使用permits指定可直接繼承或實作的子型態,最後使用sealed彌封整個類別階層。

例如,定義List介面只能有Nil與Cons兩個子類別,它們必須在相同套件或模組之中,也只有Nil與Cons類別能直接實現List:

sealed interface List<T> permits Nil, Cons<T> {
T head();
List<T> tail();
}

彌封了整個類別階層,初看會有些疑慮:阻止其他擴充可能性之目的何在?這不是違反封裝嗎?permits列出的清單,不是曝露了實作嗎?

限制類別階層?

就實務面來看,有時就是需要限制子型態的可能性,例如,在建立3D繪圖軟體時,決定採用點、邊、線、面與實體來表示形狀,在建立模型時,就必須限制Shape型態只能有Vertex、Edge、Wire、Face、Solid子型態,也就是說,若領域中建模對象的型態邊界已知,阻止其他擴充可能性,就會是個需求。

對於封裝,許多人的迷思在於,封裝必然與隱藏畫上等號,其實,封裝真正的目的,在於隱藏無需關心的細節、揭露感興趣的部份,例如,先前專欄〈不只是語法糖的記錄類別〉就談到,記錄類別的目的,就是要明確地將資料載體的名稱、結構、狀態等揭露出來,因為對於一個資料載體,關心的正是這些資訊。

permits列出的清單,確實是曝露了實作,或者應該說,曝露實作才是permits真正的目的:領域中建模對象的型態邊界已知,使用者必須明確地知道這個型態邊界。限制類別階層只是在語法上,由編譯器實現出的效果,然而sealed與pemits的結合,在語義上的真正目的是「明確地揭露型態邊界」,讓開發者可以掌控型態邊界,型態的使用者也能清楚邊界所在。

型態邊界的細化

若真要從限制的角度來看彌封類別,其實被限制的只有直接擴充的可能性,例如,在定義Vertex到Solld等Shape子類別時,不一定要限定為final,還可以是sealed或non-sealed。換言之,就多型來看,可操作的Shape實例,只要是Vertex到Solld等型態就可以了,必要時,Vertex到Solld等型態,可以再次sealed,(讓型態的使用者可以)揭露各自的型態邊界,或者使用non-sealed,讓Vertex到Solld等,可以進一步擁有多樣化的子型態。

這意味著,型態邊界的細化並未受限制,就Shape來說的型態邊界是Vertex到Solld,然而,在必要時,Vertex還可以進一步細化出Point2D、Point3D,也就是定義Vertex可以使用sealed修飾,明確揭露Vertex的邊界。

這也是JEP 360中談到的,對於彌封類別允許的子型態,不能限制這些子型態再被擴充的可能性,這也是促使彌封類別加入Java的原因之一,因為單就限定子型態來說,Java 17之前,可以透過套件權限達到某種程度的實現。

例如,若父類別沒有任何權限修飾,就只能在同套件中使用,開發者就只能在同套件中定義子型態,從而限制了子型態的可能性,然而因為無法存取父類別,也就無法從事多型操作。

另一種方式是,若父類別建構式沒有任何權限修飾,就只能在同套件中呼叫,其他套件的類別無法繼承該父類別,因為沒有呼叫父類別建構式的權限,會引發編譯錯誤,如此一來,開發者就可以放心地在同套件中定義子型態。

然而,這種方式也阻止了客戶端進一步細化型態邊界的可能性。例如,採用此方式定義了Vertex到Solld等型態為Shape的子型態,客戶端將無法進一步將Vertex細化出Point2D、Point3D。

Enum 2.0?

透過彌封類別揭露型態邊界之後,由於型態邊界是透明的,易於讓開發者與使用者清楚邊界所在之外,在需要列舉子型態的場合,編譯器也就有辦法推論,是否列出了全部的子型態,例如,目前Java 17中若開啟預覽特性,使用switch模式比對(JEP 406),若lt為方才定義的List型態,可以如下進行模式比對:

switch(lt) {
case Nil nil -> 0;
case Cons<Integer> cons -> cons.head() + sum(cons.tail());
};

這邊不需要自行加入default,對於彌封類別,編譯器發現未列舉出全部子型態,會自動加入default,執行內容為拋出IncompatibleClassChangeError實例,這樣的行為令人聯想到Java 5就加入的Enum特性,編譯器有辦法檢查switch的案例,是否涵蓋全部列舉值,甚至有些人直接將彌封類別比喻為Enum 2.0。

但是,本質上還是不同,最大的差別在於,Enum列舉的是一組「常數」,如果用Enum的清單有5個,那麼就只有5個值,而彌封類別列舉的是一組「型態」,如果彌封類別允許了5個子型態,各型態可以有無數的實例。

在實現彌封類別子類時,我們可以使用記錄類別,〈不只是語法糖的記錄類別〉談到,未來版本的Java,會有針對記錄類別的解構模式比對(JEP 405),若將以記錄類別實作Cons類別,方才的switch,未來或許可以如下撰寫(正式語法尚未確定),如果你曾使用直接支援函數式程式設計的語言,例如Haskell或Scala,此時馬上就會識別出來,這是處理代數資料型態(Algebraic data type)常見的拆解模式:

switch(lt) {
case Nil nil -> 0;
case Cons<Integer>(head, tail) -> head + sum(tail);
};

揭露型態邊界後的效益

在2012年JCD大會,我談過〈Java開發者的函數式程式設計〉,有興趣可參考〈Java Lambda Tutorial〉第二個區塊的文件,沒想到其中的範例,九年多之後,可重新用彌封類別等特性,更貼切地實現(可參考〈代數資料型態:Java 17〉這個gist)。

與其將彌封類別比喻為Enum 2.0,不如說彌封類別與記錄類別若能彈性結合,可實現廣義的列舉(generalized enums),因為從代數資料型態的角度來看,彌封類別其實是Sum型態,而記錄類別是Product型態(可參考先前專欄〈重構與代數資料型態〉),而在純函數式中,Product型態與Sum型態可以組合,構成各式各樣的型態。

確實地,未來Java對函數式會有更多的支援,但Java終究不是純函數式語言,這類支援有其適用與不適用的場合,選擇適當的時機加以運用即可,知道彌封類別是Sum型態,好處是可以從另一個角度來更認識它,或者說可以瞭解到,先前談到的限定類別階層、switch模式比對、解構模式等,以及對純函數式更多的支援,都是在明確揭露型態邊界之後,可以得到的效益!

專欄作者

熱門新聞

Advertisement