Categories
程式開發

Java 14 特性專題報導:記錄


本文要點

  • Java SE 14(2020年3月)引入記錄(record)(JEP359)作為預覽特性。記錄的目的是增強語言能力,簡化“純數據”聚合建模。
  • 可以將記錄看作一個命名元組;它是一個面向特定有序元素序列的透明的淺不可變載體。
  • 記錄可以在各種情況下用於常見用例的建模,比如多返回值、流連接、組合鍵、樹節點、DTO等等,並提供更強的語義保證,使開發人員和框架可以更可靠地推斷它們的狀態。
  • 與枚舉類似,記錄與類相比也有一些限制,因此不會替代所有的數據載體類。具體來說,它們的目的不是替代可變的JavaBean類。
  • 在保證兼容性的前提下,可以將現有的符合條件的類遷移到記錄。

在QCon紐約大會題為Java Future的演講中,Java語言架構師Brian Goetz帶我們快速瀏覽了Java語言的一些近期和未來特性。在本系列的第一篇文章中,他探討了局部變量類型推斷。在這篇文章中,他深入討論了記錄。

Java SE 14(2020年3月)引入記錄(Record)(JEP359)作為預覽特性。記錄的目的是增強語言能力,簡化“純數據”聚合建模。我們可以像下面這樣聲明一個簡單的x-y點抽象,如下所示:

record Point(int x, int y) { }

它聲明一個final類Point,其中包含不可變組件x和y以及適當的訪問器、構造函數、equals、hashCode和toString實現。
我們都熟悉另一種方法——編寫(或使用IDE生成)構造函數、對象方法和訪問器的樣板文件來填充實現。

這些東西寫起來肯定很麻煩,但更重要的是,讀起來很費勁;我們必須通讀所有的樣板代碼才能得出結論,我們實際上根本不需要讀它。

記錄是什麼?

可以將記錄看作一個命名元組(nominal tuple)。它是一個面向特定有序元素序列的透明的淺不可變載體。狀態元素的名稱和類型在記錄頭中聲明,稱為狀態描述。命名意味著聚合及其組件都有名稱,而不僅僅是索引;透明意味著客戶端可訪問狀態(儘管實現可能需要為這種訪問提供中介);淺不可變意味著記錄所表示的值的元組在實例化後不會改變(但是,如果這些值是對可變對象的引用,那麼被引用對象的狀態可能會改變)。

像枚舉一樣,記錄是類的一種受限形式,針對某些常見情況進行了優化。枚舉為我們提供了各種各樣的便利;我們放棄了對實例化的控制,作為回報,我們獲得了某些語法和語義上的好處。然後,對於特定的情況,我們可以根據枚舉的收益是否大於成本來自由地選擇枚舉或普通的類。

記錄為我們提供了一個類似的交易;它們要求我們放棄的是將API與表示解耦的能力,這反過來又允許該語言從狀態描述機械地派生出用於構造、狀態訪問、相等比較和表示的API和實現。

將API綁定到表示似乎與面向對象的基本原則封裝相衝突。雖然封裝是管理複雜性的一種基本技術,而且大多數時候它都是正確的選擇,但有時我們的抽像是如此簡單——例如x-y點——以至於封裝的成本超過了收益。其中一些成本是顯而易見的——例如編寫一個簡單的域類所需的樣板文件。但是,還有另一個代價不太明顯:API元素之間的關係不是由語言捕獲的,而是由約定捕獲的。這削弱了對抽象進行機械推理的能力,進而導致樣板代碼更多。

從歷史上看,在Java中使用數據對象編程需要一個大的轉變。我們都熟悉下面的可變數據載體建模技術:

class AnInt {
    private int val;
    public AnInt(int val) { this.val = val; }
    public int getVal() { return val; }
    public void setVal(int val) { this.val = val; }
    // 针对equals, hashCode, toString方法的更多样板代码
}

