9 レコード・クラス
特殊な種類のクラスであるレコード・クラスは、通常のクラスよりも少ない手間でプレーン・データ集計をモデル化するために役立ちます。
レコード・クラスに関する背景情報は、JEP 395を参照してください。
レコード宣言は、ヘッダー内でその内容の記述を指定します。適切なアクセサ、コンストラクタ、equals
、hashCode
およびtoString
メソッドが自動的に作成されます。クラスは単純な「データ・キャリア」として機能することを意図しているため、レコードのフィールドはfinalです。
たとえば、次の2つのフィールドを持つレコード・クラスがあります:
record Rectangle(double length, double width) { }
矩形のこの簡潔な宣言は、次の標準クラスと同等です:
public final class Rectangle {
private final double length;
private final double width;
public Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
double length() { return this.length; }
double width() { return this.width; }
// Implementation of equals() and hashCode(), which specify
// that two record objects are equal if they
// are of the same type and contain equal field values.
public boolean equals...
public int hashCode...
// An implementation of toString() that returns a string
// representation of all the record class's fields,
// including their names.
public String toString() {...}
}
レコード・クラス宣言は、名前、オプションの型パラメータ(汎用レコード宣言がサポートされる)、レコードのコンポーネントをリストするヘッダーおよび本文で構成されます。
レコード・クラスは、次のメンバーを自動的に宣言します:
- ヘッダーのコンポーネントごとに、次の2つのメンバーがあります:
- レコード・コンポーネントと同じ名前と宣言された型を持つ
private
final
フィールド。このフィールドは、コンポーネント・フィールドと呼ばれることもあります。 - 名前とコンポーネントのタイプが同じ
public
アクセッサ・メソッド。Rectangle
レコード・クラスの例では、これらのメソッドはRectangle::length()
およびRectangle::width()
です。
- レコード・コンポーネントと同じ名前と宣言された型を持つ
- 署名がヘッダーと同じである標準コンストラクタです。このコンストラクタは、レコード・クラスをインスタンス化する
new
式の各引数を対応するコンポーネント・フィールドに割り当てます。 equals
メソッドとhashCode
メソッドの実装。これらのメソッドは、2つのレコード・クラスのタイプが同じで、等しいコンポーネント値が含まれている場合、その2つが等しいことを指定します。toString()
メソッドの実装。すべてのレコード・クラスのコンポーネントとその名前の文字列表現が含まれます。
レコード・クラスは単に特殊な種類のクラスであるため、new
キーワードを使用してレコード・オブジェクト(レコード・クラスのインスタンス)を作成します。たとえば:
Rectangle r = new Rectangle(4,5);
レコードのコンポーネント・フィールドにアクセスするには、そのアクセサ・メソッドを呼び出します。
System.out.println("Length: " + r.length() + ", width: " + r.width());
この例の出力は次のとおりです。
Length: 4.0, width: 5.0
レコード・クラスの標準コンストラクタ
次の例では、Rectangle
レコード・クラスの標準コンストラクタを明示的に宣言します。length
およびwidth
がゼロより大きいことを検証します。そうでない場合は、IllegalArgumentExceptionをスローします:
record Rectangle(double length, double width) {
public Rectangle(double length, double width) {
if (length <= 0 || width <= 0) {
throw new java.lang.IllegalArgumentException(
String.format("Invalid dimensions: %f, %f", length, width));
}
this.length = length;
this.width = width;
}
}
標準コンストラクタのシグネチャでレコード・クラスのコンポーネントを繰り返すと、退屈でエラーが発生しやすくなります。これを回避するには、シグネチャが暗黙的な(コンポーネントから自動的に導出される)コンパクト・コンストラクタを宣言します。
たとえば、次のコンパクト・コンストラクタ宣言では、前の例と同じ方法で、length
およびwidth
を検証します:
record Rectangle(double length, double width) {
public Rectangle {
if (length <= 0 || width <= 0) {
throw new java.lang.IllegalArgumentException(
String.format("Invalid dimensions: %f, %f", length, width));
}
}
}
この簡潔な形式のコンストラクタ宣言は、レコード・クラスでのみ使用できます。標準コンストラクタに現れる文this.length = length;
およびthis.width = width;
は、コンパクト・コンストラクタには現れないことに注意してください。コンパクト・コンストラクタの最後に、その暗黙的仮パラメータが、そのコンポーネントに対応するレコード・クラスのprivateフィールドに割り当てられます。
代替レコード・コンストラクタ
引数リストがレコードの型パラメータと一致しない、代替の非標準コンストラクタを定義できます。ただし、これらのコンストラクタは、レコードの標準コンストラクタを呼び出す必要があります。次の例では、レコードRectanglePair
のコンストラクタに、1つのパラメータPair<Float>
が含まれています。暗黙的に定義された標準コンストラクタを呼び出して、フィールドlength
およびwidth
を初期化します:
record Pair<T extends Number>(T x, T y) { }
record RectanglePair(double length, double width) {
public RectanglePair(Pair<Double> corner) {
this(corner.x().doubleValue(), corner.y().doubleValue());
}
}
レコード・クラス・メンバーの明示的な宣言
レコード・クラスのコンポーネントに対応するpublic
アクセッサ・メソッドなど、ヘッダーから導出された任意のメンバーを明示的に宣言できます。たとえば:
record Rectangle(double length, double width) {
// Public accessor method
public double length() {
System.out.println("Length is " + length);
return length;
}
}
独自のアクセッサ・メソッドを実装する場合は、暗黙的に導出されるアクセッサと同じ特性を持っていることを確認します(たとえば、public
と宣言され、対応するレコード・クラス・コンポーネントと同じ戻り型を持っていること)。同様に、独自のバージョンのequals
、hashCode
およびtoString
メソッドを実装する場合は、すべてのレコード・クラスの共通スーパークラスであるjava.lang.Record
クラスの特性および動作と同じであることを確認します。
次の例のように、静的フィールド、静的イニシャライザおよび静的メソッドをレコード・クラスで宣言でき、これらは標準クラスの場合と同様に動作します。たとえば:
record Rectangle(double length, double width) {
// Static field
static double goldenRatio;
// Static initializer
static {
goldenRatio = (1 + Math.sqrt(5)) / 2;
}
// Static method
public static Rectangle createGoldenRectangle(double width) {
return new Rectangle(width, width * goldenRatio);
}
}
インスタンス変数(静的でないフィールド)またはインスタンス・イニシャライザは、レコード・クラスで宣言できません。
たとえば、次のレコード・クラス宣言はコンパイルされません:
record Rectangle(double length, double width) {
// Field declarations must be static:
BiFunction<Double, Double, Double> diagonal;
// Instance initializers are not allowed in records:
{
diagonal = (x, y) -> Math.sqrt(x*x + y*y);
}
}
独自のアクセッサ・メソッドを実装するかどうかに関係なく、レコード・クラスでインスタンス・メソッドを宣言できます。ネストされたクラスおよびインタフェースは、ネストされたレコード・クラス(暗黙的に静的)を含めて、レコード・クラスで宣言することもできます。たとえば:
record Rectangle(double length, double width) {
// Nested record class
record RotationAngle(double angle) {
public RotationAngle {
angle = Math.toRadians(angle);
}
}
// Public instance method
public Rectangle getRotatedRectangleBoundingBox(double angle) {
RotationAngle ra = new RotationAngle(angle);
double x = Math.abs(length * Math.cos(ra.angle())) +
Math.abs(width * Math.sin(ra.angle()));
double y = Math.abs(length * Math.sin(ra.angle())) +
Math.abs(width * Math.cos(ra.angle()));
return new Rectangle(x, y);
}
}
レコード・クラスではnative
メソッドを宣言できません。
レコード・クラスの機能
レコード・クラスは暗黙的にfinal
であるため、明示的に拡張できません。ただし、これらの制限事項を除けば、レコード・クラスは通常のクラスと同様に動作します:
-
汎用レコード・クラスを作成できます。たとえば:
record Triangle<C extends Coordinate> (C top, C left, C right) { }
-
1つ以上のインタフェースを実装するレコード・クラスを宣言できます。たとえば:
record Customer(...) implements Billable { }
-
レコード・クラスとその個々のコンポーネントに注釈を付けることができます。たとえば:
import java.lang.annotation.*; @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD) public @interface GreaterThanZero { }
record Rectangle( @GreaterThanZero double length, @GreaterThanZero double width) { }
レコード・コンポーネントに注釈を付けると、注釈がレコード・クラスのメンバーおよびコンストラクタに伝播される場合があります。この伝播は、注釈インタフェースが適用可能なコンテキストによって決まります。前述の例では、
@Target(ElementType.FIELD)
というメタ注釈は、@GreaterThanZero
という注釈がレコード・コンポーネントに対応するフィールドに伝播されることを意味します。したがって、このレコード・クラス宣言は、次の通常のクラス宣言と同等になります:public final class Rectangle { private final @GreaterThanZero double length; private final @GreaterThanZero double width; public Rectangle(double length, double width) { this.length = length; this.width = width; } double length() { return this.length; } double width() { return this.width; } }
レコード・クラス、シール・クラスおよびインタフェース
レコード・クラスは、シール・クラスおよびインタフェースを適切に処理します。例については、許可されたサブクラスとしてのレコード・クラスを参照してください。
ローカル・レコード・クラス
ローカル・レコード・クラスはローカル・クラスに似ていますが、メソッド本体で定義されたレコード・クラスです。
次の例では、業者はレコード・クラスMerchant
でモデル化されています。業者による販売も、レコード・クラスSale
を使用してモデル化されています。Merchant
とSale
はどちらも最上位のレコード・クラスです。業者とその月間売上合計の集計は、findTopMerchants
メソッド内で宣言されるローカル・レコード・クラスMonthlySales
を使用してモデル化されています。このローカル・レコード・クラスにより、後続のストリーム操作が読みやすくなります:
import java.time.*;
import java.util.*;
import java.util.stream.*;
record Merchant(String name) { }
record Sale(Merchant merchant, LocalDate date, double value) { }
public class MerchantExample {
List<Merchant> findTopMerchants(
List<Sale> sales, List<Merchant> merchants, int year, Month month) {
// Local record class
record MerchantSales(Merchant merchant, double sales) {}
return merchants.stream()
.map(merchant -> new MerchantSales(
merchant, this.computeSales(sales, merchant, year, month)))
.sorted((m1, m2) -> Double.compare(m2.sales(), m1.sales()))
.map(MerchantSales::merchant)
.collect(Collectors.toList());
}
double computeSales(List<Sale> sales, Merchant mt, int yr, Month mo) {
return sales.stream()
.filter(s -> s.merchant().name().equals(mt.name()) &&
s.date().getYear() == yr &&
s.date().getMonth() == mo)
.mapToDouble(s -> s.value())
.sum();
}
public static void main(String[] args) {
Merchant sneha = new Merchant("Sneha");
Merchant raj = new Merchant("Raj");
Merchant florence = new Merchant("Florence");
Merchant leo = new Merchant("Leo");
List<Merchant> merchantList = List.of(sneha, raj, florence, leo);
List<Sale> salesList = List.of(
new Sale(sneha, LocalDate.of(2020, Month.NOVEMBER, 13), 11034.20),
new Sale(raj, LocalDate.of(2020, Month.NOVEMBER, 20), 8234.23),
new Sale(florence, LocalDate.of(2020, Month.NOVEMBER, 19), 10003.67),
// ...
new Sale(leo, LocalDate.of(2020, Month.NOVEMBER, 4), 9645.34));
MerchantExample app = new MerchantExample();
List<Merchant> topMerchants =
app.findTopMerchants(salesList, merchantList, 2020, Month.NOVEMBER);
System.out.println("Top merchants: ");
topMerchants.stream().forEach(m -> System.out.println(m.name()));
}
}
ネストされたレコード・クラスと同様に、ローカル・レコード・クラスは暗黙的に静的です。つまり、静的でないローカル・クラスとは異なり、独自のメソッドは包含するメソッドの変数にアクセスできません。
内部クラスの静的メンバー
Java SE 16より前の場合、メンバーが定数変数でないかぎり、内部クラスで明示的または暗黙的に静的メンバーを宣言できませんでした。つまり、ネストされたレコード・クラスは暗黙的に静的であるため、内部クラスでレコード・クラス・メンバーは宣言できません。
Java SE 16以降では、内部クラスで、レコード・クラス・メンバーを含め、明示的または暗黙的に静的なメンバーを宣言できます。その方法を示すサンプル・コードを次に示します。
public class ContactList {
record Contact(String name, String number) { }
public static void main(String[] args) {
class Task implements Runnable {
// Record class member, implicitly static,
// declared in an inner class
Contact c;
public Task(Contact contact) {
c = contact;
}
public void run() {
System.out.println(c.name + ", " + c.number);
}
}
List<Contact> contacts = List.of(
new Contact("Sneha", "555-1234"),
new Contact("Raj", "555-2345"));
contacts.stream()
.forEach(cont -> new Thread(new Task(cont)).start());
}
}
レコードのシリアライズと通常のオブジェクトのシリアライズの違い
レコード・クラスのインスタンスはシリアライズおよびデシリアライズできますが、writeObject、readObject、readObjectNoData、writeExternalまたはreadExternalメソッドを提供してプロセスをカスタマイズすることはできません。レコード・クラスのコンポーネントはシリアライズを制御し、レコード・クラスの標準コンストラクタはデシリアライズを制御します。
レコードは、通常のシリアライズ可能オブジェクトとは異なる方法でシリアライズされます。レコード・オブジェクトの直列化形式は、レコード・コンポーネントから導出された値のシーケンスです。レコード・オブジェクトがデシリアライズされると、コンポーネント値がこの値シーケンスから再構築されます。その後、コンポーネント値を引数として指定し、レコードの標準コンストラクタを呼び出すことで、レコード・オブジェクトが作成されます。対照的に、通常のオブジェクトがデシリアライズされた場合は、そのコンストラクタのいずれかを呼び出さなくても、通常のオブジェクトを作成できます。したがって、シリアライズ可能なレコードは、レコード・クラスによって提供される保証を利用して、よりシンプルでセキュアなシリアライズ・モデルを提供します。
レコードのシリアライズの原則
レコードのシリアライズに関しては、2つの原則があります:
- レコード・オブジェクトのシリアライズは、その状態コンポーネントのみに基づきます。
- レコード・オブジェクトのデシリアライズでは、標準コンストラクタのみが使用されます。
最初のポイントの結果として、レコード・オブジェクトのシリアライズされた形式をカスタマイズすることはできません。シリアライズされた形式は、状態コンポーネントのみに基づきます。この制約により、レコードのシリアライズされた形式をプログラマが理解しやすくなります。シリアライズされた形式は、レコードの状態コンポーネントで構成されます。
2番目のポイントは、デシリアライズ・プロセスの仕組みに関連しています。デシリアライズによって、(レコード・クラスではなく)通常のクラスのオブジェクトのバイトを読み取るとします。デシリアライズでは、スーパークラスの引数なしのコンストラクタを呼び出すことで新しいオブジェクトを作成した後、リフレクションを使用してオブジェクトのフィールドをストリームからデシリアライズされた値に設定します。通常のクラスにはストリームから取得した値を検証する機会がないため、これは安全ではありません。その結果、通常のJavaプログラムがコンストラクタを使用して作成することはできない「不可能な」オブジェクトが生成される可能性があります。レコードを使用すると、デシリアライズの動作が変わります。デシリアライズでは、レコード・クラスの標準コンストラクタを呼び出すことで新しいレコード・オブジェクトを作成し、ストリームからデシリアライズされた値を標準コンストラクタに引数として渡します。この場合、通常のJavaプログラムがレコード・オブジェクトを新しく作成するときと同様に、レコード・クラスがフィールドに割り当てる前に値を検証できるため、安全です。「不可能な」オブジェクトが生成されることはありません。これが実現可能なのは、レコード・コンポーネント、標準コンストラクタおよびシリアライズされた形式がすべて既知で一貫性があるためです。
レコードのシリアライズの仕組み
通常のオブジェクトのシリアライズとレコードのシリアライズの違いを示すために、整数の範囲をモデル化する2つのクラス、RangeClass
とRangeRecord
を作成しましょう。RangeClass
は通常のクラスですが、RangeRecord
はレコードです。これらは、下限値のlo
と上限値のhi
を持ちます。また、Serializable
を実装します。これは、それぞれのインスタンスをシリアライズできるようにするためです。
public class RangeClass implements Serializable {
private static final long serialVersionUID = -3305276997530613807L;
private final int lo;
private final int hi;
public RangeClass(int lo, int hi) {
this.lo = lo;
this.hi = hi;
}
public int lo() { return lo; }
public int hi() { return hi; }
@Override public boolean equals(Object other) {
if (other instanceof RangeClass that
&& this.lo == that.lo && this.hi == that.hi) {
return true;
}
return false;
}
@Override public int hashCode() {
return Objects.hash(lo, hi);
}
@Override public String toString() {
return String.format("%s[lo=%d, hi=%d]", getClass().getName(), lo, hi);
}
}
equals
、hashCode
およびtoString
メソッドの詳細なボイラープレート・コードに注意してください。
次の例は、RangeClass
に対応する等価のレコードです。Serializable
を実装することで、レコード・クラスを通常のクラスと同じ方法でシリアライズ可能にします。
public record RangeRecord (int lo, int hi) implements Serializable { }
RangeRecord
をシリアライズ可能にするために、ボイラープレートを追加する必要はありません。具体的に言うと、レコード・クラスのserialVersionUID
は明示的に宣言しないかぎり0L
であり、serialVersionUID値の一致要件はレコード・クラスには適用されないため、serialVersionUID
フィールドを追加する必要はありません。
まれに、通常のクラスとレコード・クラスの間で移行の互換性を確保するためにserialVersionUID
を宣言することがあります。詳細は、Javaオブジェクト直列化仕様の5.6.2 互換性のある変更の項を参照してください。
次の例では、RangeClass
オブジェクトとRangeRecord
オブジェクトの両方を、同じ上限値と下限値でシリアライズします。
import java.io.*;
public class Serialize {
public static void main(String... args) throws Exception {
try (var fos = new FileOutputStream("serial.data");
var oos = new ObjectOutputStream(fos)) {
oos.writeObject(new RangeClass(100, 1));
oos.writeObject(new RangeRecord(100, 1));
}
}
}
import java.io.*;
public class Deserialize {
public static void main(String... args) throws Exception {
try (var fis = new FileInputStream("serial.data");
var ois = new ObjectInputStream(fis)) {
System.out.println(ois.readObject());
System.out.println(ois.readObject());
}
}
}
この2つの例を実行すると、次のように出力されます:
RangeClass[lo=100, hi=1]
RangeRecord[lo=100, hi=1]
これには問題があります。間違いに気付くことができましたか。下限値が上限値を超えています。これは許可されません。整数範囲の実装には、範囲の下限が上限を超えることはできないという不変条件が必要です。
RangeClass
とRangeRecord
を次のように変更して、この不変条件を追加しましょう:
public class RangeClass implements ... {
// ...
public RangeClass(int lo, int hi) {
if (lo > hi)
throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
this.lo = lo;
this.hi = hi;
}
// ..
}
public record RangeRecord (int lo, int hi) implements ... {
public RangeRecord {
if (lo > hi)
throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
}
}
RangeRecord
では、コンパクト版の標準コンストラクタ宣言が使用されるため、ボイラープレートの割当てを省略できます。
RangeClass
とRangeRecord
の例を更新した結果、Deserialize
の例では次のように出力されます:
RangeClass[lo=100, hi=1]
Exception in thread "main" java.io.InvalidObjectException: 100, 1
at java.base/java.io.ObjectInputStream.readRecord(ObjectInputStream.java:2296)
at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2183)
at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1685)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:499)
at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:457)
at Deserialize.main(Deserialize.java:9)
Caused by: java.lang.IllegalArgumentException: 100, 1
at RangeRecord.<init>(RangeRecord.java:6)
at java.base/java.io.ObjectInputStream.readRecord(ObjectInputStream.java:2294)
... 5 more
この例では、RangeClass
とRangeRecord
が下限値100と上限値1で作成されているにもかかわらず、serial.data
ファイル内のストリーム・オブジェクトをデシリアライズしようとします。
この例は、通常のクラスとレコード・クラスではデシリアライズの方法が異なることを示しています:
RangeClass
オブジェクトは、新しく作成されたオブジェクトがコンストラクタの不変条件に違反しているにもかかわらず、デシリアライズされました。最初はわかりにくく思えるかもしれませんが、前述のように、クラスが通常のクラスである(レコード・クラスではない)オブジェクトのデシリアライズでは、(最初の非シリアライズ可能)スーパークラスの引数なしのコンストラクタ(この場合はjava.lang.Object)を呼び出すことで、オブジェクトを作成します。もちろん、Serialize
の例では、RangeClass
オブジェクトに対してそのようなバイト・ストリームを生成することはできません。2つの引数を持つコンストラクタを不変条件のチェックとともに使用する必要があるためです。ただし、デシリアライズはバイトのストリームに対してのみ動作し、場合によってはこのバイトをほとんどどこからでも取得できることを覚えておいてください。- その一方で、
RangeRecord
ストリーム・オブジェクトは、下限と上限のストリーム・フィールド値がコンストラクタ内の不変条件チェックに違反するため、デシリアライズに失敗しました。これは、デシリアライズが標準コンストラクタを介して行われるという適切な意図した動作です。
シリアライズ可能クラスが、そのコンストラクタのいずれかを呼び出さずに作成された新しいオブジェクトを持つことができるという事実は、経験豊富な開発者でも見落としがちです。遠くにある引数なしのコンストラクタを呼び出すことで作成されたオブジェクトは、デシリアライズされたクラスのコンストラクタ内の不変条件チェックが実行されないため、実行時に予期しない動作につながる可能性があります。ただし、レコード・オブジェクトのデシリアライズを悪用して「不可能な」オブジェクトを作成することはできません。
詳細は、Javaオブジェクト直列化仕様の1.13 レコードの直列化の項を参照してください。
レコード・クラスに関連するAPI
abstract
クラスjava.lang.Recordは、すべてのレコード・クラスの共通スーパークラスです。
ソース・ファイルがjava.lang
以外のパッケージからRecordという名前のクラスをインポートすると、コンパイラ・エラーが発生する場合があります。Javaソース・ファイルは、暗黙的なimport java.lang.*;
文を介して、java.langパッケージ内のすべての型を自動的にインポートします。これには、プレビュー機能が有効か無効かに関係なく、java.lang.Recordクラスが含まれます。
com.myapp.Record
の次のクラス宣言を考えてみます:package com.myapp;
public class Record {
public String greeting;
public Record(String greeting) {
this.greeting = greeting;
}
}
次の例では、org.example.MyappPackageExample
はワイルドカードを使用してcom.myapp.Record
をインポートしますが、コンパイルは行いません:
package org.example;
import com.myapp.*;
public class MyappPackageExample {
public static void main(String[] args) {
Record r = new Record("Hello world!");
}
}
コンパイラは、次のようなエラーメッセージを生成します:
./org/example/MyappPackageExample.java:6: error: reference to Record is ambiguous
Record r = new Record("Hello world!");
^
both class com.myapp.Record in com.myapp and class java.lang.Record in java.lang match
./org/example/MyappPackageExample.java:6: error: reference to Record is ambiguous
Record r = new Record("Hello world!");
^
both class com.myapp.Record in com.myapp and class java.lang.Record in java.lang match
com.myapp
パッケージのRecord
とjava.lang
パッケージのRecord
は、ワイルドカードを使用してインポートされます。したがって、どちらのクラスも優先されず、コンパイラは単純な名前Record
の使用を検出するとエラーを生成します。
この例をコンパイルできるようにするには、Record
の完全修飾名をインポートするようにimport
文を変更します:
import com.myapp.Record;
ノート:
java.langパッケージでのクラスの導入はまれですが、Java SE 5ではEnum、Java SE 9ではModule、Java SE 14ではRecordなど、時々必要になります。クラスjava.lang.Classには、レコード・クラスに関連する次の2つのメソッドがあります:
- RecordComponent[] getRecordComponents(): レコード・クラスのコンポーネントに対応するjava.lang.reflect.RecordComponentオブジェクトの配列を返します。
- boolean isRecord(): クラスがレコード・クラスとして宣言された場合に
true
を返すことを除き、isEnum()
に似ています。