こんにちは、かつコーチです。
「値を保持するだけのクラス」を作るたびに、コンストラクタ・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に置き換えるか、独自にequals・hashCodeをオーバーライドする必要があります。
// ✅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レスポンスなど「保持するだけのデータ」に向いており、状態が変化するドメインオブジェクトには不向き
- 配列フィールドを持つ
recordはequals()が中身を比較しないため、Listなど参照比較にならない型を使う
次に読むべき記事
- equals・hashCodeの実装原則を詳しく知りたい方は「equals・hashCode・toStringの実装」の記事へ
- enumとの使い分けは「enumの使い方」の記事へ
タグ: Java, 中級者向け, オブジェクト指向