CLOVER🍀

That was when it all began.

Java 16で導入された、JEP 395 Recordsを試す

これはなにをしたくて書いたもの?

Java 16からRecordsが導入されましたが、使ったことがなかったので自分でも扱っておこうかなと。

JEP 395 Records

RecordsのJEPはこちら。

JEP 395: Records

ドキュメントとしては、こちらを見るのがよいでしょう。

Java言語更新(16) / レコード・クラス

RecordsはJava 14でプレビュー機能として導入され、Java 16で正式版になりました。

ざっくり言うと、こういうJavaクラスが

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

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

    int x() { return x; }
    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) { }

呼び方は、レコードなのかレコードクラスなのかなんとも言えませんが…

Record classes are a new kind of class in the Java language.

特殊な種類のクラスであるレコード・クラスは、通常のクラスよりも少ない手間でプレーン・データ集計をモデル化するために役立ちます。

recordを使ってクラスを宣言すると、以下が自動的に宣言されたことになるようです。

  • private finalなフィールドとアクセッサー
  • フィールドに対応する引数に取るコンストラクター
  • equals、hashCode、toStringメソッドの実装

レコードの制限は、以下です。

  • A record class declaration does not have an extends clause. The superclass of a record class is always java.lang.Record, similar to how the superclass of an enum class is always java.lang.Enum. Even though a normal class can explicitly extend its implicit superclass Object, a record cannot explicitly extend any class, even its implicit superclass Record.
  • A record class is implicitly final, and cannot be abstract. These restrictions emphasize that the API of a record class is defined solely by its state description, and cannot be enhanced later by another class.
  • The fields derived from the record components are final. This restriction embodies an immutable by default policy that is widely applicable for data-carrier classes.
  • A record class cannot explicitly declare instance fields, and cannot contain instance initializers. These restrictions ensure that the record header alone defines the state of a record value.
  • Any explicit declarations of a member that would otherwise be automatically derived must match the type of the automatically derived member exactly, disregarding any annotations on the explicit declaration. Any explicit implementation of accessors or the equals or hashCode methods should be careful to preserve the semantic invariants of the record class.
  • A record class cannot declare native methods. If a record class could declare a native method then the behavior of the record class would by definition depend on external state rather than the record class's explicit state. No class with native methods is likely to be a good candidate for migration to a record.

  • 他のクラスを継承することはできない

  • 暗黙的にfinalとなり、abstractとして定義することはできない
  • 定義したフィールドはfinalとなり、変更不可
  • インスタンスフィールド、インスタンスイニシャライザを明示的に定義できない
  • 定義したフィールドと異なる型のアクセッサー等を定義できない
  • nativeメソッドは定義できない

その他は、通常のクラスのように使えます。

  • Instances of record classes are created using a new expression.
  • A record class can be declared top level or nested, and can be generic.
  • A record class can declare static methods, fields, and initializers.
  • A record class can declare instance methods.
  • A record class can implement interfaces. A record class cannot specify a superclass since that would mean inherited state, beyond the state described in the header. A record class can, however, freely specify superinterfaces and declare instance methods to implement them. Just as for classes, an interface can usefully characterize the behavior of many records. The behavior may be domain-independent (e.g., Comparable) or domain-specific, in which case records can be part of a sealed hierarchy which captures the domain (see below).
  • A record class can declare nested types, including nested record classes. If a record class is itself nested, then it is implicitly static; this avoids an immediately enclosing instance which would silently add state to the record class.
  • A record class, and the components in its header, may be decorated with annotations. Any annotations on the record components are propagated to the automatically derived fields, methods, and constructor parameters, according to the set of applicable targets for the annotation. Type annotations on the types of record components are also propagated to the corresponding type uses in the automatically derived members.
  • Instances of record classes can be serialized and deserialized. However, the process cannot be customized by providing writeObject, readObject, readObjectNoData, writeExternal, or readExternal methods. The components of a record class govern serialization, while the canonical constructor of a record class governs deserialization.

  • インスタンス生成はnewで行う

  • トップレベルでの宣言、ネストした宣言が可能、ジェネリクスも適用可能
  • staticメソッド、staticフィールド、staticイニシャライザを定義可能
  • インターフェースを実装可能
  • ネストした型を定義可能
  • アノテーションを利用可能
  • シリアライズ可能
    • ただし、writeObjectやreadObject、writeExternal、readExternal等でのカスタマイズは不可

