18
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

9.1 レコードによる不変のデータの設計~新Java Advanced編 / AI対応版

18
Last updated at Posted at 2023-02-04

はじめに

自己紹介

皆さん、こんにちは、Udemy講師の斉藤賢哉です。私はこれまで、約30年にわたって企業システムの開発に携わってきました。現在も現役のアーキテクトとして、重要システムの技術設計やソリューションの選定を担当しています。直近では、システム開発へのAI適用(AI駆動開発)という全社的な施策でリーダーを務めています。
セミナーでの登壇や雑誌への技術記事の寄稿を行ってきたほか、以下の書籍も執筆しています。

Udemy講座のご紹介

この記事の内容は、私が講師を務めるUdemy講座『新 Java Advanced編 / AI対応版』の一部の範囲をカバーしたものです。『新 Java Advanced編 / AI対応版』はこちらのリンクから購入できます。

この講座は、以下のような皆様にお薦めします。

  • Javaの基礎を学び終え、設計とレビューができるエンジニアを目指す方
  • AIが書いた並行処理や入出力のコードに潜むリスクを、仕組みから見抜けるようになりたい方
  • フレームワークの裏で使われるリフレクションやアノテーションの仕組みを理解したい方
  • 「Oracle認定Javaプログラマ」Goldの範囲を、仕組みから理解したい方

AIとチャットできるオールインワンの学習環境をリリースしました

Udemy講座の演習を動かすための学習環境として、Windows向けのデスクトップアプリ「Codinable(コーディナブル)」をリリースしました。
エディタ、実行結果、テスト結果、SQL、Webブラウザ、そしてAIチャットを1つの画面にまとめた、オールインワンの学習環境です。

Codinableの画面構成

学習に必要なものは、すべてCodinableの中で完結します。

  • 演習の実行環境(Java 25、Gradle、Bashなど)が一式同梱されており、JDKのインストールや環境変数の設定は不要です
  • インストールして「新規コース開始・再開」からコースを選ぶだけで、講座のすべての演習がそのまま動きます
  • 書き換えて動かなくなっても、「初期化」で配布時の状態に戻せます
  • 開いている演習のファイルと実行結果を踏まえて、AIに質問したり(Askモード)、ファイルの書き換えを指示したり(Agentモード)できます

AIに書き換えを指示し、差分を確かめてから実行する、というAI駆動開発の基本の流れを、Codinableの中だけで安全に体験できます。

AIチャットに書き換えを指示し、差分を確かめて実行する

AIチャットは、受講者ご自身が生成AIサービス(Anthropic、OpenAI、Googleのいずれか)のAPIキーを登録して使うBYOK(Bring Your Own Key)方式です。利用料金は各社から直接請求されます。APIキーを登録しなくても、AIチャット以外の機能はすべて使えます。

Codinableのインストーラは、以下のGitHubのReleasesページからダウンロードできます(64ビット版のWindows 10/11、管理者権限は不要)。

この記事を含むシリーズ全体像

この記事はJava SEの一部の機能・仕様を取り上げたものですが、一連のシリーズになっており、シリーズ全体でJava SEを網羅しています。また認定資格である「Oracle認定Javaプログラマ」(Silver、Gold)の範囲もカバーしています。シリーズの全体像および「Oracle認定Javaプログラマ」の範囲との対応関係については、以下を参照ください。

9.1 レコードによる不変のデータの設計

チャプターの概要

本チャプターでは、Java 16で正式な機能になったレコード(レコードクラス)を、不変のデータを設計するための仕組みとして取り上げます。レコードは数行で宣言でき、AIエージェントも積極的に使いますが、その不変性は「フィールドを再代入できない」ところまでです。宣言から何が用意されるか、不変条件をどこで守るか、データの種類をどう振り分けるかを図で理解し、AIが書いたレコードの設計をレビューできるようになることを目指します。

9.1.1 レコードの構造と値に基づく等価性

本レッスンでは、レコードの宣言から暗黙的に用意されるメンバーを確かめ、どのようなデータをレコードにすべきかを判断できるようにします。

宣言から用意されるメンバー

レコードは、recordキーワードに続けて、名前と、( )の中に型と名前の組を並べて宣言します。この( )の並びをレコードヘッダー、1つ1つの要素をレコードコンポーネントと呼びます。

  • 09_record: pro.kensait.java.advanced.lsn_9_1_1.Person
