深入理解 Java17 新特性:Sealed Classes

公众号-JavaEdge發表於2024-07-24

0 關鍵總結

  • Java SE 15在2020年9月釋出,預覽功能引入“封閉類”(JEP 360)
  • 封閉類是一種限制哪些其他類或介面可擴充套件它的類或介面
  • 類似列舉,封閉類在領域模型中捕獲替代方案,允許程式設計師和編譯器推理其窮盡性
  • 封閉類對於建立安全的層次結構也很有用,透過解耦可訪問性和可擴充套件性,允許庫開發者公開介面,同時控制所有實現
  • 封閉類與記錄和模式匹配一起工作,以支援更資料中心化的程式設計形式

1 預覽功能

鑑於Java全球影響力和高相容性承諾,語言功能設計錯誤代價非常高。如語言功能存在缺陷,保持相容性不僅意味很難移除或顯著改變功能,且現有功能還會限制未來功能發展。新功能要透過實際使用來驗證,開發人員的反饋至關重要。為確保在快速釋出節奏下有足夠的時間進行實驗和反饋,新語言功能將透過一或多個輪次的預覽來測試,這些功能是平臺的一部分,但需要單獨選擇進入,並且尚未成為永久功能,以便在根據開發人員的反饋進行調整時,不會破壞關鍵程式碼。

Java SE 15(2020年9月)引入了作預覽功能。封閉允許類和介面更好地控制其允許的子型別,這對於一般領域建模和構建更安全的平臺庫都很有用。

一個類或介面可以宣告為sealed,這意味著只有特定的一組類或介面可以直接擴充套件它:

sealed interface Shape
    permits Circle, Rectangle { ... }

這宣告瞭一個名為Shape的封閉介面。permits列表表示只有CircleRectangle可以實現Shape。(在某些情況下,編譯器可以為我們推斷出允許列表。)任何其他嘗試擴充套件Shape的類或介面將會收到編譯錯誤(或在執行時嘗試生成宣告Shape為超型別的非標籤類檔案時,收到執行時錯誤)。

我們已熟悉透過final類限制擴充套件;封閉可被認為是終結性的泛化。限制允許的子型別集可能帶來兩個好處:

  • 超型別的作者可以更好地推理可能的實現,因為他們可以控制所有的實現
  • 而編譯器可以更好地推理窮盡性(例如在switch語句或強制轉換中)

封閉類與[記錄]配合得很好。

2 和列舉型別類似的和積型別

上面的介面宣告表明,一個Shape可以是CircleRectangle,而不能是其他任何東西。即所有Shape的集合等於所有Circle的集合加上所有Rectangle的集合。因此,封閉類通常被稱為和型別,因為它們的值集是其他型別固定列表的值集的總和。封閉類和和型別不是新事物,如Scala和Haskell都有封閉類,而ML有定義和型別的原語(有時稱為標籤聯合判別聯合)。

和型別經常與積型別一起出現。記錄是最近[引入Java]的積型別形式,因為它們的狀態空間是其元件的狀態空間的笛卡爾積的一個子集(如果這聽起來複雜,可以將積型別想象為元組,記錄是命名元組)。

用記錄完成Shape的宣告:

sealed interface Shape
    permits Circle, Rectangle {

      record Circle(Point center, int radius) implements Shape { }

      record Rectangle(Point lowerLeft, Point upperRight) implements Shape { } 
}

和型別和積型別是咋配合的;“一個圓由一箇中心和一個半徑定義”,“一個矩形由兩個點定義”,最後“一個形狀要麼是一個圓要麼是一個矩形”。由於我們預計在同一個編譯單元中共同宣告基型別及其實現型別是很常見的,因此當所有子型別都在同一編譯單元中宣告時,允許省略permits子句,並推斷為在該編譯單元中宣告的子型別集合:

sealed interface Shape {

      record Circle(Point center, int radius) implements Shape { }

      record Rectangle(Point lowerLeft, Point upperRight) implements Shape { } 
}

3 違反封裝?

歷史上,物件建模鼓勵隱藏抽象型別的實現集。被告知不要問“可能的Shape子型別是什麼”,類似地被告知向特定實現類的下轉型是種“程式碼異味”。

