Java Record

Ref

相關知識與觀念來自於我在這幾篇文章的閱讀

Record 要解決什麼問題

在 JDK14 的 JEPS-359 的宣告中, 說明了 Record 這個關鍵字

It is a common complaint that "Java is too verbose" or has too much "ceremony". Some of the worst offenders are classes that are nothing more than plain "data carriers" that serve as simple aggregates.

其目的主要是為了解決傳統 Java Bean 或是 Value Object 而產生需花費大量力氣撰寫的 boilerplate, boilerplate 指的是諸如 class 內的 equals(), hashCode(), toString(), setter(), getter() 的這些枯燥重複的代碼, 雖然現代的 IDE 幾乎可以自動產生上述的對應程式碼, 但仍對閱讀上有一定的負荷.

Record Futures

RecordEnum 一樣, 是一種 Java 類型的宣告, 具有以下特質.

  1. Record 內的所有成員都是 final 的狀態, 也就是在建構階段, 所有的成員狀態就會被定下來, 為不可變 (immutable) 的狀態, 在非同步的資料處理上, 確保一定的線程安全.

  2. 所有成員都具備 public 可被外部讀取的方法 (下面範例會簡單說明).

  3. Record 本身具有隱式 final 特性, 不可被繼承擴展, 也不能被宣告為 abstract.

  4. Record 本身會自動實現 equals()hashCode(), 若成員的 status 皆相同則其 equals() 且有相同的 hashCode(), 跟一般物件會參照 Reference equals() 是不一樣的, 要特別注意.

  5. 若要在 Record 定義宣告建構以外的成員, 則必須是 static 狀態.

  6. Record 可以像 class 一樣可以實作 interface, 也能透過 new 來實例化.

Demo

Record 為 JDK14 之後的特性, 而我採用 JDK17 作為示範, 主要是因為 JDK17 為 LTS 的版本. 範例裡, 會定義 Point 這個 Record, 用來做 2D 座標資料使用, 滿符合 Record 的使用情境.

Compact canonical constructor

範例的 Record 會使用 Compact canonical constructor 的寫法, 所以會先解釋這部分.

傳統的 Constructor 會像是這樣, 但因為 Record 的特性, 所有成員都是 final 的狀態必須有初始狀態 (如果是 js 的話, 就是 const 這個關鍵字).

record Point(int x) {
  Point (int x) {
    if (x < 0) {
      x = 0;
    }
    this.x = x;
  }
}

傳統的方式會透過 this 去重新把外部參數設定到 Point 的 Consturtor, 而 Java 為了要盡量適應現代的寫法, 於是讓 Constructor 可以更精簡. 讓開發者可以更專注於邏輯上, 且省去枯燥乏味的 this.x = x; 這種指定, 可以寫成以下.

record Point(int x) {

  // 省去了建構子上的參數括弧
  Point {
    if (x < 0) {
      x = 0;
    }
    // 省去了將參數指定到本身
    // this.x = x;
  }
}

Point Record

隨便寫個 x, y 座標不可以大於 (100, 100) 的檢核. 然後採用 Compact canonical constructor. 所以只會看到檢核.

record Point(int x, int y) {
  // Compact canonical constructor
  Point {
    // Simple validation
    if (x > 100 || y > 100) {
      throw new IllegalStateException("x, y must be less than 100");
    }
  }

  int sum() {
    return x + y;
  }
}

簡單的使用 AssertJ 測試一下

@Test
void test() {
  final Point point = new Point(0, 0);

  Assertions.assertThat(point).isInstanceOf(Point.class);
  Assertions.assertThat(point.getClass().isRecord()).isTrue();  // true
  Assertions.assertThat(point.x()).isZero();
  Assertions.assertThat(point.y()).isZero();
  Assertions.assertThat(point.sum()).isZero();

  final Point another = new Point(0, 0);
  Assertions.assertThat(point).isEqualTo(another); // true

  System.out.println(point);  // Point[x=0, y=0]
}

使用 Tuples?

Java 本身其實也能實現像 python Tuples 這樣的資料結構. 寫起來可能會像是這樣的 Generic Type 結構.

class Tuples<First, Second, Third> {
  private First first;
  private Second second;
  private Third third;
}

用起來就會長這樣


final Tuples<First, Second, Third> tuples = new Tuples<>();

這就有一個很明顯的缺點, 就是當要處理的欄位越多, 你就要寫越多種 Generic type(Fourths, Fives, ...), 這時候我就很羨慕 Python. 且在 JEPS359 裡面就提到幾個 Tuples 的小缺點, 可能不是那麼符合 Java 的精神.

Equals 與 hashCode

Record 在 Equals 與 HashCode 的處理, 在許多情境下 class 本身覆寫 equals()hashCode() 是很重要的, 在一般情境下 equals()hashCode() 需要參照物件內的成員做覆寫, 這時使用 Record 是很有優勢的, 不需要因為新增一個屬性, 而忘記改了 equals()hashCode() 內的邏輯.

而 Record 的 equals() 還有些不同, 在大多數情況下在比對 java 物件時, 同時會比對 instance 的記憶體位置, 舉例來說我們可以很明確的知道這個案例是 Non-Equals.

class Point {
    int x;
    int y;

    public Point(int x, int y) {
      this.x = x;
      this.y = y;
    }
}

@Test
void notEqual() {
  var p1 = new Point(1, 1);
  var p2 = new Point(1, 1);

  assertNotEquals(p1, p2);
  assertNotEquals(p1.hashCode(), p2.hashCode());
}

而 Record 會是 Equals. 因此 Record 更適合用來作為純粹的資料載體的設計.

record Point(int x, int y) {}

@Test
void beEqual() {
  var p1 = new Point(1, 1);
  var p2 = new Point(1, 1);

  assertEquals(p1, p2);
  assertEquals(p1.hashCode(), p2.hashCode());
}

Set 集合

因為 Record 的 equals() 特性關係, 在使用 Set 集合上需特別注意.

record Point(int x, int y) {}

@Test
void recordSet() {
  var p1 = new Point(1, 1);
  var p2 = new Point(1, 1);

  // duplicate element 
  var immutableSet = Set.of(p1, p2);
  assertEquals(1, immutableSet.size());
}

例子中的 Set.of() 因為 equals() 的判斷在處理 Record 集合時, 會遇到 duplicate element 的錯誤.

Summary

會開始看 Record 主要是因為 DDD 設計裡面的 Value Object 很適合用 Record 這樣的類型來定義, Record 確實簡化了不少開發作業, 且也具備更好的語意與可讀性. 而 Lombok 雖然有支援像是 @Setter(AccessLevel.NONE) 或是 @Value 這樣的功能, 且也能彈性的加入自己想要的一般 class 的成員宣告或是被繼承, 相較之下 Lombok 彈性了不少, 兩者各有其優勢. 若要認真區分,

  • 我會在單純的 Getter / Immutable 物件設計上採用 Record,

  • 在 JPA Entity 上我可能不會採用 Record, 因為 entity 本質上並非 Immutable, 且在某些情境下關聯集合可能會遭遇 duplicate element.

  • 但若是考量到物件抽象繼承, 我還是會採用 Lombok.