public record Person(String name, int age, String gender) {
}

この1行から、通常のクラスであれば数十行になるメンバーが暗黙的に用意されます。同じ内容を通常のクラスで書いたものが、lsn_9_1_1.Person_ofClassです。用意されるメンバーを、次の図に整理します。

レコードクラスの宣言から暗黙的に用意されるメンバー

  • 左の「レコードクラスの宣言」のrecord Person(String name, int age, String gender)から、右の5つのメンバーが用意される
  • private finalな3つのフィールド(name、age、gender)を持つ
  • 3つの値を引数に取るコンストラクタを持つ
  • 参照のみのアクセサメソッドname()、age()、gender()を持つ。先頭にgetは付かない
  • 3つのフィールドを文字列化するtoString()と、3つのフィールドから等価性を判定するequals()・hashCode()を持つ
  • 全体はfinalクラスで継承できず、暗黙的にjava.lang.Recordを継承するため、他のクラスをextendsすることもできない

値が等しければ等しい

レコードのequals()は、インスタンスが同じかではなく、すべてのコンポーネントの値が等しいかで判定します。hashCode()も同じコンポーネントから算出されるため、レコードはそのままHashSetの要素やHashMapのキーに使えます。

  • 09_record: pro.kensait.java.advanced.lsn_9_1_1.Main_HashSet
Person alice = new Person("Alice", 25, "female");
// bob、carolの生成とset.add()は省略
Set<Person> set = new HashSet<>();
set.add(alice);

Person alice2 = new Person("Alice", 25, "female");
System.out.println(set.add(alice2)); //【1】
  • 【1】alice2は別のインスタンスだが、3つのコンポーネントの値がすべて等しいため重複と判定され、falseが表示される

2つのインスタンスがHashSetの中でどう判定されるかを、図で示します。

値に基づく等価性とHashSetによる重複の判定

  • ヒープに「alice:Person[name=Alice, age=25, gender=female]」と「alice2:Person[name=Alice, age=25, gender=female]」の別々の2つのインスタンスがある
  • 2つの間に「== は false(別のインスタンス)」と「equals() は true(値が等しい)」の2つのラベルがある
  • 「HashSet」の箱にはaliceだけが入り、alice2からの「add()」の矢印は「false(重複)」で止まる
  • 下に「equals()とhashCode()は、すべてのコンポーネントから作られる」という帯がある

レコードにすべきデータ

レコードは、値の組み合わせそのものが意味を持ち、作った後に変わらないデータ(複数の値を返す戻り値、APIとの間で受け渡すデータ、Mapのキーなど)に向いています。状態が変わるものや、識別子で同一性を判断したいものは、通常のクラスで設計します。

レコードにするかどうかの判断の流れを、図で示します。

レコードにするか通常のクラスにするかの判断

  • 最初の分岐は「作った後に値を変える必要があるか」で、「はい」は「通常のクラス(状態を持つオブジェクト)」に進む
  • 「いいえ」の次の分岐は「すべての値が等しいときに同じものと見なしてよいか」で、「いいえ(IDで同一性を判断する)」は「通常のクラス(equalsを設計する)」に進む
  • 「はい」は「レコード」に進み、その下に「可変なコンポーネントは防御的コピー」という注記がある(防御的コピーはレッスン9.1.2で扱う)

レビューの観点

  • 作った後に値を変えたいデータを、レコードにしていないか(変更のたびに新しいインスタンスを作る設計になっているかを確かめる)
  • 識別子で同一性を判断すべきデータ(会員など)を、すべての値で等価性を判定するレコードにしていないか
  • getName()のようなget付きのメソッドを、レコードのアクセサだと思い込んだコードになっていないか(アクセサはname())

9.1.2 不変条件とコンパクトコンストラクタ

本レッスンでは、レコードの不変条件をどこで検証するか、可変なコンポーネントをどう守るかを判断できるようにします。

生成の時点で不変条件を検証する

レコードにコンパクトコンストラクタと呼ばれる特殊なコンストラクタを書くと、フィールドへ値が代入される前に、引数を検証できます。コンパクトコンストラクタは引数の並びを書かず、コンポーネントの名前がそのまま引数として使えます。処理が終わると、引数の値が暗黙的にフィールドへ代入されるため、this.name = name;のような代入は書けません。

  • 09_record: pro.kensait.java.advanced.lsn_9_1_2.Person