為啥現在新增看似違反這些長期原則的語言功能?(也可問類似問題,關於記錄:要求在類的表示和其API之間建立特定關係是否違反封裝?)

當然是“視情況而定”。建模一個抽象服務時,透過抽象型別與服務互動是一個積極的好處,因為減耦,並最大限度提高系統演進靈活性。但建模一個特定領域時,如該領域特性已很清楚,封裝可能沒太多優勢。正如記錄中所見,建模如XY點或RGB顏色這樣簡單資料時,使用物件的完全通用性來建模資料需要大量低價值工作,更糟糕的,往往掩蓋實際發生的事。此時,封裝成本不值得其帶來的好處;將資料建模為資料更簡單直接。

同樣的論點適用於封閉類。建模一個已知且穩定的領域時,“我不會告訴你有哪些種類的形狀”的封裝可能不會帶來我們期望從不透明抽象中獲得的好處,甚至可能使客戶更難處理一個實際上很簡單的領域。

這不意味著封裝是個錯誤;這僅意味著有時成本和收益的平衡不一致,可透過判斷來確定何時有幫助,何時妨礙。當選擇公開或隱藏實現時,須明確封裝的收益和成本。它是否為我們提供演進實現的靈活性或僅是個資訊破壞的障礙,阻礙對方已顯而易見的東西?封裝的好處通常巨大,但在建模已知領域的簡單層次結構時,宣告堅如磐石的抽象的開銷有時可能超過收益。

Shape這樣的型別不僅承諾其介面,還承諾實現它的類時,可更好詢問“你是圓形嗎”並轉換為Circle,因為Shape明確命名Circle作為其已知子型別之一。就像記錄是一種更透明的類,和型別是一種更透明的多型性。這就是為啥和型別和積型別如此頻繁一起出現;它們都代表透明性和抽象之間的權衡,所以在一個地方有意義的地方,另一個地方也可能有意義。(和積型別通常被稱為代數資料型別。)

4 窮盡性

Shape這樣的封閉類承諾一個可能子型別的窮盡列表,這有助於程式設計師和編譯器以我們以前無法做到的方式推理形狀。(其他工具也可以利用這些資訊;Javadoc工具在生成的封閉類文件頁面中列出了允許的子型別。)

Java SE 14引入一種有限形式的模式匹配,將來會擴充套件。第一個版本允許我們在instanceof中使用型別模式

if (shape instanceof Circle c) {
    // 編譯器已為我們將shape轉換為Circle,並繫結到c
    System.out.printf("Circle of radius %d%n", c.radius()); 
}

從那裡易跳到在switch中使用型別模式。可用switch表示式,其case標籤是型別模式,如下計算形狀的面積:

float area = switch (shape) {
    case Circle c -> Math.PI * c.radius() * c.radius();
    case Rectangle r -> Math.abs((r.upperRight().y() - r.lowerLeft().y())
                                 * (r.upperRight().x() - r.lowerLeft().x()));
    // no default needed!
}

封閉的貢獻在無需default子句,因為編譯器從Shape的宣告中知道CircleRectangle覆蓋了所有的形狀,因此switch中的default子句將不可達。(編譯器仍會在switch表示式中默默地插入一個丟擲預設子句,以防Shape的允許子型別在編譯和執行時之間發生變化,但沒有必要堅持程式設計師編寫這個“以防萬一”的預設子句。)這類似我們對待另一個窮盡性的來源——覆蓋所有已知常量的enum上的switch表示式也不需要default子句(在這種情況下省略它通常是個好主意,因為這更有可能提醒我們錯過了一個情況。)

Shape這樣的層次結構為其客戶端提供一個選擇:他們可完全透過抽象介面處理形狀,但他們也可在有意義時“展開”抽象並透過更明確的型別進行互動。像模式匹配這樣的語言特性使這種展開更易讀寫。

5 代數資料型別示例

“和積模式”可以是一種強大的模式。為了適用,它必須極不可能更改子型別列表,並且我們預見到讓客戶端直接區分子型別會更容易和更有用。

