【Modern Java】Java16で正式リリースとなった Records (JEP 395: Records)

blog1.mammb.com


JEP 395: Records

Records は、不変データのキャリアとして機能するクラスで、名前付きのタプルとして扱えます。 JDK 14 で Preview、JDK 15 で Second Preview を経て JDK 16 で正式リリースとなりました。

x-y座標の不変なキャリアを考えた場合、以下のような多くのボイラープレートが必要でした。

class Point {
    private final int x;
    private final int y;

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

    public int x() { return x; }
    public int y() { return y; }

    public boolean equals(Object o) {
        if (!(o instanceof Point)) return false;
        Point other = (Point) o;
        return other.x == x && other.y == y;
    }

    public int hashCode() {
        return Objects.hash(x, y);
    }

    public String toString() {
        return String.format("Point[x=%d, y=%d]", x, y);
    }
}

上記はレコードクラスを使うことで以下のように宣言することができます。

record Point(int x, int y) { }

インスタンス化は普通のクラスと同様です。

var point = new Point(1, 2);


レコードクラス宣言

レコードクラスは以下のように宣言します。

[クラス修飾子] record レコードクラス名 [型パラメータ]
        (レコードコンポーネントリスト) [implements インターフェース] {
    レコードボディ定義
}

レコードコンポーネントリストとは、先程の Point の例で言う (int x, int y) になります。 int xint yレコードコンポーネントと呼びます。

レコードコンポーネントは、プライベートファイナルなフィールドとして定義され、コンポーネントと同じ型と名前のアクセサメソッドが使えるようになります。 int x の場合は public int x() というアクセサとなり、public int getX() という Java Beans 形式ではありません。

コンストラクタは、全てのレコードコンポーネント値を引数に取り、プライベートファイナルフィールドを初期化するものが自動定義されます。 通常のクラスで自動定義されるデフォルト・コンストラクタに対して、レコードクラスで自動定義されるコンストラクタはカノニカル・コンストラクタ(canonical constructor)と呼びます。 将来的には、パターンマッチングを可能にするデコンストラクション・パターンがサポートされることになるでしょう。

レコードクラスの宣言に extends 句は使用できません。全てのレコードクラスは、暗黙的に常にjava.lang.Record を継承したものとなります。 レコードクラスは暗黙のうちに final なクラスとなり継承することもできません。 一方、implements 句によりインターフェースの実装は自由にできます。

レコードコンポーネント値に即した equals hashCode toString メソッドが自動定義されます。


レコードクラスのルール

レコードクラスで出来ないこと
  • レコードクラスは、スーパークラスを明示的に指定することはできない
  • レコードクラスは、暗黙的に final であり継承できない
  • レコードクラスは、abstract とすることはできない
  • レコードクラスは、インスタンスフィールドを明示的に宣言することはできない
  • レコードクラスは、ネイティブメソッドを宣言できない
レコードクラスでできること
  • レコードクラスは、インターフェースを実装できる
  • レコードクラスは、トップレベルまたはネストして宣言することができる
  • レコードクラスがネストされた場合は暗黙のうちに static となる
  • レコードクラスは、ジェネリックにすることができる
  • レコードクラスとそのヘッダーのコンポーネントは、アノテーションで装飾することができる
  • レコードクラスは、インスタンスメソッドを宣言できる
    • レコードクラスにより自動定義されるメソッドをオーバーライドできる
    • アクセサ、equals または hashCode メソッドをオーバーライドする場合はレコードクラスのセマンティックな不変性を維持するのはプログラマの責任
  • レコードクラスは、スタティックメソッド、スタティックフィールド、およびスタティックイニシャライザを宣言できる
  • レコードクラスのインスタンスは、シリアル化およびデシリアル化することができる
    • ただし、writeObject、readObject、readObjectNoData、writeExternal、readExternal の各メソッドを用意して、処理をカスタマイズすることはできない


カノニカル・コンストラクタにパラメータ検証を追加する

コンストラクト時にパラメータ検証を行うには以下のようにできます。

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

通常のコンストラクタのように引数リストを記載することなく、コンパクトな形で定義します。this.lothis.hi とは記載しません。

