Javaのrecordってなに?普通のclassと比べながら理解する

こんにちは、さるまりんです。

Javaのコードを見ていると、

public record Animal(String name, int age) {
}

のような書き方を見かけることがあります。

classではなく、recordと書かれています。

とても短いですが、これは何をしているのでしょうか。

古いJavaと仲良しな僕は、このrecordが何なのか知りませんでした。

今回は、普通のclassと比べながら、Javaのrecordについて見ていきます。

単に短く書く方法としてではなく、

このクラスは何を表そうとしているのか

というところまで、一緒に考えてみたいと思います。

ちなみに、recordもJavaにおける特別な種類のクラスです。


recordはJava 16から正式に使える機能

recordは、Java 14でプレビュー機能として登場し、Java 16で正式な機能になりました。

そのため、Java 16以降であれば、プレビュー機能を有効にしなくても使えます。

ここでは、Java 17以降の環境を想定して進めます。


まずは普通のclassで書いてみます

動物の名前と年齢を持つAnimalクラスを作ってみます。

public final class Animal {

    private final String name;
    private final int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

Animalは、次の二つの値を持っています。

  • 名前を表すname
  • 年齢を表すage

どちらにもfinalを付けているため、コンストラクタで値を入れたあと、別の値を代入することはできません。

値を取り出すために、getName()getAge()も用意しています。

これだけでも、ある程度まとまった量のコードになりました。

さらに、二つのAnimalが同じ値を持っているか比べたい場合は、equals()hashCode()も必要になります。

内容をわかりやすく表示したい場合は、toString()も書くことになるかもしれません。

import java.util.Objects;

public final class Animal {

    private final String name;
    private final int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    @Override
    public boolean equals(Object object) {
        if (this == object) {
            return true;
        }

        if (!(object instanceof Animal animal)) {
            return false;
        }

        return age == animal.age
                && Objects.equals(name, animal.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }

    @Override
    public String toString() {
        return "Animal[name=" + name + ", age=" + age + "]";
    }
}

僕にとっては見慣れたコードです。

ただ、Animalが名前と年齢を持つデータであることを表すために、かなり多くのコードを書いています。


recordで書いてみます

先ほどのAnimalを、recordで書いてみます。

public record Animal(String name, int age) {
}

これだけです。

nameageは、レコード・コンポーネントと呼ばれます。

この宣言から、Javaは主に次のものを用意してくれます。

  • nameageに対応するprivate finalフィールド
  • 二つの値を受け取るコンストラクタ
  • 値を取り出すアクセサ
  • equals()
  • hashCode()
  • toString()

見た目は短いですが、中身のないクラスというわけではありません。

Animalというデータが、

  • String型のname
  • int型のage

からできていることを宣言しています。

え、これだけ?!ってなりましたよ。

でも、データを持つクラスで定型的に用意するものは、これで準備できるんです。


実際に動かしてみましょう

Animal.javaを作ります。

public record Animal(String name, int age) {
}

続いて、Main.javaを作ります。

public class Main {

    public static void main(String[] args) {
        Animal gorilla = new Animal("ゴリラ", 8);

        System.out.println(gorilla.name());
        System.out.println(gorilla.age());
        System.out.println(gorilla);
    }
}

コンパイルして実行します。

javac Animal.java Main.java
java Main

次のように表示されます。

ゴリラ
8
Animal[name=ゴリラ, age=8]

toString()を自分で書いていませんが、Animalの内容が表示されました。


getterとは少し名前が違います

普通のJavaBeans形式のクラスでは、値を取り出すメソッドを次のように書くことがあります。

animal.getName();
animal.getAge();

recordが自動的に用意するアクセサは、getName()getAge()ではありません。

animal.name();
animal.age();

レコード・コンポーネントと同じ名前のメソッドになります。

最初は少し不思議に見えるかもしれません。

recordの宣言を見たときは、

public record Animal(String name, int age) {
}

name()age()で値を取り出せる、と読むことができます。


同じ値を持つrecordを比べてみる

recordでは、equals()hashCode()も自動的に用意されます。

public class Main {