承諾一個固定的子型別集,並鼓勵客戶端直接使用這些子型別,是一種緊耦合。一般,我們被鼓勵在設計中使用松耦合,以最大限度提高更改靈活性,但這種松耦合也有成本。語言中同時擁有“不透明”和“透明”抽象允許我們為特定情況選擇合適工具。

一個可能會使用和積型別的地方是在java.util.concurrent.FutureAPI。Future代表一個可能與其發起者併發執行的計算;Future表示的計算可能尚未開始,已開始但尚未完成,已成功完成或異常完成,已超時或被取消。Futureget()反映所有這些可能性:

interface Future<V> {
    ...
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}
  • 如計算尚未完成,get()會阻塞直到完成模式之一發生
  • 如成功,返回計算結果
  • 如計算透過丟擲異常完成,此異常將被包裝在ExecutionException
  • 如計算超時或被中斷,將拋不同型別異常

此API非常精確,但用起來有些痛苦,因為有多個控制路徑,正常路徑(get()返回值)和許多失敗路徑,每個都必須在catch塊處理:

try {
    V v = future.get();
    // 處理正常完成
}
catch (TimeoutException e) {
    // 處理超時
}
catch (InterruptedException e) {
    // 處理取消
}
catch (ExecutionException e) {
    Throwable cause = e.getCause();
    // 處理任務失敗
}

如Java 5引入Future時有封閉類、記錄和模式匹配,可能這樣定義返回型別:

sealed interface AsyncReturn<V> {
    record Success<V>(V result) implements AsyncReturn<V> { }
    record Failure<V>(Throwable cause) implements AsyncReturn<V> { }
    record Timeout<V>() implements AsyncReturn<V> { }
    record Interrupted<V>() implements AsyncReturn<V> { }
}

...

interface Future<V> {
    AsyncReturn<V> get();
}

一個非同步結果要麼成功(帶返回值),要麼失敗(帶異常),要麼超時,要麼取消。

這是更統一描述可能結果的方式,而非透過返回值和異常分別描述其中一些結果。客戶端仍須處理所有情況——無法避免任務可能失敗的事實——但我們可更統一處理這些情況(更緊湊地):

AsyncResult<V> r = future.get();
switch (r) {
    case Success(var result): ...
    case Failure(Throwable cause): ...
    case Timeout(), Interrupted(): ...
}

6 和積型別是廣義的列舉

理解和積型別的一個好方法是,它們是列舉的廣義形式。一個列舉宣告宣告瞭一個具有窮盡常量例項集的型別:

enum Planet { MERCURY, VENUS, EARTH, ... }

可將資料與每個常量關聯,如行星的質量和半徑:

enum Planet {
    MERCURY (3.303e+23, 2.4397e6),
    VENUS (4.869e+24, 6.0518e6),
    EARTH (5.976e+24, 6.37814e6),
    ...
}

廣義而言,一個封閉類列舉的不是封閉類的固定例項列表,而是固定例項型別的種類列表。如這個封閉介面列出各種型別的天體及與每種型別相關的資料:

sealed interface Celestial {
    record Planet(String name, double mass, double radius)
        implements Celestial {}
    record Star(String name, double mass, double temperature)
        implements Celestial {}
    record Comet(String name, double period, LocalDateTime lastSeen)
        implements Celestial {}
}

正如你可窮盡地切換列舉常量,你也可以窮盡地切換各種天體型別:

switch (celestial) {
    case Planet(String name, double mass, double radius): ...
    case Star(String name, double mass, double temp): ...
    case Comet(String name, double period, LocalDateTime lastSeen): ...
}

這種模式的例子隨處可見:UI系統中的事件,面向服務系統中的返回碼,協議中的訊息等。

7 更安全的層次結構

到目前為止,我們討論了封閉類在將替代方案納入領域模型時的有用性。封閉類還有另一個完全不同的應用:安全層次結構。

Java一直允許我們透過將類標記為final來表示“這個類不能被擴充套件”。final存在承認了一個關於類的基本事實:有時它們被設計為可擴充套件,有時則不是,希望支援這兩種模式。實際上,[Effective Java]建議我們“設計和記錄用於擴充套件,否則禁止它”。這是很好的建議,如語言能更多幫助我們,可能更常被採納。

