Java 新特性前瞻:封印類

雲棲號資訊:【點擊查看更多行業資訊
在這裏您可以找到不同行業的第一手的上雲資訊,還在等什麼,快來!

本文要點

即將於 2020 年 9 月發佈的 Java SE 15 將引入“封印類(sealed class)”(JEP 360),並將其作爲預覽特性。
封印類是一種類或接口,對哪些類或接口可以擴展它們進行了限制。
封印類就像枚舉一樣,可以捕獲領域模型中的可選項,讓程序員和編譯器可以控制窮舉。
通過解耦可訪問性和可擴展性,封印類有助於創建安全的繼承結構,讓程序庫開發人員既可以公開接口,又能夠控制所有的實現。
封印類與記錄類和模式匹配一起,爲以數據爲中心的編程模式提供支持。
Java SE 15(即將於 2020 年 9 月發佈)引入 封印類作爲預覽特性。封印類和接口對可擴展它們的子類型具有更多的控制權, 這對於一般的領域建模和構建更安全的平臺庫來說都是很有用的。
我們可以用sealed 來聲明一個類或接口,這意味着只有一組特定的類或接口可以直接對其進行擴展:

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

這段代碼聲明瞭一個叫作 Shape 的封印接口。permits 列表限制了只有“Circle”和“Shape”可以實現 Shape。(在某些情況下,編譯器可以爲我們推斷出 permits 子句)。任何其他嘗試擴展 Shape 的類或接口都將收到編譯錯誤(如果你試圖通過其他方式生成 Shape 子類,會在運行時出現錯誤)。

我們都知道可以通過 final 來限制擴展,而封印類可以被認爲是廣義的 final。限制可擴展的子類型將帶來兩個好處:超類型可以更好地指定可能的實現,而編譯器可以更好地控制窮舉(例如在 switch 語句或進行類型轉換時)。封印類可與 記錄類配對使用。

求和類型和乘積類型

上面的接口聲明瞭Shape 可以是Circle 或Rectangle,但不能是其他東西。換句話說,Shape 的集合等於Circle 的集合加上Rectangle 的集合。因此,封印類通常被稱爲求和(sum)類型,因爲它們的值的集合是其他固定幾種類型的值集合的總和。求和類型和封印類並不是什麼新生事物,Scala 也有封印類,Haskell 和ML 有用於定義求和類型的原語,有時候也被叫作標記聯合(tagged union)或區分聯合(discriminated union)。

求和類型經常與乘積類型一起使用。最近在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 { }  
} 

這樣是不是違反了封裝性原則?

面向對象建模鼓勵我們隱藏抽象類的實現,不建議我們問“Shape 可能的子類型是什麼”之類的問題,並告訴我們向下轉換到特定的實現類是一種“代碼壞味道”。那麼,爲什麼我們要引入這個似乎違反了這些原則的語言特性呢?(我們也可以針對記錄類提出同樣的問題:要求在類表示與其 API 之間建立特定關係是不是違反了封裝性原則?)

答案當然是“視情況而定”。在對抽象服務進行建模時,客戶端通過抽象類型與服務進行交互可以降低耦合度,並最大限度地提高系統的演化靈活性。但是,在對特定領域進行建模時,如果該領域的特性已經是衆所周知的,那麼封裝性原則可能就不一定會給我們帶來多大好處。正如我們在記錄類中所看到的那樣,在對一些很普通的事物(例如點或 RGB 顏色)進行建模時,使用通用性對數據進行建模既需要做大量低價值的工作,而且更糟糕的是,這樣通常會造成混淆。對於這種情況,封裝性原則的成本已經超過了它的優勢。

同樣的結論也適用於封印類。在爲一個簡單且穩定的領域建模時,封裝性原則並不一定會爲我們帶來好處,甚至還可能讓客戶端更加難以使用簡單的領域內容。

當然,這並不說封裝性原則是錯誤的,而是說成本和收益之間的權衡有時候不是那麼明顯。我們可以自己判斷什麼時候可以從中獲得好處,什麼時候會給我們造成阻礙。在選擇是公開還是隱藏實現時,我們必須清楚封裝性原則的好處和成本。通常,封裝性是有好處的,但在爲簡單的領域建模時,封裝性的好處可能會大打折扣。