    public static void main(String[] args) {
        Animal first = new Animal("ゴリラ", 8);
        Animal second = new Animal("ゴリラ", 8);
        Animal third = new Animal("ライオン", 5);

        System.out.println(first.equals(second));
        System.out.println(first.equals(third));
    }
}

実行結果です。

true
false

firstsecondは、別々に作ったオブジェクトです。

それでも、同じAnimal型で、nameageの値が同じなので、equals()trueになります。

recordでは、オブジェクトが同じ一つのものかどうかではなく、レコード・コンポーネントの値を使って比較できます。

データを表すクラスとして使いやすい理由の一つです。


recordの値はあとから変更できません

recordのコンポーネントに対応するフィールドは、finalになります。

そのため、オブジェクトを作ったあとで値を代入し直すことはできません。

たとえば、次のようなsetterは自動的に作られません。

animal.setName("ライオン");

また、recordの中で次のようなメソッドを書くこともできません。

public record Animal(String name, int age) {

    public void changeName(String newName) {
        this.name = newName;
    }
}

namefinalなので、再代入しようとするとコンパイルエラーになります。

名前や年齢の異なる動物が必要になった場合は、新しいAnimalを作ります。

Animal gorilla = new Animal("ゴリラ", 8);
Animal olderGorilla = new Animal(gorilla.name(), 9);

recordは、内部の状態を少しずつ変えながら使うというより、

ある時点の値を、ひとまとまりのデータとして表す

ことに向いています。


メソッドを書くこともできます

recordは、値を持つだけしかできないわけではありません。

普通のクラスと同じように、メソッドを書くこともできます。

public record Animal(String name, int age) {

    public String introduction() {
        return name + "は" + age + "歳です。";
    }
}

呼び出してみます。

public class Main {

    public static void main(String[] args) {
        Animal gorilla = new Animal("ゴリラ", 8);

        System.out.println(gorilla.introduction());
    }
}

実行結果です。

ゴリラは8歳です。

recordだから、処理を書いてはいけないということではありません。

ただし、そのrecordが表しているデータと関係の深い処理を置くと、役割がわかりやすくなりそうです。


作るときに値をチェックしましょう

年齢に負の数が入るのは、不自然ですよね。

recordでは、コンパクト・コンストラクタを使って、受け取った値を確認できます。

public record Animal(String name, int age) {

    public Animal {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException(
                    "名前を入力してください"
            );
        }

        if (age < 0) {
            throw new IllegalArgumentException(
                    "年齢は0以上にしてください"
            );
        }
    }
}

通常のコンストラクタとは少し形が違います。

public Animal {
}

引数の一覧を書いていません。

それでも、recordの宣言にあるnameageを使えます。

Animal animal = new Animal("", -1);

不正な値で作ろうとすると、例外が発生します。

recordを使うことで、自動的に正しいデータになるわけではありません。

どのような値を許すのかは、必要に応じて自分たちで決める必要があります。


recordは完全に変更不能とは限りません

ここはちょっと注意が必要です。

recordのフィールドはfinalです。

しかし、フィールドが参照しているオブジェクトの中身まで、自動的に変更不能になるわけではありません。

動物の好きな食べ物を、Listで持たせてみます。

import java.util.List;

public record Animal(
        String name,
        List<String> favoriteFoods
) {
}

次のように使います。

import java.util.ArrayList;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        List<String> foods = new ArrayList<>();
        foods.add("バナナ");

        Animal gorilla = new Animal("ゴリラ", foods);

        foods.add("リンゴ");

        System.out.println(gorilla.favoriteFoods());
    }
}

実行結果です。

[バナナ, リンゴ]

recordを作ったあとに、元のfoodsへリンゴを追加しました。

すると、recordが持っているリストの内容も変わりました。

