Java 迎來 Records

初探 Java 14 的資料 records 會如何改變你寫程式的方式

Du Spirit
Java Magazine 翻譯系列
15 min readMay 17, 2024

--

Translated from “Records Come to Java” By Ben Evans, Java Magazine January 10, 2020. Copyright Oracle Corporation.

在本文中,我將介紹 Java 的新概念 Record,一種新的 Java 類別,專門設計用於:

  • 為純資料聚合提供第一級語涵
  • 彌補 Java 型別系統中潛在的缺陷
  • 對常用程式設計模式提供語言層級的語法支持
  • 減少樣板程式碼

Record 的功能正如火如荼地開發中,目標是作為 Java 14 (預計三月釋出) 的預覽功能推出 [譯註:翻譯這篇文章時,Java 22 已經釋出,Record 也已經成為正式功能],要更好了解本文,你需要對 Java 程式設計有一定的理解,必對於程式語言是如何設計與實作有興趣。

我們先探討一下 Java 的基本概念。

Java 的 Record 是什麼?

Java 為人詬病的其中一點,你需要寫不少的程式才能讓一個類別變得有用。通常你需要寫以下內容 [譯註:那我可能是異類,除非真的需要用到對應的功能,不然我很少寫 toString()hashCode()equals()]:

  • toString()
  • hashCode()equals()
  • Getter 函式
  • 一個公開建構子

對於簡單的領域類別,這些函示經常很枯燥、重複,屬於可以自動產生的東西 (IDE 通常會提供這功能),但目前,語言本身卻沒有任何方式自動生成。

當你在閱讀別人的程式碼,這令人沮喪的差距會讓體驗更糟。例如,作者可能用 IDE 自動產生 hashCode()equals() 處理類別的所有屬性 (fields),如果你不檢查實作的每一行,你怎麼確保正確性?如果重構時新增一個新屬性,這些函式沒有重新產生,會發生什麼事?

Record 的目標是擴展 Java 語言的語法,建立一種方式,用來表達一個類別:只有屬性,就是只有屬性,除了屬性外沒有別的。當你描述這樣的類別,編譯器自動幫你生成所有的函式,並確保所有屬性都參與如 hashCode() 等函式。

一個沒有使用 Record 的例子

本文中,我用外匯 (Foreign Exchange, FX) 交易作為例子來解釋 Record,我會展示如何使用 Record 改善你的領域建模,最後得到更乾淨、簡潔和簡單的程式碼。

注意,在一篇短文中,不可能描述一個現實中完整的交易系統,因此,我僅專注在一些基本的面向。

思考一下,外匯交易如何下單,基本的訂單型別可能包含 [譯註:會出現在程式碼中的英文會盡可能保留]:

  • 買賣的單位數 (以百萬貨幣單位計)
  • side,即買方或賣方,通常分別稱 bid (出價) 或 ask (詢價)
  • 交換的貨幣 (currency pair)
  • 下單的時間
  • 訂單逾時前的有效時間 (TTL)

所以,如果你有一百萬英鎊,想賣給美元,以每英鎊換取 1.25 美元,在外匯交易的術語中,你想「以 1.25 美元的匯率,買入一百萬英鎊/美元的匯率」,交易員還會提及交易時間,通常是指當下;以及訂單的有效期限,通常是一秒或更短。

在 Java 中,你會宣告一個如下領域類別 (我稱之為 Classic,以強調你目前需要這樣做):

然後,一張訂單能像這樣被建立:

但,宣告的程式碼中有多少是真正必要的?在目前的 Java 版本中,多數開發者可能只宣告屬性,然後用 IDE 產生所有的函式,讓我們看一下 Record 如何改善這情況。

順便提一下,Java 並沒有提供以定義類別以外的方式描述資料聚合,因此,任何只有屬性的型別 (type) 都是一個類別 (class)。

使用 Record 的例子

這個新概念是一個 record 類別 (通常簡稱為 record),是一種不可變 (即一般 Java 所謂的淺層不可變 [譯註:不能直接把屬性換成另一個值,但如果屬性是一個物件,可以呼叫物件的函式改變物件的內容]) 的透明載體,乘載固定數量的數值,稱為 record components。每個組件由一個屬性保存提供的值,並提供一個函式取得該值,屬性名稱、讀取函式的名稱和組件的名稱一致。