在這個公共API中,val出現了三次——構造函數參數和兩個訪問器方法。除了命名約定,這段代碼就沒有其他東西了,要表達或要求這三個val都是指代同一個東西,或者getVal()將返回最近由setVal()設置的值——最好是在人類可讀的規範中表達(但在現實中,我們幾乎從來沒有這樣做)。與這樣的類交互需要一個大的轉變。

另一方面,記錄做出了更大的承諾——x()訪問器和x構造函數的參數指代的是同一個數量。因此,不僅編譯器能夠派生出這些成員的合理的默認實現,框架也可以機械地推斷出構造和狀態訪問協議(及其交互),從而機械地派生出行為,比如編組成JSON或XML。

要點

如前所述,記錄有一些限制。它們的實例的字段(對應於記錄頭中聲明的組件)是隱式final的;它們不能有任何其他實例字段;記錄類本身不能擴展其他類;記錄類是隱式final的。除此之外,它們可以擁有幾乎所有其他類可以擁有的東西:構造函數、方法、靜態字段、類型變量、接口等等。

作為這些限制的交換,記錄會自動獲得規範化構造函數的隱式實現(其簽名與狀態描述相匹配)、針對每個組件的讀取訪問器(名稱和組件相同)、每個狀態組件的私有final字段以及基於狀態的Object方法equals()、hashCode()和toString ()的實現。 (將來,當Java語言支持解構模式時,記錄也會自動支持該模式。 )如果隱式構造函數和方法聲明不合適,記錄的聲明可以“重寫”它們(儘管必須遵循​​隱式超類java.lang.Record中指定的約束),並可以聲明其他成員(受約束條件限制)。

一個例子是,記錄可能希望改進構造函數實現,驗證構造函數中的狀態。例如,在一個Range類中,我們想要檢查範圍的下限是否大於上限:

public record Range(int lo, int hi) {
    public Range(int lo, int hi) {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
        this.lo = lo;
        this.hi = hi;
    }
}

雖然這個實現非常好,但有些遺憾的是,為了執行一個簡單的不變量檢查,我們不得不把組件的名稱多用了五次。人們很容易就會想,開發人員說服自己不檢查這些不變量,因為他們不想添加太多記錄剛幫他們節省的樣板代碼。

因為這種情況很常見,有效性檢查也很重要,所以記錄允許使用一種特殊的緊湊形式來顯式地聲明規範化構造函數。在這種形式中,參數列表可以全部省略(假設它與狀態描述相同),構造函數的參數隱式地提交到構造函數末尾的記錄字段。 (構造函數參數本身是可變的,這意味著如果構造函數想要規範化狀態——例如將一個rational值降到最低——可以通過修改構造函數參數來實現。)以下是上述記錄聲明的精簡版本:

public record Range(int lo, int hi) {
    public Range {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
    }
}

這帶來了一個令人滿意的結果:我們只需要閱讀不能從狀態描述中推導出來的代碼。

應用場景舉例

雖然不是所有的類——甚至不是所有以數據為中心的類——都可以變成為記錄,但是記錄的應用場景很多。

Java經常需要的一個特性是多返回值——允許一個方法一次返回多個項;因為無法做到這一點,我們常常只能暴露次優的API。考慮一下,有兩個方法掃描一個集合併返回最小或最大值:

static T min(Iterable elements,
                Comparator comparator) { ... }
static T max(Iterable elements,
                Comparator comparator) { ... }

這些方法很容易編寫,但是有些地方不令人滿意;為了獲得兩個邊界值,我們必須掃描該列表兩次。這比只掃描一次的效率要低,而且如果被掃描的集合可以並發修改,還可能會產生不一致的結果。

雖然這可能就是作者想要暴露的API,但更有可能是我們得到的API,因為編寫更好的API工作量太大。具體來說,一次返回兩個邊界值意味著我們需要同時返回兩個值的方法。當然,我們可以通過聲明一個類來做到這一點,但是大多數開發人員會立即尋找避免這樣做的方法——純粹是因為聲明輔助類的語法開銷。通過降低描述自定義聚合的成本,我們可以很容易地將其轉換成我們可能想要的API:

record MinMax(T min, T max) { }
static MinMax minMax(Iterable elements,
                           Comparator comparator) { ... }

另一個常見的例子是組合映射鍵。有時,我們希望Map以兩個不同值的組合為鍵,例如表示給定用戶最後一次使用某個特性的時間。我們很容易通過HashMap實現這一點,它的鍵組合了人員和特性。但是,如果沒有一個方便的PersonAndFeature類型供我們使用,我們就必須編寫一個,包含所有構造、相等比較、散列等樣板代碼細節。同樣,我們可以做到這一點,但是我們的懶惰可能會成為障礙,例如,我們可能會被誘惑,將人的名字與特性的名字連接起來設置映射的鍵,這將導致更難於閱讀、更容易出錯的代碼。記錄讓我們可以直接這樣做:

record PersonAndFeature(Person p, Feature f) { }
Map lastUsed = new HashMap();

流處理通常會希望使用組合,就像映射鍵一樣——我們會遇到相同的意外問題,使我們實現次優的解決方案。例如,假設我們希望對派生量執行流操作,例如對得分最高的玩家進行排名。我們可以這樣寫:

List topN
        = players.stream()
             .sorted(Comparator.comparingInt(p -> getScore(p)))
             .limit(N)
             .collect(toList());

這夠簡單了,但是如果找到分數需要一些計算呢?我們需要計算O(n^2)次,而不是O(n)次。有了記錄,我們很容易臨時將一些派生數據附加到流的內容上,對聯合數據進行操作,然後將其投射成我們想要的東西:

record PlayerScore(Player player, Score score) {
    // convenience constructor for use by Stream::map
    PlayerScore(Player player) { this(player, getScore(player)); }
}
List topN
    = players.stream()
             .map(PlayerScore::new)
             .sorted(Comparator.comparingInt(PlayerScore::score))
             .limit(N)
             .map(PlayerScore::player)
             .collect(toList());

如果此邏輯位於方法內部,甚至可以將記錄聲明為該方法的局部記錄。

當然,還有許多其他常見的記錄用例:樹節點、數據傳輸對象(DTO)、actor系統中的消息等。

擁抱我們的懶惰

到目前為止,這些示例中的一個共同主題是,不需要記錄也可以得到正確的結果,但是由於語法開銷,我們很可能會走捷徑。我們都想不恰當地重用現有的抽象,而不是編寫正確的抽象代碼,或偷工減料省略對象方法的實現(當這些對像被用作映射鍵時可能導致微妙的錯誤,或當toString()值不能提供幫助時,調試變得更加困難)。

簡單來說,我們想要的東西給我們帶來了兩個好處。最明顯的一個是,受益於簡潔,代碼已經做了正確的事,但更準確地說,這也意味著我們將得到更多做正確的事的代碼——因為我們降低了做正確的事所需的活化能,因此減少了偷工減料的誘惑。我們在局部變量類型推斷中看到了類似的效果;當聲明變量的開銷減少時,開發人員更有可能將復雜的計算分解為更簡單的計算,從而得到可讀性更好、出錯更少的代碼。

未選之路

每個人都同意在Java中建模數據聚合——我們經常這麼做——太繁瑣。不幸的是,這種共識只是語法層面的;關於記錄應該有多大的靈活性、哪些限制是可以接受的、哪些用例是最重要的,意見分歧很廣泛(而且很大)。

一條重要的未選之路是設法擴展記錄來替換可變的JavaBean類。雖然這會帶來明顯的好處——具體地說,可以增加可能成為記錄的類的數目——但額外的成本也會很高。複雜,難以推斷的特別功能,更有可能以令人驚訝的方式與其他特性進行交互——如果我們試圖從如今常用的各種JavaBean模式推導出特性設計,這就是我們會得到的(更不用提關於哪些用例足夠普遍值得語言支持這樣的爭論了)。

因此,雖然從表面上看很容易認為記錄主要是關於樣板代碼簡化,但我們更願意把它當作一個語義問題來處理;我們如何才能直接在語言中更好地建模聚合模型,並為開發人員能夠輕鬆推斷這樣的類提供良好的語義基礎?(將其視為語義問題而非語法問題的方法對枚舉非常有效。)對於Java而言,符合邏輯的答案是:記錄是命名元組。