recordのfavoriteFoodsフィールドへ、別のリストを再代入することはできません。

しかし、参照しているリストそのものは変更できます。

この性質は、浅い不変性と呼ばれます。英語ではshallow immutabilityです。


List.copyOf()で守ってみる

recordの外からリストを書き換えられたくない場合は、コンストラクタでコピーする方法があります。

import java.util.List;

public record Animal(
        String name,
        List<String> favoriteFoods
) {

    public Animal {
        favoriteFoods = List.copyOf(favoriteFoods);
    }
}

もう一度、同じように試します。

import java.util.ArrayList;
import java.util.List;

public class Main {

    public static void main(String[] args) {
        List<String> foods = new ArrayList<>();
        foods.add("バナナ");

        Animal gorilla = new Animal("ゴリラ", foods);

        foods.add("リンゴ");

        System.out.println(gorilla.favoriteFoods());
    }
}

実行結果です。

[バナナ]

Animalを作るときに、受け取ったリストをList.copyOf()でコピーしています。

そのため、元のfoodsへリンゴを追加しても、Animalが持っているリストには影響しません。

さらに、次のようにrecordから取り出したリストへ追加しようとすると、UnsupportedOperationExceptionが発生します。

gorilla.favoriteFoods().add("オレンジ");

recordを使うだけで、すべての値が安全に守られるわけではありません。

中にどのような型を持たせるかも考える必要があります。


recordに独自のインスタンスフィールドは追加できません

recordのインスタンスが持つデータは、ヘッダーに書いたレコード・コンポーネントによって決まります。

そのため、次のように独自のインスタンスフィールドを追加することはできません。

public record Animal(String name, int age) {

    private String description;
}

Animalが持つデータを増やしたい場合は、recordのヘッダーに追加します。

public record Animal(
        String name,
        int age,
        String description
) {
}

recordの宣言を見るだけで、

このデータが何からできているのか

がわかるようになっています。

なお、定数として使うstaticフィールドは宣言できます。


recordは継承できません

recordは、暗黙的にfinalなクラスになります。

そのため、recordを継承して別のクラスを作ることはできません。

public class Gorilla extends Animal {
}

このような継承はできません。

また、recordはjava.lang.Recordを暗黙的に継承しているため、別のクラスをextendsすることもできません。

でも、インターフェースを実装することはできます。

public interface Introducible {

    String introduction();
}
public record Animal(
        String name,
        int age
) implements Introducible {

    @Override
    public String introduction() {
        return name + "は" + age + "歳です。";
    }
}

「クラスの継承はできないけれど、インターフェースは実装できる」と覚えておくとよさそうです。


recordが使いやすそうな場面

recordは、いくつかの値をひとまとまりにして渡したい場面で使いやすそうです。

たとえば、ユーザー情報を返すデータです。

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

検索条件をまとめることもできます。

public record UserSearchCondition(
        String keyword,
        int page,
        int size
) {
}

集計結果を表すこともできます。

public record SalesSummary(
        int orderCount,
        long totalAmount
) {
}

このようなクラスは、状態を何度も書き換えながら動くものではありません。

いくつかの値をまとめ、処理から処理へ渡す役割を持っています。

このような場合、recordの考え方と相性がよさそうですよね。


DTOだから必ずrecord、ではありません

DTOは、処理の間でデータを受け渡すためのオブジェクトです。

recordは、DTOをシンプルに表す方法として使えることがあります。

ただし、

DTOなら必ずrecordにする

とは限りません。

利用しているフレームワークやライブラリ、そのバージョンや設定によっては、

  • 引数のないコンストラクタ
  • setter
  • クラスの継承
  • プロキシを使った仕組み

などを前提としている場合があります。

現在の主要なフレームワークにはrecordを扱えるものも増えていますが、すでに使っている仕組みと合うかは確認したほうがよさそうです。