public record Person(String name, int age, String gender) implements Serializable {
    // serialVersionUIDは省略
    public Person { //【1】
        if (name == null) throw new IllegalArgumentException("name is null");
        if (age < 0) throw new IllegalArgumentException("age is under 0");
    }
    // toString()のオーバーライドとsayHello()の追加は省略
}
  • 【1】コンパクトコンストラクタで、nameがnullでないこと、ageが0以上であることを検証する
  • レコードには、インタフェースのimplements、スタティックなメンバー、メソッドのオーバーライドや追加はできるが、インスタンスフィールドは追加できない

コンパクトコンストラクタの位置付けを、インスタンス生成の流れの中で示します。

コンパクトコンストラクタとインスタンス生成の流れ

  • new Person("Alice", 25, "female")を実行すると、まずコンパクトコンストラクタが引数を検証する
  • name == nullまたはage < 0のときは、IllegalArgumentExceptionを送出する
  • 検証を通ると、コンパクトコンストラクタの処理の後に、フィールドへの代入が暗黙的に行われる
  • こうして生成されたインスタンスは、不変である

レコードの不変性は浅い

レコードのフィールドはfinalですが、守られるのは参照だけです。コンポーネントがListのような可変なオブジェクトであれば、生成に使ったリストやアクセサが返したリストを通じて、中身を書き換えられます。これを防ぐには、コンパクトコンストラクタの中で、引数をList.copyOf()で変更できないコピーに置き換えます(引数への代入は許されています)。

可変なリストを持つレコードの、問題のある設計と良い設計を以下に示します。

// 問題のある設計: 渡されたリストをそのまま保持する
record Order(String id, List<String> items) {}

// 良い設計: 変更できないコピーを保持する
record SafeOrder(String id, List<String> items) {
    SafeOrder {
        Objects.requireNonNull(id);
        items = List.copyOf(items); //【1】
    }
}
  • 【1】引数itemsを変更できないコピーに置き換える。この後、暗黙的にフィールドへ代入される
  • Orderでは、生成の後に呼び出し元が元のリストに要素を追加すると、order.items()の中身も変わる
  • SafeOrderでは、元のリストを変えても影響せず、items().add(...)はUnsupportedOperationExceptionになる

2つの設計で、参照がどうつながっているかを図で比較します。

可変なコンポーネントの共有と防御的コピー

  • 左の「問題のある設計(Order)」では、呼び出し元の変数「list」とOrderのフィールド「items」が、ヒープ上の同じ「ArrayList [A, B]」を参照している
  • 左では、呼び出し元の「list.add("C")」によってOrderの中身も「[A, B, C]」に変わることが示されている
  • 右の「良い設計(SafeOrder)」では、呼び出し元の「list」は「ArrayList [A, B]」を、フィールド「items」は別の「変更できないList [A, B]」を参照している
  • 右では、「list.add("C")」の影響がSafeOrderに及ばないことが示されている
  • 下に「finalが守るのは参照だけ。中身は守らない」という帯がある

レビューの観点

  • 不変条件の検証が、レコードを使う側ではなく、コンパクトコンストラクタに書かれているか
  • List・Map・配列などの可変なコンポーネントを、そのまま保持していないか。List.copyOf()などで変更できないコピーにしているか
  • 配列のコンポーネントを持つレコードで、equals()が配列の中身ではなく参照で比較されることを考慮しているか
  • toString()をオーバーライドせずに、パスワードなどの秘密の値をコンポーネントに持たせ、ログに出してしまう設計になっていないか

9.1.3 レコードパターンによるデータの種類の振り分け

本レッスンでは、レコードパターンで値を取り出す仕組みを確かめ、データの種類ごとの処理の振り分けが漏れなく書かれているかを判断できるようにします。

型の判定と値の取り出しをまとめて書く

Java 21では、レコードの値を分解して取り出すレコードパターンが導入されました。instanceofやswitchの中で、型の判定と、コンポーネントの値のローカル変数への取り出しを1つの記述で行えます。whenに続けて条件を書くと、取り出した値でさらに場合分けできます。また、Java 22以降は、使わないコンポーネントを_と書いて省略できます。