為什麼有這樣的限制?

對記錄的限制乍一看似乎有些武斷,但它們都源於一個共同的目標,我們可以將其概括為“記錄是狀態,整個狀態,除了狀態什麼都不是”。具體來說,我們希望記錄的相等性來自於狀態描述中聲明的整個狀態,而不是其他。可變字段,或額外的字段,或者父類允許的字段,所有這些都會帶來一些情況,使得記錄相等性的判斷忽略某些狀態組件(在相等計算中包含可變組件會有問題),或依賴於不屬於狀態描述組成部分的其他狀態(如額外的實例字段或超類狀態)。這將使這個特性大大復雜化(因為開發人員肯定會需要具體指定哪些組件是相等計算的一部分),並破壞期望的語義不變量(如從結果值提取狀態並構造一個新記錄會獲得一個與原來相等的記錄)。

為什麼不是結構化元組?

考慮到記錄設計的中心是命名元組,人們可能會問為什麼我們沒有選擇結構化元組。答案很簡單:名字很重要。帶有firstName和lastName組件的Person記錄比String和String組成的元組更清楚、更安全。類通過其構造函數支持狀態驗證;元組不能。類可以從其狀態派生出其他行為;元組不能。合適的類可以在不破壞客戶端代碼的情況下以兼容的方式遷移到記錄和從記錄遷移;元組不能。而且,結構化元組不能區分Point和Range(兩者都是整數對),即使它們具有完全不同的語義。 (我們曾經在Java語言中面臨過命名和結構化表示之間的選擇;在Java 8中,我們選擇命名函數類型而不是結構化函數類型,那有很多原因;而選擇命名元組而不是結構化元組時,有許多原因是相同的。)

未來展望

JEP 355將記錄列為一個獨立的特性,但是記錄的設計受到以下期望的影響:記錄應能夠與當前正在開發的其​​他幾個特性(密封類型、模式匹配和內聯類)很好地結合。

記錄是乘積類型的一種形式,之所以這麼說,是因為它們的狀態空間是其組件的狀態空間的笛卡爾積的子集,並且佔了通常所說的代數數據類型(algebraic data types)的一半。另一半稱為和類型;和類型是一個可區分的聯合,如“Shape是Circle或Rectangle”;我們目前在Java中還無法表達這樣的東西(除非通過非公共構造函數之類的技巧)。密封類型將解決這個限制,因此,類和接口可以直接聲明它們只能被一組固定的類型擴展。積和(Sums of products)是一種非常常見和有用的技術,用於以靈活但類型安全的方式(如復雜文檔的節點)對複雜域進行建模。

具有乘積類型的語言通常通過模式匹配支持乘積解構;記錄從設計伊始就支持輕鬆地解構(記錄的透明性要求部分源於此目標)。模式匹配的第一階段只支持類型模式,但是記錄上的解構模式很快就會實現。

最後,記錄通常(但不總是)與內聯類型類似;滿足記錄和內聯類型要求的聚合(很多都會)可以將記錄和內聯結合成內聯記錄。

小結

記錄提供了一種將數據建模為數據的直接方法,而不是使用類來模擬數據,從而減少了許多公共類的冗餘。記錄可以在各種情況下用於常見用例的建模,比如多返回值、流連接、組合鍵、樹節點、DTO等等,並提供更強的語義保證,允許開發人員和框架更可靠地推斷它們的狀態。雖然記錄本身很有用,但是它們還可以與一些即將出現的特性進行積極的交互,包括密封類型、模式匹配和內聯類。

作者簡介:

Brian Goetz是Oracle的Java語言架構師,也是JSR-335(面向Java編程語言的Lambda表達式)規範的負責人。他是暢銷書《Java並發編程實戰》的作者,自從Jimmy Carter擔任總統以來,就一直對編程著迷。

原文鏈接:

Java 14 Feature Spotlight: Records