一串屬性提供 record 的狀態描述。在一個類別中,屬性 x,建構子參數 x 和讀取器 x() 可能沒有關聯。但在 record 中,根據定義,他們是同一件事,record 即其狀態。

為了能建立 record 類別的實例,也同樣生成一個稱作正規建構子的建構子,其參數列和宣告的狀態描述完全吻合。

Java 語言 (Java 14 預覽功能) 提供簡潔的語法宣告 record,如下所示,程式設計師只需要宣告組成 record 的組件的名稱與型別:

透過宣告,你不只省下打字的工夫,你更是建立一個強而有力的語意聲明:FXOrder 是一個由狀態提供的型別,任何實例只是一組屬性數值的聚合。

這樣做的一結果是屬性名稱變成你的 API,因此,挑選好的名稱變得更重要 (例如,Pair 不是一個好的型別名稱,因為可能指的是一雙鞋子)。

要使用這新的語言功能,編譯任何 record 時,你要指示要使用預覽功能:

javac --enable-preview -source 14 FXOrder.java

如果你用 javap 檢視編譯後的檔案,你會看到編譯器自動產生許多樣板程式 (下面我只展示函式與其參數)

$ javap FXOrder.class
Compiled from "FXOrder.java"
public final class FXOrder extends java.lang.Record {
public FXOrder(int, CurrencyPair, Side, double,
java.time.LocalDateTime, int);
public java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public int units();
public CurrencyPair pair();
public Side side();
public double price();
public java.time.LocalDateTime sentAt();
public int ttl();
}

這看起來,和基於類別的實作中的函式集合十分相似,事實上,建構子和讀取函式都和過去完全相同 [譯註:個人不覺得是完全相同,因為 getter 通常是以 getis 開頭,但 record 並沒有依這慣例產生 getter]。

Record 的特點

然而,像是 toString()equals() 的實作可能會讓某些開發者感到驚訝:

public java.lang.String toString();
Code:
0: aload_0
1: invokedynamic #51, 0 // InvokeDynamic #0:toString:(LFXOrder;)Ljava/lang/String;
6: areturn

toString() 函式 (以及 equals()hashCode()) 的實作,使用一種基於 invokedynamic 的機制,這與最近的 Java 版本中,字串串接的實作一樣,轉換成 invokedynamic 機制。

同樣,你可以看到一個新類別,java.lang.Record,作為所有 record 類別的超類別,是個抽象類別,並將 equals()hashCode()toString() 宣告為抽象函式。

無法直接繼承 java.lang.Record 類別,如果試圖編譯像下面的程式:

編譯器會像這下面這樣拒絕:

$ javac --enable-preview -source 14 FXOrderClassic.java

FXOrderClassic.java:3: error: records cannot directly extend Record
public final class FXOrderClassic extends Record {
^
Note: FXOrderClassic.java uses preview language features.
Note: Recompile with -Xlint:preview for details.
1 error

這意味,只有一種方式能取得 record:明確宣告然後用 javac 產生類別,這方式確保所有的 record 類別都是 final

當使用 record,還有幾個核心的 Java 功能也有特殊的特性。

首先,record 必須遵守關於 equals() 函式的特殊約定:

If a record R has components c1, c2, … cn, then if a record instance is copied as follows:

一個 record,R,有 c1c2、... cn 等組件,則如果有一個如下的複本:

R copy = new R(r.c1(), r.c2(), ..., r.cn());

則這情境 r.equals(copy) 必須為真,注意,和一般熟悉關於 equals()hashCode() 的合約,這個不變量約束是增加上去,不是取代。

其次,record 的 Java 序列化實作和一般的類別不同。這是好事,因為眾所周知,Java 的序列化機制在一般情況下有嚴重缺陷。正如 Java 語言的架構師 Brian Goetz 所述:「序列化建構一個看不見但公開的建構子,以及一組看不見但能存取內部狀態的存取器」 [譯註:最簡單不採雷的方式,就是不要用 Java 原生的序列化機制]。

幸運地是,record 設計得相當簡單:他們只是屬性的載體,所以無需使用序列化機制中的奇怪細節。取而代之,你總是能使用公開的 API 以及正規建構子,序列化與反序列化 record。

此外,除非有明確宣告,不然 record 類別的 serialVersionUID0L,record 類別不再需要滿足 serialVersionUID 的值需要匹配的要求。

在進入下一節之前,我想強調,有一個新的程式設計模式以及一個用較少樣板程式定義類別的新語法,他們和 Valhalla 專案中正在開發的 inline class 無關 [譯註:不確定是不是我漏看了,但到 Java 22 為止,還沒看到這兩個新功能,我確實是蠻期待的]。

設計層級的考量

接著探索 record 功能的一些設計層面。為此,回顧一下 enum 是如何在 Java 中運作是有幫助的。在 Java 中,一個 enum 是一種特殊形式的類別,實作一種模式,但語法開銷很小,編譯器為我們產生大量的程式碼。

同樣,在 Java 中,record 是一種特殊形式的類別,用精簡的語法實作資料載體模式,所有你預期的樣板程式都由編譯器自動產生。

然而,即便是資料載體類別這樣簡單的概念,只保有屬性,非常直覺,但在細節上到底是意味著什麼?

當 record 首次被討論時,許多不同的設計被考慮過,例如:

  • 減少樣板程式的普通 Java 物件 (plain old Java objects, POJOs)
  • JavaBeans 2.0
  • 具名組合 (Named tuples)
  • 乘積型別 (Product types),一種代數資料的形式

這些可能選項在 Brian Goetz 他的原始設計圖中討論過,每個設計選項都伴隨著與設計中心選擇相關的次要問題,例如:

  • Hibernate 能代理它們嗎?
  • 它們與傳統 JavaBean 完全相容嗎?
  • 有相同屬性且以相同順序宣告的兩個 record 會視為相同的型別嗎?
  • 是否使用模式比對技術,例如模式比對與解構?

基於這些的任一種方法設計 record 功能看似都合理,因為皆有優點與缺點。然而,最終的設計決策是,record 是具名組合 (named tuples)。

這選擇部分受 Java 型別系統的關鍵設計所驅動,即名義型別。這理念是 Java 的每個存儲 (變數、屬性) 都是明確型別,且每個型別都具有對人來說有意義的名稱。

即便是匿名類別,型別依舊有名稱,只是由編譯器分配名稱,它們不是 Java 語言中有效的名稱,但在 VM 中是允許的 [譯註:這些匿名類別的名稱是由編譯器產生在 bytecode 中],例如:

jshell> var o = new Object() {
...> public void bar() { System.out.println("bar!"); }
...> }
o ==> $0@37f8bb67

jshell> var o2 = new Object() {
...> public void bar() { System.out.println("bar!"); }
...> }
o2 ==> $1@31cefde0

jshell> o = o2;
| Error:
| incompatible types: $1 cannot be converted to $0
| o = o2;
| ^^

注意,即使這些匿名類別已完全相同的方式宣告,編譯器仍產生不同的類別匿名類別,$0$1,且不允許相互賦值,因為在 Java 型別中,這些變數是不同的型別。

一些 (非 Java) 語言,以其類別的整體結構 (具有的屬性與函式) 作為型別 (而不是以明確的名稱),這撐過結構化型別。

若 record 打破 Java 的傳統,為 record 帶來結構化型別,那就是個重大改變。結果,records 是名義聚合 (nominal tuples) 的設計決定,意味著你應該可以預期 record 能在其他語言使用聚合的情境中運作良好。這包含一些使用情境,例如複合映射鍵 (compound map keys) [譯註:簡單說,就是把一個物件當成 map 的 key 使用,替代建構子一節中的例子比較好理解] 或是模擬函式的多重回傳等,一個複合映射鍵的例子可能像這樣:

順便一提,record 不見得能良好地取代目前用 JavaBeans 寫的程式碼,有幾個原因:JavaBeans 是可變的,但 record 不是;且它們的存取器 (accessors) 慣例也不同 [譯註:看起來不遵循既有慣例是刻意的]。

不僅是簡單的單行宣告,record 確實允許一些額外的彈性,因為它們仍是類別。具體來說,除了預設自動產生的內容外,開發者可以定義額外的函式、建構子以及靜態屬性。然而,這些能力應謹慎使用,記住,record 的設計目的是讓開發者能將相關的屬性組合在一起變成一個不可變的資料。

一個好的經驗法則:愈是想要在基礎的資料載體加入額外的函式 (或是實作一個介面),很大的機率,應該使用完整的類別而不是 record。

緊湊建構子

這個經驗法則有重要的可能例外,使用緊湊建構子,就如同 Java 規範中的描述:

宣告緊湊建構子的意圖,是提供僅作驗證和/或正規化的程式,傳入正規建構子,其餘初始化的程式由編譯器提供。

例如,你希望驗證訂單以確保不會買入或賣出負數數量或是設置無效的 TTL 數值:

一個緊湊建構子不會導致編譯器產生獨立的建構子,相反,你在緊湊建構子中描述的程式碼會作為正規建構子開頭額外的程式碼,不需要將建構子參數賦值給屬性,這仍是自動產生,以一般的方式出現在建構子中。

一個 Java 的 record 比其他語言的匿名聚合更好的優勢,能在建立 records 時執行程式,這樣能進行驗證 (如果傳入不合法的狀態則拋出例外),這是純結構化聚合無法做到的。

替代建構子

也是可以在 record 內用一些靜態的工廠函式,例如,作為 Java 缺少預設參數的替代方案。以交易的例子,你可能包含一個像這樣的靜態工廠,宣告一個能用預設參數快速建立物件的方式:

當然,也可以宣告一個替代建構子,你應該根據情況選擇合適的方式。

使用替代建構子的另一種用途是建立 record 作為複合映射鍵,像這個例子:

OrderPartition 型別能作為一個映射的鍵 [譯註:因為 record 會自動生成需要的 hashCode()equals()],例如,你可以建構一本訂單簿,用於交易比對引擎中:

然後,當收到一筆新訂單,addOrder() 函式取出合適的訂單分區 (由幣值對和買賣方組成),然後將該筆新訂單加到合適的按價格排序的訂單簿,這新訂單可能與訂單簿中既有的訂單匹配 (稱作 crossing),所以我需要在函式checkForCrosses() 檢查是否發生。

有時候,你不想使用緊湊建構子,而是使用完整的顯式正規建構子,這表示你需要在建構子中進行實際的工作 —— 對簡單的資料載體而言,這使用情境很少。然而,某些情境,如需要對參數進行防禦性複製,這選項是必要的。因此,編譯器允許顯示正規建構子 [譯註:意思是指開發者自己寫正規建構子],但使用前要謹慎思考。

結論

Record 的意圖是作為簡單的資料載體,是聚合的一種版本,以符合邏輯和一致性的方式融入 Java 打造的型別系統中。這幫助許多應用程式建立更清晰和小巧的領域類別,這同樣幫助團隊減少以手工鑽寫基礎模式的實作,減少或移除像 Lombok 函式庫的使用。

然而,如密封類別,record 的部分重要使用案例在未來會顯現,模式比對,特別是,解構模式,讓一個 record 能分解成其組件,顯示出極大的潛力,能改變許多開發人員在 Java 寫程式的方式,密封型別與 record 的組合,還能為 Java 提供一個語言特性,稱之為代數資料型別 (algebraic data types)。

如果你已從其他程式語言熟悉這些特性,很好,如果不熟悉,不用擔心。它們以符合你所熟知的 Java 語言的方式設計,並且容易在你的程式碼中開始使用。

然而,你必須記住,直到 Java 釋出特定語言特性的最終版本前,不要依賴它。當討論未來可能的新功能,像我的這篇文章,應理解它的目的是探索新功能。

譯者的告白

Java 22 早已發布,record 也已經脫離預覽,成為正式功能了,老實說,這篇翻譯來的有點晚,但內容是真的蠻不錯的,最後還是選擇把它翻譯完。個人還蠻常用 record 的,特別是當成 Value Object 使用,加上很多 JSON 函式庫後續也支援 record,當成 Data Transfer Object 也很好用,希望這篇有幫助到想認識 record 的開發者。

--

--