Javaのレコード(Records)は入れ子にして便利に使える #javajo #java14

結論

タイトル通り。

前置き

先日Java女子部でこんなイベントを行いました。
javajo.doorkeeper.jp

型の基本から最新情報まで学べる豪華な会で、知らないことも多くとても面白かったです。
他の多くの参加者にとっても同様だったようで、満足度の高いイベントになりました。西川さんありがとうございました!

さて、Java 14からプレビュー機能として「レコード」が追加されました*1

特徴の一部を西川さんの資料から拝借しました:

  • イミュータブルである
  • recordは暗黙のうちにfinalである
  • recordクラスのメンバー変数はfinalである
  • 値がすべて同じならequals()ではtrueを返さなくてはならない

Java女子部のイベントではサンプル付きでレコードについて学んだのですが、
フィールドがint型やString型だけだったので、 「レコードは入れ子にできる?したらどうなる?」という疑問が湧きまして。実際使うときって入れ子にしたいし。
ということで、それを動かして調べてみるのが私の宿題となったのでした。

ソースコード付き実験と解説

レコードを定義してみる

大好きな「あつまれどうぶつの森」風サンプルにしました。実際のあつ森はこの何億倍も複雑ですが。

  • 名前と色の情報を持った家具がある
  • 家具はリメイクすることで色を変えられる
  • 家具は複数個ポケットにしまうことができる

を満たすクラスたちをレコードとして作ってみます (サンプルにおいて package とか import は省略してます)。
f:id:ihcomega:20200804095014j:plain:w300

クラス名の後にコンストラクタ風にメンバー変数を渡すと便利なメソッドやらが色々生成されます(後述)。
Scalacase class みたい。

/**
 * 家具の名前
 */
public record FurnitureName(String value) {}
/**
 * 家具の色
 */
public enum FurnitureColor {
    RED,
    BLUE,
    YELLOW,
}
/**
 * 家具
 */
public record Furniture(FurnitureName name,
                        FurnitureColor color
) {
    public FurnitureName name() {
        return name;
    }
}
/**
 * 家具を入れるポケット
 * 入れる位置がkey, 中身がvalue
 */
public record Pocket(Map<Integer, Furniture> furniture) {}

FurniturePocketコンパイルされた後どんなメンバーを持つか見てみます。

$ javap -p Pocket.class
Compiled from "Pocket.java"
public final class com.example.animal_crossing.main_character.Pocket extends java.lang.Record {
  private final java.util.Map<java.lang.Integer, com.example.animal_crossing.item.Furniture> furniture;
  public com.example.animal_crossing.main_character.Pocket(java.util.Map<java.lang.Integer, com.example.animal_crossing.item.Furniture>);
  public java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.util.Map<java.lang.Integer, com.example.animal_crossing.item.Furniture> furniture();
}
$ javap -p Furniture.class
Compiled from "Furniture.java"
public final class com.example.animal_crossing.item.Furniture extends java.lang.Record {
  private final com.example.animal_crossing.item.FurnitureName name;
  private final com.example.animal_crossing.item.FurnitureColor color;
  public com.example.animal_crossing.item.Furniture(com.example.animal_crossing.item.FurnitureName, com.example.animal_crossing.item.FurnitureColor);
  public com.example.animal_crossing.item.FurnitureName name();
  public java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public com.example.animal_crossing.item.FurnitureColor color();
}

javapコマンドに詳しくなくてもかんたん!以下のことが分かりますね。

  • record で宣言をすると Record クラスを継承したクラスとなる
  • レコードクラス自体は final となる
  • レコードクラスのメンバー変数は private final となる (ので、setterは生成されない)
  • toString(), hashCode(), equals(), getter, コンストラクタが自動で生成される

レコードにふるまいを持たせてみる