また、値を段階的に設定したい場合や、内部の状態を変えながら動かしたい場合も、普通のクラスのほうが自然かもしれません。

recordを使えるかどうかだけでなく、

この型は、固定された値のまとまりとして扱いたいものなのか

を考えることが大切になりそうです。


普通のclassを使ったほうがよさそうな場面

たとえば、銀行口座を表すクラスを考えてみます。

public class BankAccount {

    private int balance;

    public void deposit(int amount) {
        balance += amount;
    }

    public void withdraw(int amount) {
        balance -= amount;
    }
}

このクラスは、入金や出金によってbalanceが変わっていきます。

固定されたデータのまとまりというより、状態と振る舞いを持つオブジェクトです。

このようなものを、無理にrecordで表す必要はなさそうです。

もちろん、新しい残高を持つrecordを毎回作る設計も考えられます。

ただ、何でもrecordにすればよいわけではなさそうですね。

  • 値のまとまりを表したいのか
  • 状態を持ち、時間とともに変化するものを表したいのか

によって、recordと普通のclassを使い分けるのがよいと考えています。


recordは短く書くためだけのものではありません

最初に見たrecordは、次のようなものでした。

public record Animal(String name, int age) {
}

普通のclassと比べると、とても短く書けます。

しかし、recordの意味は、単なるコードの省略ではないようです。

この宣言には、

Animalは、nameとageからできているデータです

という設計上の意図も表れています。

普通のclassでは、

  • 状態が変わるのか
  • setterがあるのか
  • 何を基準に比較するのか
  • どのフィールドが本体なのか

を、クラスの中まで読まないとわからないことがあります。

recordでは、ヘッダーを見ることで、そのデータを作っている要素がわかります。

コードを書く量が減るだけでなく、読む側にも意図を伝えやすくなるのかもしれませんね。


AIが書いたrecordも、意味を読んでみましょう

ChatGPTなどのAIにJavaのコードを作ってもらうと、DTOやAPIのレスポンスとしてrecordが使われることがあります。

public record LoginResult(
        boolean success,
        String message
) {
}

このコードを見たとき、

短く書かれたクラスらしい

だけで終わらず、

  • successmessageが、このデータを作る要素になっている
  • 値は作ったあとに再代入できない
  • success()message()で値を取り出せる
  • 同じrecord型のコンポーネントの値を基準にしたequals()hashCode()がある
  • 状態を変えながら動かすクラスではなさそう

と読めるようになります。

AIが生成したコードであっても、その形を選んだ理由を考えることはできます。

そのまま貼り付けるのではなく、使われているJavaの仕組みを一つずつ読めるようになると、コードを選ぶ側に少しずつ近づけるのではないでしょうか。


まとめ

今回は、Javaのrecordについて、普通のclassと比べながら見てきました。

recordを宣言すると、主に次のものが自動的に用意されます。

  • コンポーネントに対応するフィールド
  • コンストラクタ
  • アクセサ
  • equals()
  • hashCode()
  • toString()

アクセサは、getName()ではなくname()のような名前になります。

また、recordのフィールドはfinalなので、作ったあとに別の値を代入することはできません。

ただし、Listなど変更可能なオブジェクトを持っている場合、その中身まで自動的に変更不能になるわけではありません。

recordは、いくつかの値をひとまとまりのデータとして表すときに使いやすい仕組みです。

一方で、内部の状態を変えながら動くオブジェクトや、継承を必要とするクラスには向かないことがあります。

public record Animal(String name, int age) {
}

この短い宣言を見たとき、

Animalは、nameとageからできたデータを表している

と読めれば、recordの見え方が少し変わりそうです。

短く書けることも便利ですが、

この型を、どのようなものとして扱いたいのか

をコードで表せることが、recordの大きな特徴なのかもしれませんね。

プログラミング言語も、まだまだ進化を続けます。

知らないことを知って、新しいものを作れるようになると楽しそうです。

読んでくださってありがとうございました。

それではまた!