record Point(int x, int y) {}  // Personはlsn_9_1_1と同じレコード

static String describe(Object obj) {
    return switch (obj) {
        case Person(String name, int age, String gender) when age < 20 -> name + "さんは未成年です"; //【1】
        case Person(String name, int age, _) -> name + "さんは" + age + "歳です"; //【2】
        case Point(int x, int y) -> "座標(" + x + ", " + y + ")";
        default -> "不明なオブジェクト"; //【3】
    };
}
  • 【1】objがPersonで、かつageが20未満のときに選ばれる。条件の厳しいcaseを先に書く
  • 【2】それ以外のPersonのときに選ばれる。genderは使わないため_で省略している
  • 【3】objはObject型で、どの型も来得るため、defaultが必要になる

switchがデータの種類と条件で処理を振り分ける流れを、図で示します。

レコードパターンによるswitchの振り分け

  • 左の「obj」から右へ、上から順に「case Person(…) when age < 20」「case Person(…)」「case Point(int x, int y)」「default」の4つの分岐が並ぶ
  • 「new Person("Bob", 15, "male")」は1番目の分岐に入り、「Bobさんは未成年です」になる
  • 「new Person("Alice", 25, "female")」は1番目の条件を満たさず、2番目の分岐に入る
  • 「new Point(3, 4)」は3番目の分岐に入り、x=3、y=4が取り出される
  • 分岐は上から順に試され、最初に一致したものだけが実行されることが矢印で示されている

sealedで種類を閉じ、defaultをなくす

defaultは、後から新しいレコードを追加したときの処理の書き忘れも黙って受け止めてしまいます。データの種類が決まっている場合は、sealedインタフェースで実装できる型を限定します。すると、switchがすべての型を扱っているかをコンパイラが検査し、defaultが不要になります。

sealed interface Shape permits Circle, Square {}
record Circle(double r) implements Shape {}
record Square(double side) implements Shape {}

static double area(Shape shape) {
    return switch (shape) { //【1】
        case Circle(double r) -> Math.PI * r * r;
        case Square(double side) -> side * side;
    };
}
  • 【1】Shapeの実装はCircleとSquareだけなので、2つのcaseですべてを扱っているとコンパイラが判断し、defaultが無くてもコンパイルできる
  • ShapeにTriangleを追加すると、area()のswitchがコンパイルエラーになり、書き忘れを見つけられる

defaultで受け止める設計と、sealedで種類を閉じる設計を、図で比較します。

defaultで受け止める設計とsealedで種類を閉じる設計

  • 左の「defaultで受け止める」では、新しく追加した「Triangle」が「default →(何もしない/例外)」に流れ、「実行するまで気づかない」というラベルがある
  • 右の「sealedで種類を閉じる」では、「sealed interface Shape permits Circle, Square」の下に「Circle」「Square」の2つのレコードがある
  • 右で「Triangle」を追加すると、switchに「コンパイルエラー(扱っていない型がある)」の印が付く
  • 下に「種類の追加漏れを、実行時ではなくコンパイル時に見つける」という帯がある

レビューの観点

  • when付きのcaseが、条件の無い同じ型のcaseより後ろに書かれていないか(後ろに書くとコンパイルエラーになる)
  • データの種類が決まっているのに、defaultで想定外の型を黙って受け止めていないか。sealedで種類を閉じられないかを確かめる
  • アクセサを1つずつ呼んでinstanceofとキャストを繰り返す古い書き方を、AIが新しく書いていないか(Java 21以降を前提にするなら、レコードパターンで書けないかを確かめる)

このチャプターで学んだこと

本チャプターでは、以下を取り上げました。

  1. レコードは、宣言だけでprivate finalなフィールド、コンストラクタ、アクセサ、toString()、equals()、hashCode()が用意される、不変のデータのための仕組みであること
  2. レコードの等価性はすべてのコンポーネントの値で決まり、作った後に変わらず、値の組み合わせが意味を持つデータに向いていること
  3. 不変条件はコンパクトコンストラクタで生成の時点に検証し、可変なコンポーネントはList.copyOf()などで防御的にコピーすること(レコードの不変性は浅い)
  4. レコードパターンで型の判定と値の取り出しをまとめて書け、sealedで種類を閉じると、振り分けの漏れをコンパイル時に見つけられること
18
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?