如果一個類型,比如 Shape,限定了接口和實現類,我們就可以更放心地把它轉成 Circle,因爲 Shape 將 Circle 列爲它的已知子類型之一。就像記錄類是一種更透明的類,求和類型是一種更透明的多態性。這就是爲什麼求和類型和乘積類型會如此頻繁一起出現。它們都代表了透明性和抽象性之間的某種折衷,因此,適合使用其中一個類型的地方也適合使用另一個類型。乘積和類型通常被稱爲 代數數據類型。

窮舉
像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 中使用類型模式已經不遠了。(Java SE 15 還不支持,但很快就會出現。) 到了那個時候,我們可以使用 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())); 
    // 不需要提供默認情況! 
} 

封印類在這裏的作用是可以不使用默認子句,因爲編譯器從 Shape 的聲明中已經知道 Circle 和 Rectangle 覆蓋了所有形狀,因此默認子句不會被執行。(編譯器仍然會悄悄地在 switch 表達式中插入一個默認子句,這樣做是爲了防止在編譯和運行這段時間內子類型發生變化,但沒有必要讓程序員來做這件事情。) 這類似於對枚舉進行 switch,因爲枚舉覆蓋了所有已知的常量,所以也不需要使用默認子句。(對於這種情況,忽略默認子句通常會更好,因爲使用默認子句好像在提醒我們是不是錯過了某種情況)。

Shape 的繼承結構給了客戶端一個選擇:它們可以完全通過抽象接口使用形狀,也可以“展開”抽象,並在必要時與更具體的形狀發生交互。模式匹配等特性使這種“展開”更易於閱讀和編寫。

代數數據類型示例

“乘積和”模式非常強大。最好的情況是,子類型列表不發生變化,並預計客戶端會直接區分子類型,這樣會更容易,也更有用。
限定一組固定的子類型,並鼓勵客戶端直接使用這些子類型,這是一種緊耦合的形式。在所有條件相同的情況下,我們鼓勵使用松耦合的設計,以最大限度地提高靈活性,但這種松耦合也是要付出代價的。在編程語言中同時使用“不透明”和“透明”的抽象可以讓我們根據實際情況選擇合適的工具。

我們可能已經在 java.util.concurrent.Future API 中使用了一系列乘積和 (如果當時這是一種選擇的話)。Future 表示可以與其發起者併發執行的計算,Future 所代表的計算可能還沒有開始、已經開始但還沒有完成、已經成功完成(或已經完成但出現異常)、已經超時或被中斷取消。Future 的 get() 方法反映了所有這些可能性:

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(): ... 
} 

乘積和是一種廣義的枚舉
我們可以把乘積和看成是一種廣義的枚舉。枚舉聲明瞭一種類型,包含一組完整的常量實例:

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,我們也可以對各種天體進行 switch:

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 系統中的事件、服務系統中的返回代碼、協議中的消息,等等。

更安全的繼承結構

到目前爲止,我們已經討論了在什麼情況下封印類對領域建模是有幫助的。封印類還有另一個完全不同的應用:更安全的繼承結構。
在 Java 裏,我們通過將類標記爲 final 來表示“這個類不能被繼承”。final 在語言中的存在說明了一個關於類的基本事實:有時候類被設計爲可擴展的,有時候則不是,我們希望同時支持這兩種模式。實際上,《 Effective Java 》建議我們“爲擴展而設計,否則就禁止擴展”。這是一個很好的建議,如果編程語言在這方面爲我們提供更多的幫助,我們可能會更容易接受這個建議。

可惜的是,編程語言在兩方面未能幫到我們:默認的類是可擴展的,而 final 機制實際上非常弱,因爲它迫使程序員在約束擴展和使用多態性之間做出選擇。以 String 爲例,字符串是不可變的,因此 String 不能被繼承,這對平臺的安全性來說至關重要——但對於實現來說,擁有多個子類型會更爲方便。解決這個問題的成本是巨大的。 緊湊字符串對僅由Latin-1 字符組成的字符串進行了特殊處理,從而顯著降低了佔用空間,並提升了性能,但如果String 是一個封印類而不是final 的類,這樣做會更容易、成本更低。

