資料載體與 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
的值域,以及同名的公開方法,就上例來說,就是會生成 x
與 y
方法,傳回對應的值域:
var p = new Point(10, 20);
System.out.println(p.x());
System.out.println(p.y());
如果想要一個可記錄資料的資料載體,使用 record
類別來定義就非常方便,正因為 record
類別是作為資料載體,必須完全、公開地表現資料的結構,不能隱藏狀態,也就不能自行定義非靜態的值域,試圖自定義非靜態的值域會引發編譯錯誤。
定義 record
類別時,不能使用 hashCode
、equals
、toString
、wait
、notify
等作為欄位名稱,record
也不能被繼承,這些限制,後續談繼承之後還會說明。
record
類別的實例狀態不可變動,必須完全、公開地表現資料的結構,不能隱藏狀態(也不能被繼承),許多開發者初次接觸 record
類別時,總有種疑問,這不是破壞了物件導向的封裝概念嗎?
封裝的邊界
物件導向經常談到封裝,然而,封裝的對象或意圖其實是多元的,也許是想隱藏狀態、不曝露實作、遮蔽資料的結構、管理物件複雜的生命週期、隔離物件間的相依關係等,大部分情況下,封裝都意謂著某種程度的隱蔽性,藏起什麼東西之類的。
然而,作為一個資料載體時,只是單純地將某些資料組合為一個概念,例如將兩個小數組合為點的概念,這些資料在名稱、結構,以及聚合後的名稱(就數學上,資料的組成構成了一個集合,例如點的集合),都是透明的,物件在外觀表現上就是會曝露一切,白話來說,物件本身提供的 API,會與物件想表現的資料耦合在一起。
這使得資料載體的封裝,與其他的封裝意圖大相徑庭,因為就其他封裝的意圖來說,往往會希望物件本身提供的 API,能夠隱藏物件本身(內部)的資料等東西;資料載體本身的意圖就是曝露資料的一切。
有時候你就是需要一個物件來表示資料,這就表示你需要的是個資料載體,然而在 record
類別出現之前,無論是哪種封裝,基本上是透過 class
來定義,這就造成了過去簡單的需求,也需要囉嗦的定義過程,這不難想像,你可以試著只使用 class
來定義出方才的 Point
類別,要有公開的 x
、y
(以及 hashCode
、equals
以及 toString
等方法),應該是需要一定行數的程式碼吧!
record
類別是用來定義資料載體,限制為不可變動、公開資料結構(以及不能被繼承),就希望開發者在定義資料載體時,才使用 record
類別。
資料載體的設計
方才的 Point
定義了二維座標點,如果想定義一個三維的 Point
,建構座標點的實例時,只指定 x
與 y
時 z
就預設為 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) {
...一些與座標計算相關的方法
...一些要取得 x、y 資訊以進行圓相關運算的方法
}
record Square(double x, double y, double length) {
...一些與座標計算相關的方法
...一些要取得 x、y 資訊以進行正方形相關運算的方法
}
這時可以組合(composite)資料,也就是讓 x
、y
組成 Point
,然後 Point
與 radius
組成 Circle
,Point
與 length
組成 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
類別,若原本 Circle
與 Square
有些方法,必須使用 x
、y
進行運算,重構後可透過 Point
實例的 x
、y
方法取得資料。
有些開發者面對資料具有相同欄位時,可能會想使用繼承的方式來消弭重複,record
類別不能實現繼承的目的之一,也是希望開發者面對資料載體設計時,優先思考組合而不是繼承。