【Java】Java21のrecordでシンプルにデータを表現する使い方

Java

こんにちは、かつコーチです。
「値を保持するだけのクラス」を作るたびに、コンストラクタ・getter・equals・hashCode・toStringを書くのに疲れたことはありませんか。
今回はJava 21(LTS)でも標準機能として使えるrecordを使って、このお決まりのコードを大幅に減らす方法を解説します。

recordとは?お決まりのコードを消す仕組み

通常のクラスで書いた場合の量

例えば「座標」を表すクラスを、通常のクラスで書くとこうなります。

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

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

    public int getX() {
        return x;
    }

    public int getY() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Point)) return false;
        Point point = (Point) o;
        return x == point.x && y == point.y;
    }

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

    @Override
    public String toString() {
        return "Point[x=" + x + ", y=" + y + "]";
    }
}

データを2つ保持するだけなのに30行近く書くことになります。

recordで書き直す

record(不変なデータを保持するために作られた特別なクラス)を使うと、同じ内容がこれだけで済みます。

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

これだけで以下がすべて自動生成されます。

  • 各フィールド(x, y
  • 全フィールドを受け取るコンストラクタ
  • 各フィールドへのアクセサ(x(), y()。getXではなくxという名前になる点に注意)
  • equals(), hashCode(), toString()
Point p1 = new Point(3, 5);
Point p2 = new Point(3, 5);

System.out.println(p1); // Point[x=3, y=5]
System.out.println(p1.x()); // 3
System.out.println(p1.equals(p2)); // true

recordの制約と設計思想

recordはなぜ不変なのか

recordのフィールドは自動的にfinalになり、再代入できません。
これは意図的な設計です。
recordは「データを保持するだけの、変更されないオブジェクト」を表現するために作られており、不変オブジェクト(一度生成したら状態を変更できないオブジェクト)を簡潔に書くための機能だからです。

そのため、recordには以下の制約があります。

  • 追加のインスタンスフィールドを持てない(コンポーネントとして宣言したフィールドのみ)
  • 他のクラスを継承できない(暗黙的にjava.lang.Recordを継承している)
  • ただしinterfaceの実装は可能
public interface Shape {
    double area();
}

public record Circle(double radius) implements Shape {
    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

コンパクトコンストラクタでバリデーションを入れる

「不変だから何も処理を挟めない」わけではありません。
コンパクトコンストラクタ(引数リストを省略して書ける、record専用の簡略コンストラクタ)を使うと、値の検証や正規化を挟めます。

public record Point(int x, int y) {
    public Point {
        if (x < 0 || y < 0) {
            throw new IllegalArgumentException("座標は0以上である必要があります: x=" + x + ", y=" + y);
        }
    }
}

通常のコンストラクタと違い、this.x = x;のような代入文を書く必要がありません。
コンパイラが検証処理の後に自動でフィールド代入を行ってくれます。

recordを使うべき場面・避けるべき場面

DTOやAPIレスポンスの表現に向いている

recordが特に効果を発揮するのは、DTO(データ転送オブジェクト)やAPIレスポンスのように「受け取ったデータをそのまま保持する」用途です。

public record UserResponse(long id, String name, String email) {
}

Jacksonなどのライブラリもrecordのシリアライズ・デシリアライズに対応しているため、モダンなJavaのWeb開発では標準的な選択肢になりつつあります。

ミュータブルな状態を持つドメインオブジェクトには不向き

一方で、状態が時間とともに変化するドメインオブジェクト(注文の進捗管理など、フィールドを更新しながら使うオブジェクト)にはrecordは向いていません。
不変性が前提のため、状態変更のたびに新しいインスタンスを作る必要があり、かえって冗長になることがあります。

// ✅新しいインスタンスを作って更新を表現する
public record OrderStatus(String orderId, String status) {
}

OrderStatus updated = new OrderStatus(current.orderId(), "SHIPPED");

このパターンは関数型的な設計と相性が良い一方、Getter/Setterでフィールドを直接更新する従来のスタイルに慣れていると、最初は違和感があります。

つまずいたポイント:equalsの挙動を勘違いしていた

配列フィールドを持つrecordのequals

recordのフィールドに配列を使ったとき、equals()が期待通りに動かず戸惑ったことがあります。

public record Data(int[] values) {
}

Data d1 = new Data(new int[]{1, 2, 3});
Data d2 = new Data(new int[]{1, 2, 3});

System.out.println(d1.equals(d2)); // false(想定外だった)

原因は、recordが自動生成するequals()は各フィールドに対して単純に==またはObjects.equals()を使うだけで、配列の中身までは比較しないためでした。
配列は参照型なので、中身が同じでも別インスタンスならObjects.equals()falseを返します。
配列を扱う場合はListに置き換えるか、独自にequalshashCodeをオーバーライドする必要があります。

// ✅After:配列の代わりにListを使う
public record Data(List<Integer> values) {
}

Data d1 = new Data(List.of(1, 2, 3));
Data d2 = new Data(List.of(1, 2, 3));

System.out.println(d1.equals(d2)); // true

まとめ

この記事のポイント

  • recordはコンストラクタ・アクセサ・equals・hashCode・toStringを自動生成し、Java 21でも標準機能として使える
  • フィールドは自動的にfinalになり、不変オブジェクトを表現するために設計されている
  • コンパクトコンストラクタでバリデーションや正規化を挟める
  • DTOやAPIレスポンスなど「保持するだけのデータ」に向いており、状態が変化するドメインオブジェクトには不向き
  • 配列フィールドを持つrecordequals()が中身を比較しないため、Listなど参照比較にならない型を使う

次に読むべき記事

  • equals・hashCodeの実装原則を詳しく知りたい方は「equals・hashCode・toStringの実装」の記事へ
  • enumとの使い分けは「enumの使い方」の記事へ

タグ: Java, 中級者向け, オブジェクト指向

タイトルとURLをコピーしました