可惜,語言在兩方面未能幫助我們:

  • 類的預設設定是可擴充套件,而非 final
  • 並且final機制實際相當弱,因為它迫使作者在限制擴充套件和使用多型作為實現技術之間做出選擇

String是個很好例子,平臺安全性要求字串不可變,因此String不能公開擴充套件——但對實現來說有多個子型別會非常方便。(解決這個問題的成本很高;[緊湊字串]透過對僅包含Latin-1字元的字串進行特殊處理,提供顯著的記憶體佔用和效能改進,但若String是封閉類而非final類,這會更容易和低成本。)

透過使用包私有建構函式並將所有實現放在同一包,模擬封閉類(但不是介面)效果的技巧眾所周知。這有幫助,但仍不舒服,公開一個不打算擴充套件的公共抽象類。庫作者更喜歡使用介面來公開不透明的抽象;抽象類被設計為一種實現輔助工具,而不是建模工具。(參見[Effective Java],“優先使用介面而不是抽象類”。)

使用封閉介面,庫作者無需在使用多型作為實現技術、允許不受控制的擴充套件或將抽象公開為介面之間做出選擇——他們可三者兼得。作者可能選擇讓實現類可訪問,但更可能的是,實現類將保持封裝。

封閉類允許庫作者解耦可訪問性和可擴充套件性。擁有這種靈活性很好,但啥時應該使用它呢?當然,我們不會想要封閉像List這樣的介面——使用者建立新的List型別是完全合理且可取的。封閉可能有:

  • 成本(使用者無法建立新實現)
  • 和收益(實現可以全域性推理所有實現)

我們應該將封閉保留給收益超過成本時。

8 細則

sealed修飾符可用於類或介面。嘗試封閉一個final類,無論:

  • 顯式宣告的final修飾符
  • 還是隱式final,如列舉和記錄類

都是錯誤的。

封閉類有個permits列表,是唯一允許的直接子型別,它們必須:

  • 在封閉類編譯時可用
  • 實際是封閉類的子型別
  • 封閉類在同一模組(或在未命名模組中則在同一個包中)

這要求實際上意味著它們必須與封閉類共同維護,這是對這種緊耦合的合理要求。

若允許的子型別都在封閉類的同一編譯單元中宣告,可省略permits子句,並推斷為同一編譯單元中宣告的所有子型別。封閉類不能用作lambda表示式的函式介面,也不能用作匿名類的基型別。

封閉類的子型別必須更明確地說明其可擴充套件性;封閉類的子型別須sealedfinal或顯式標記為non-sealed。(記錄和列舉隱式為final,因此不需要顯式標記。)如果類或介面沒有封閉的直接超型別,標記為non-sealed是錯誤的。

將現有final類變為sealed是二進位制和原始碼相容的。對於你不控制所有實現的非final類,將其封閉既不二進位制相容也不原始碼相容。將新的允許子型別新增到封閉類是二進位制相容但不原始碼相容的(這可能會破壞switch表示式的窮盡性)。

9 總結

封閉類有多種用途;它們在領域建模技術中很有用,當捕獲領域模型中的窮盡替代方案時;在解耦可訪問性和可擴充套件性時,它們也是有用的實現技術。封閉型別是[記錄]的自然補充,因為它們共同形成了一種稱為代數資料型別的常見模式;它們也是[模式匹配]的自然契合。
關注我,緊跟本系列專欄文章,咱們下篇再續!

作者簡介:魔都架構師,多家大廠後端一線研發經驗,在分散式系統設計、資料平臺架構和AI應用開發等領域都有豐富實踐經驗。

各大技術社群頭部專家博主。具有豐富的引領團隊經驗,深厚業務架構和解決方案的積累。

負責:

  • 中央/分銷預訂系統效能最佳化
  • 活動&券等營銷中臺建設
  • 交易平臺及資料中臺等架構和開發設計
  • 車聯網核心平臺-物聯網連線平臺、大資料平臺架構設計及最佳化
  • LLM Agent應用開發
  • 區塊鏈應用開發

目前主攻市級軟體專案設計、構建服務全社會的應用系統。

參考:

  • 程式設計嚴選網

本文由部落格一文多發平臺 OpenWrite 釋出!

相關文章