何らかの前処理が必要なケースも同様に定義できます。

record Rational(int num, int denom) {
    Rational {
        int gcd = gcd(num, denom);
        num /= gcd;
        denom /= gcd;
    }
}

なお、this.num のように記載するとエラーとなります。


引数の異なるコンストラクタを定義する

異なるコンストラクタを定義することができます。

record Range(int lo, int hi) {
    Range(int lo) {
        this(lo, 0);
    }
}

コンストラクタからは、カノニカル・コンストラクタ this() でインスタンス化する必要があります。


ローカルレコードクラス

メソッド内部にローカルなレコードクラスを定義できます。

List<Merchant> findTopMerchants(List<Merchant> merchants, int month) {
    // Local record
    record MerchantSales(Merchant merchant, double sales) {}

    return merchants.stream()
        .map(merchant -> new MerchantSales(merchant, computeSales(merchant, month)))
        .sorted((m1, m2) -> Double.compare(m2.sales(), m1.sales()))
        .map(MerchantSales::merchant)
        .collect(toList());
}

ローカルレコードクラスは(入れ子のレコードクラスと同様に)暗黙のうちに static になります。 つまり、ローカルレコードクラスのメソッドは、(ローカルクラスとは対照的に)そのメソッドを囲んでいるメソッドの変数にアクセスすることはできません。


アノテーションの扱い

レコードクラスのコンポーネントがアノテートされている場合、アノテーションが適用されるすべての要素がアノテートされたものとして扱われます。

record Point(int x, int y) { } というレコードクラスがあった場合、int xint y といったレコードコンポーネントは以下の3つを定義するものとなります。

  • 同じ型と同じ名前のフィールド
  • 同じ型と同じ名前のアクセサメソッド
  • カノニカル・コンストラクタの同じ型と同じ名前のパラメータ

これらに応答する以下のアノテーションがあった場合を考えます。

@Target(ElementType.FIELD)
public @interface Ann1 {}

@Target({ElementType.METHOD})
public @interface Ann2 {}

@Target({ElementType.PARAMETER})
public @interface Ann3 {}

これらのアノテーションを付与したレコードクラスを以下のように定義した場合、

record Point3D(@Ann1 int x, @Ann2 int y, @Ann3 int z) { }

以下のようにアノテーションが付与されたものとして扱われます。

class Point3D {
    @Ann1
    private final int x;
    private final int y;
    private final int z;

    Point(int x, int y, @Ann3 int z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    public int x() { return x; }
    @Ann2
    public int y() { return y; }
    public int z() { return z; }

    // ...
}

以下のようなケースでは、フィールド、コンストラクタパラメータ、アクセサメソッド全てにアノテーションが付与される形となります。

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
public @interface Ann4 { }

まとめると以下となります。

  • レコードコンポーネントのアノテーションが ElementType.FIELD の場合、アノテーションは対応するプライベートフィールドに付与される
  • レコードコンポーネントのアノテーションが ElementType.METHOD の場合、アノテーションはアクセサメソッドに付与される
  • レコードコンポーネントのアノテーションが ElementType.PARAMETER の場合、カノニカル・コンストラクタの対応するパラメータに付与される
  • レコードコンポーネントのアノテーションが ElementType.TYPE の場合、アノテーションは以下全てに付与される
    • 対応するフィールドの型
    • 対応するアクセサメソッドの戻り値の型
    • 対応するコンストラクタのパラメータの型
    • レコードコンポーネントの型(リフレクションによって実行時にアクセス可能なもの)

レコードクラスの各メソッドをオーバーライド定義している場合は、アノテーションの伝搬は行われません。


Reflection API

java.lang.Class.RecordComponents にメソッドが2つ追加されています。

  • Point.class.isRecord() レコードクラスの場合 true
  • RecordComponent[] components = Point.class.getRecordComponents() レコードコンポーネントを取得
    • レコードの宣言に現れるのと同じ順番でレコードのコンポーネント定義を配列として取得

レコードコンポーネントに定義されたアノテーションは、ElementType.RECORD_COMPONENT として定義されたアノテーションのみが取得されます。