さてここで「ポケットから家具をひとつ取り出してリメイクしてまたポケットにしまう」というコードを実行できるようにレコードを拡張してみます。 レコードはイミュータブルなので Furniture インスタンスを直接書き換えることはできません。
ということで、 PocketFurniture にリメイクに必要なメソッドを生やしてみました。
慣れないJavaですが、Stream API使いに改善の余地があったら教えて下さい

/**
 * 家具
 */
public record Furniture(FurnitureName name,
                        FurnitureColor color
) {

    /**
     * 家具の色を指定したものに変える(リメイクする)
     *
     * @param newColor リメイク後の色
     * @return 色の変わった新しい家具
     */
    public Furniture remake(FurnitureColor newColor) {
        return new Furniture(name,
                newColor);
    }
}
/**
 * 家具を入れるポケット
 * 入れる位置がkey, 中身がvalue
 */
public record Pocket(Map<Integer, Furniture> furniture) {

    /**
     * 指定した位置の家具を取り出す
     *
     * @param position 取り出す位置
     * @return 取り出した家具
     */
    // 余談なんですが、最初はOptional<Furniture>を返すようにしてたものの
    // 呼び出し元がmapやflatMap祭りになって見辛くなったので今回は強気のgetにしました
    public Furniture takeOut(int position) {
        return furniture.get(position);
    }

    /**
     * 指定した位置の家具を、別の家具と入れ替える
     *
     * @param position 入れ替える位置
     * @param newFurniture 入れ替え後、ポケットの指定した位置に入る家具
     * @return 入れ替え後の家具が入った新しいポケット
     */
    // これもうちょっとスマートに書けるのかな?
    public Pocket replace(int position, Furniture newFurniture) {
        Map<Integer, Furniture> replaced =
                furniture.entrySet().stream()
                        .map(e -> {
                            if (e.getKey() == position) {
                                return Map.entry(position, newFurniture);
                            } else {
                                return e;
                            }
                        })
                        .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue));
        return new Pocket(replaced);
    }
}

レコードの特徴を確認してみる

Java女子部と同じように equals() メソッドの挙動を見て、値が同じであればtrueを返すのを確認することにしました。
次のようなコードを -enableassertions オプション付きで実行してみます。

Furniture cuteTable = new Furniture(new FurnitureName("かわいいテーブル"), FurnitureColor.RED);
Furniture coolChair = new Furniture(new FurnitureName("かっこいい椅子"), FurnitureColor.BLUE);

// かわいいテーブル(赤)・かっこいい椅子(青)が1つずつ入ったポケットをつくる
Pocket myPocket = new Pocket(Map.of(0, cuteTable, 1, coolChair));

// かわいいテーブルを青にリメイクする
Pocket onceRemadePocket = myPocket.replace(0, myPocket.takeOut(0).remake(FurnitureColor.BLUE));

// かわいいテーブルを再び赤にリメイクする
Pocket remadeAgainPocket = myPocket.replace(0, myPocket.takeOut(0).remake(FurnitureColor.RED));

// 1回目のリメイクでPocketインスタンスが作り直される
assert myPocket != onceRemadePocket;

// 1回目のリメイクでPocketの中身が始めと異なる
assert !myPocket.equals(onceRemadePocket);

// 2回目のリメイクでPocketインスタンスが作り直される
assert onceRemadePocket != remadeAgainPocket;

// 2回目のリメイクでPocketの中身が始めと同じと判定される
assert myPocket.equals(remadeAgainPocket);

System.out.println("問題なくここまでくればOK");

実行結果は次のようになったので、想定通り!

問題なくここまでくればOK

今回はここまで。

余談

javap -p -c -v xxx.classの結果を見て、 equalsメソッドの生成についてもうちょっと細かいところも見てみたんですが
それも記事に入れようとすると公開まで時間がかかりそうなので諦めました。へへへ

このへんにたどり着いた↓ jdk/ObjectMethods.java at 827e5e32264666639d36990edd5e7d0b7e7c78a9 · openjdk/jdk · GitHub