有一種方法可以模擬封印類(不是接口),即使用包內可見的構造函數,並將所有實現放在同一個包中。雖然這樣做是可以的,但令人感到不是很舒服,因爲你要公開一個抽象類,但又不希望被擴展。程序庫作者更喜歡使用接口來公開不透明的抽象,但抽象類是用來爲實現提供輔助的,並不是建模工具(參見《Effective Java》的“Prefer interfaces to abstract classes”)。

有了封印接口,程序庫作者不需要再糾結是使用多態性、是允許不受控制的擴展還是將抽象公開爲接口——他們可以同時擁有這三種技術。作者可能會選擇讓實現類可訪問,但更有可能讓實現類保持封裝性。

封印類允許程序庫作者將可訪問性與可擴展性解耦。這種靈活性很好,但我們應該在什麼時候使用呢?當然,我們不希望將List 變成封印接口,因爲對於用戶來說,創建新類型的List 是完全合理和可取的。封印既有成本(用戶不能創建新的實現) 也有好處(可以全局控制實現),我們應該在好處高過成本的時候使用封印。

其他說明

sealed 可以用於修飾類或接口,但試圖對一個 final 類添加 sealed 修飾符是不行的,不管這個類是顯式地使用 final 聲明,還是隱式地使用 final(比如枚舉和記錄類)。

一個封印類有一個允許擴展它的子類型列表,這些子類型必須在編譯封印類時可用,必須是封印類的子類型,並且必須與封印類位於同一個模塊中 (如果是未命名的模塊,就必須在同一個包中)。實際上這意味着它們必須與封印類一同維護,對於這種緊密的耦合,這樣的要求是合理。

如果允許擴展的子類型都與封印類位於相同的編譯單元中,那麼 permit 子句可以省略。封印類不能作爲 lambda 表達式的函數接口,也不能作爲匿名類的基類。

封印類的子類型必須更明確地說明它們的可擴展性。封印類的子類型必須是 sealed、final 或顯式標記爲 non-sealed。(記錄類和枚舉是隱式 final,因此不需要顯式標記。) 如果類或接口的超類型不是 sealed,那麼就不能將其標記爲 non-sealed 的。

將已有的 final 類變成 sealed 的,不管是在二進制文件還是源碼方面都是兼容的。但將非 final 類變成 sealed,不管是在二進制還是源代碼方面都是不兼容的。在封印類中添加新的允許子類型是二進制兼容的,但不是源代碼兼容的 (這可能會破壞 switch 表達式的窮舉性)。

總結

封印類有多種用途。如果有必要捕獲領域模型中的一組完整可選項,可以將它們可以作爲一種領域建模技術。如果需要解耦可訪問性和可擴展性,可以將它們可以作爲一種實現技術。封印類是對記錄類的自然補充,因爲它們一起形成了代數數據類型。它們也很適合用於模式匹配。Java 也很快會帶來模式匹配。

腳註
這個示例使用了某種 switch 表達式形式——它使用模式作爲 case——Java 還不支持這種形式。每六個月的發佈週期允許我們同時設計功能,但可以單獨交付。我們非常期待在不久的將來 switch 能夠使用模式作爲 case。

作者簡介
Brian Goetz 是 Oracle 的 Java 語言架構師,JSR-335 (Java Lambda 表達式) 規範負責人。他是暢銷書《Java 併發實踐》一書的作者,自 Jimmy Carter 擔任美國總統以來,他就一直癡迷於編程。

【雲棲號在線課堂】每天都有產品技術專家分享!
課程地址:https://yqh.aliyun.com/zhibo

立即加入社羣,與專家面對面,及時瞭解課程最新動態!
【雲棲號在線課堂 社羣】https://c.tb.cn/F3.Z8gvnK

原文發佈時間:2020-08-04
本文作者:Brian Goetz
本文來自:“InfoQ”,瞭解相關信息可以關注“InfoQ

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章