確認はこれくらいにして、実際に使っていってみましょう。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.8.1 2023-08-24
OpenJDK Runtime Environment (build 17.0.8.1+1-Ubuntu-0ubuntu122.04)
OpenJDK 64-Bit Server VM (build 17.0.8.1+1-Ubuntu-0ubuntu122.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.4 (dfbb324ad4a7c8fb0bf182e6d91b0ae20e3d2dd9)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.8.1, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-82-generic", arch: "amd64", family: "unix"

準備

Maven依存関係等はこちら。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.10.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.24.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.1.2</version>
            </plugin>
        </plugins>
    </build>

動作は、テストコードで確認しようと思います。

レコードを試す

まずはレコードを宣言してみます。

src/main/java/org/littlewings/record/Book.java

package org.littlewings.record;

import java.util.List;

public record Book(String isbn, String title, int price, List<String> tags) {
}

javapしてみましょう。

$ javap -p target/classes/org/littlewings/record/Book.class
Compiled from "Book.java"
public final class org.littlewings.record.Book extends java.lang.Record {
  private final java.lang.String isbn;
  private final java.lang.String title;
  private final int price;
  private final java.util.List<java.lang.String> tags;
  public org.littlewings.record.Book(java.lang.String, java.lang.String, int, java.util.List<java.lang.String>);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String isbn();
  public java.lang.String title();
  public int price();
  public java.util.List<java.lang.String> tags();
}

Recordというクラスを継承しているようです。

Record (Java SE 17 & JDK 17)

この時点で、ちょっと確認してみましょう。

テストコードの雛形。

src/test/java/org/littlewings/record/RecordTest.java

package org.littlewings.record;

import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class RecordTest {

    // ここにテストを書く!
}

まずはアクセッサーで値の確認。

    @Test
    void recordGettingStarted() {
        Book book =
                new Book("978-4297136130", "プロになるためのSpring入門 -- ゼロからの開発力養成講座", 3960, List.of("Java", "Spring"));

        assertThat(book.isbn()).isEqualTo("978-4297136130");
        assertThat(book.title()).isEqualTo("プロになるためのSpring入門 -- ゼロからの開発力養成講座");
        assertThat(book.price()).isEqualTo(3960);
        assertThat(book.tags()).isEqualTo(List.of("Java", "Spring"));
    }

newインスタンスを生成して、アクセッサーで値を取得できます。

toStringequalsの確認もしてみます。

    @Test
    void toStringAndEquals() {
        Book book =
                new Book("978-4297136130", "プロになるためのSpring入門 -- ゼロからの開発力養成講座", 3960, List.of("Java", "Spring"));
        assertThat(book.toString())
                .isEqualTo("Book[isbn=978-4297136130, title=プロになるためのSpring入門 -- ゼロからの開発力養成講座, price=3960, tags=[Java, Spring]]");

        Book another =
                new Book("978-4297136130", "プロになるためのSpring入門 -- ゼロからの開発力養成講座", 3960, List.of("Java", "Spring"));
        assertThat(book).isEqualTo(another);

        Book notEqual1 =
                new Book("978-4297136130", "プロになるためのSpring入門 -- ゼロからの開発力養成講座", 3960, List.of("Java"));
        assertThat(book).isNotEqualTo(notEqual1);

        Book notEqual2 =
                new Book("978-4297136130", "プロになるためのSpring入門 -- ゼロからの開発力養成講座", 200, List.of("Java", "Spring"));
        assertThat(book).isNotEqualTo(notEqual2);
    }

これは確かに便利ですね。

次は、作成したレコードを少し変えてみます。

フィールドを追加してみます。

public record Book(String isbn, String title, int price, List<String> tags) {
    private String foo;
}

これはコンパイルエラーになります。制限に書かれていたとおりですね。

java: フィールド宣言は静的である必要があります
  (フィールドをレコード・コンポーネントに置換することを検討)

ちょっと変わったコンストラクターを追加できます。

src/main/java/org/littlewings/record/Book.java
package org.littlewings.record;

import java.util.List;

public record Book(String isbn, String title, int price, List<String> tags) {
    public Book {
        if (price <= 0) {
            throw new IllegalArgumentException("invalid price");
        }
    }
}

以下のような記述は、コンパクト・コンストラクターと呼ぶみたいです。

    public Book {
        // ここにコンストラクターの実装を書く
    }

コンストラクター引数はありませんが、これでもインスタンスに値は設定されるようです。

今回は値を確認して、例外をスローするようにしてみました。

以下のように通常のコンストラクターを書くこともできるのですが、コンパクト・コンストラクターの方が楽ですし、ミスも減りそうで
良いですね。

public record Book(String isbn, String title, int price, List<String> tags) {
    public Book(String isbn, String title, int price, List<String> tags) {
        this.isbn = isbn;
        this.title = title;
        this.price = price;
        this.tags = tags;

        if (price <= 0) {
            throw new IllegalArgumentException("invalid price");
        }
    }
}

インスタンスメソッドは追加可能なので、こちらも試してみましょう。

src/main/java/org/littlewings/record/Book.java

package org.littlewings.record;

import java.util.List;

public record Book(String isbn, String title, int price, List<String> tags) {
    public Book {
        if (price <= 0) {
            throw new IllegalArgumentException("invalid price");
        }
    }

    public String tagsAsString() {
        return String.join(",", tags);
    }
}

あとはテストで確認。

    @Test
    void addConstructorAndInstanceMethod() {
        assertThatThrownBy(() -> new Book("978-4297136130", "プロになるためのSpring入門 -- ゼロからの開発力養成講座", 0, List.of("Java", "Spring")))
                .hasMessage("invalid price")
                .isExactlyInstanceOf(IllegalArgumentException.class);

        Book book =
                new Book("978-4297136130", "プロになるためのSpring入門 -- ゼロからの開発力養成講座", 3960, List.of("Java", "Spring"));

        assertThat(book.tagsAsString()).isEqualTo("Java,Spring");
    }

OKですね。

Bean Validationと組み合わせてみる

最後に、アノテーションと組み合わせる例も試してみましょう。

レコードで使いたくなるもののひとつとして、Bean Validationがあるでしょうか。Hibernate Validatorはレコードに対応しているようなので、
こちらで試してみたいと思います。

Both versions improve testing of Java records and make sure the annotation processor is working correctly with records.

Hibernate Validator 6.2.4.Final, 7.0.5.Final and 8.0.0.CR2 released - In Relation To

Maven依存関係に以下を追加。

        <dependency>
            <groupId>org.hibernate.validator</groupId>
            <artifactId>hibernate-validator</artifactId>
            <version>8.0.1.Final</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish.expressly</groupId>
            <artifactId>expressly</artifactId>
            <version>5.0.0</version>
        </dependency>

Java SE環境で動作させているので、EL式の実装が必要です。

Bean Validationのアノテーションをつけたら、こんな感じになりました。

src/main/java/org/littlewings/record/BookWithValidation.java

package org.littlewings.record;

import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;

import java.util.List;

public record BookWithValidation(
        @NotEmpty
        String isbn,
        @NotEmpty
        String title,
        @Min(1)
        int price,
        @NotEmpty
        List<String> tags) {
}

通常のJava Beansの形式より、だいぶスッキリですね。

テストコード。

src/test/java/org/littlewings/record/RecordBeanValidationTest.java

package org.littlewings.record;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;

class RecordBeanValidationTest {
    @Test
    void recordWithBeanValidation() {
        Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

        BookWithValidation invalidBook =
                new BookWithValidation(null, null, -1, Collections.emptyList());

        Set<ConstraintViolation<BookWithValidation>> violations = validator.validate(invalidBook);
        assertThat(violations).hasSize(4);

        List<String> propertyPathAndMessages =
                violations
                        .stream()
                        .sorted(Comparator.comparing(c -> c.getPropertyPath().toString()))
                        .map(c -> c.getPropertyPath().toString() + ":" + c.getMessage())
                        .toList();

        assertThat(propertyPathAndMessages)
                .isEqualTo(List.of(
                        "isbn:空要素は許可されていません",
                        "price:1 以上の値にしてください",
                        "tags:空要素は許可されていません",
                        "title:空要素は許可されていません"
                ));
    }

    @Test
    void valid() {
        Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

        BookWithValidation book =
                new BookWithValidation("978-4297136130", "プロになるためのSpring入門 -- ゼロからの開発力養成講座", 3960, List.of("Java", "Spring"));

        Set<ConstraintViolation<BookWithValidation>> violations = validator.validate(book);
        assertThat(violations).isEmpty();
    }
}

OKですね。

まとめ

Java 16で導入された、JEP 395 Recordsを試してみました。

今までJava Beansとして書いていたものがだいぶスッキリできるので良いですね。

とはいえ、これまでのフレームワークやライブラリーで使えるかどうかは対応状況次第なので、そのあたりは使いたいものがレコードに
対応しているかどうか確認してからですね。