資料載體與 record

May 30, 2022

在〈定義類別〉看過 record 的基本使用,現代許多軟體之間,有諸多資料交換的需求,有時候你就是需要一個物件來表示資料,物件的型態名稱代表資料類型,物件的值域對應資料的欄位。

使用 record

例如,你可能從某網站,接收到一筆純文字資料 {x: 10, y: 20},代表著二維座標系統上的點,這時若想定義 Point 類別來代表,可以使用 record 類別:

public record Point(double x, double y) {}

Point 定義了二維座標系統上的點,依序記錄了 x 與 y 座標,編譯器預設會以指定的欄位名稱產生標準建構式(canonical constructor):

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

new Point(10, 20) 僅僅代表座標 (10, 20),因為該物件無法變動狀態,編譯器會以指定的欄位名稱,生成 private final 的值域,以及同名的公開方法,就上例來說,就是會生成 xy 方法,傳回對應的值域:

var p = new Point(10, 20);
System.out.println(p.x());
System.out.println(p.y());

如果想要一個可記錄資料的資料載體,使用 record 類別來定義就非常方便,正因為 record 類別是作為資料載體,必須完全、公開地表現資料的結構,不能隱藏狀態,也就不能自行定義非靜態的值域,試圖自定義非靜態的值域會引發編譯錯誤。

定義 record 類別時,不能使用 hashCodeequalstoStringwaitnotify 等作為欄位名稱,record 也不能被繼承,這些限制,後續談繼承之後還會說明。

record 類別的實例狀態不可變動,必須完全、公開地表現資料的結構,不能隱藏狀態(也不能被繼承),許多開發者初次接觸 record 類別時,總有種疑問,這不是破壞了物件導向的封裝概念嗎?

封裝的邊界

物件導向經常談到封裝,然而,封裝的對象或意圖其實是多元的,也許是想隱藏狀態、不曝露實作、遮蔽資料的結構、管理物件複雜的生命週期、隔離物件間的相依關係等,大部分情況下,封裝都意謂著某種程度的隱蔽性,藏起什麼東西之類的。

然而,作為一個資料載體時,只是單純地將某些資料組合為一個概念,例如將兩個小數組合為點的概念,這些資料在名稱、結構,以及聚合後的名稱(就數學上,資料的組成構成了一個集合,例如點的集合),都是透明的,物件在外觀表現上就是會曝露一切,白話來說,物件本身提供的 API,會與物件想表現的資料耦合在一起。

這使得資料載體的封裝,與其他的封裝意圖大相徑庭,因為就其他封裝的意圖來說,往往會希望物件本身提供的 API,能夠隱藏物件本身(內部)的資料等東西;資料載體本身的意圖就是曝露資料的一切。

有時候你就是需要一個物件來表示資料,這就表示你需要的是個資料載體,然而在 record 類別出現之前,無論是哪種封裝,基本上是透過 class 來定義,這就造成了過去簡單的需求,也需要囉嗦的定義過程,這不難想像,你可以試著只使用 class 來定義出方才的 Point 類別,要有公開的 xy(以及 hashCodeequals 以及 toString 等方法),應該是需要一定行數的程式碼吧!

record 類別是用來定義資料載體,限制為不可變動、公開資料結構(以及不能被繼承),就希望開發者在定義資料載體時,才使用 record 類別。

資料載體的設計

方才的 Point 定義了二維座標點,如果想定義一個三維的 Point,建構座標點的實例時,只指定 xyz 就預設為 0 呢?可以自定義建構式:

public record Point(double x, double y, double z) {
    public Point(double x, double y) {
        this(x, y, 0.0); 
    }
}

自定義建構式的限制是,一定要以 this() 呼叫某個建構式,而建構式的呼叫鏈,最後呼叫了標準建構式,這是為了確保資料的完整性。

如果想針對標準建構式傳入的資料做點處理呢?例如,檢查使用者名稱不得為 null,然後轉為小寫呢?可以自定義標準建構式:

public record User(String name, int age) {
    public User(String name, int age) {
        Objects.requireNonNull(name);
        this.name = name.toLowerCase();
        this.age = age;
    }
}

如果你自定義標準建構式,因為資料每個欄位對應的值域都是 private final,建構式中就必須明確地設值,不過 record 欄位定義與建構式的參數重複了,其實你可以定義精簡建構式(compact constructor):

public record User(String name, int age) {
    public User {
        Objects.requireNonNull(name);
        name = name.toLowerCase();
    }
}

精簡建構式的內容,會被安插至編譯器產生的標準建構式開頭,也就是說以上相當於:

public record User(String name, int age) {
    public User(String name, int age) {
        Objects.requireNonNull(name);
        name = name.toLowerCase();
        this.name = name;
        this.age = age;
    }
}

編譯器會為 record 類別自動生成與值域名稱對應的方法,你也可以自行定義其他方法,不過通常自定義方法,是為了資料間的計算、轉換等,例如點的位移、點與點間距離計算、轉換資料格式等:

import static java.lang.Math.pow;
import static java.lang.Math.sqrt;

public record Point(double x, double y) {
    public Point translated(double x, double y) {
        return new Point(this.x + x, this.y + y);
    }
    
    public double distance(Point p) {
        return sqrt(pow(this.x - p.x, 2) +  pow(this.y - p.y, 2));
    }

    public String toJSON() {
        return """
               {
                   "x": %f,
                   "y": %f
               } 
               """.formatted(this.x, this.y);
    }
}

因為靜態成員基本上只是以類別名稱作為名稱空間,與實例的狀態無關,record 類別可以定義靜態成員,例如,定義一個 fromJSON 靜態方法:

import java.util.regex.Pattern;

public record Point(double x, double y) { 
    private static Pattern regex = Pattern.compile(
        """
        "x":(?<x>\\d+\\.?\\d*),"y":(?<y>\\d+\\.?\\d*)"""
    );
    
    public static Point fromJSON(String json) {
        var matcher = regex.matcher(json.replaceAll("\\s+",""));
        if(matcher.find()) {
            return new Point(
                Double.parseDouble(matcher.group("x")),
                Double.parseDouble(matcher.group("y"))
            );
        }
        throw new IllegalArgumentException("cannot parse json");
    }
}

如果資料具有相同的欄位該怎麼辦呢?例如:

record Circle(double x, double y, double radius) {
    ...一些與座標計算相關的方法
    ...一些要取得 xy 資訊以進行圓相關運算的方法
}

record Square(double x, double y, double length) {
    ...一些與座標計算相關的方法
    ...一些要取得 xy 資訊以進行正方形相關運算的方法
}

這時可以組合(composite)資料,也就是讓 xy 組成 Point,然後 Pointradius 組成 CirclePointlength 組成 Square

record Point(double x, double y) {
    ...一些與座標計算相關的方法
}

record Circle(Point center, double radius) {
    ...一些要透過 Point 的 x()y() 資訊以進行圓相關運算的方法
}

record Square(Point center, double length) {
    ...一些要透過 Point 的 x()y() 資訊以進行正方形相關運算的方法
}

與座標計算相關的方法,可以抽取至 Point 類別,若原本 CircleSquare 有些方法,必須使用 xy 進行運算,重構後可透過 Point 實例的 xy 方法取得資料。

有些開發者面對資料具有相同欄位時,可能會想使用繼承的方式來消弭重複,record 類別不能實現繼承的目的之一,也是希望開發者面對資料載體設計時,優先思考組合而不是繼承。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter