BT

InfoQ ホームページ アーティクル Java 14の新機能 - Record

Java 14の新機能 - Record

Lire ce contenu en français

ブックマーク

原文(投稿日:2020/02/04)へのリンク

プレビュー機能

Javaプラットフォームの世界的な普及と、その互換性に関する責任の高さを考えた時、言語機能の設計上のミスへの対価は極めて大きなものになります。言語の仕様的欠陥という文脈から考えた互換性の保証とは、単に機能の廃止や大幅な変更が非常に難しいというだけではありません。既存の機能が将来的な機能の動作を制限するという意味も持つ — 今日歓迎された新機能が、明日は互換性という名の制限になるのです。

言語機能の最終的な実証の場は、開発現場における使用です。現実のコードベースで試した開発者からのフィードバックは、機能が意図通り動作することを証明する上で欠かせないものなのです。Javaが複数年のリリースサイクルを採用していた頃は、試験とフィードバックのための十分な時間がありました。現在の短いリリースサイクルの下で、試験とフィードバックに十分な時間を確保する必要から、言語の新機能は1回以上のプレビューを通過することになります。プレビュー機能はプラットフォームの一部ではありますが、独立的にオプトインする必要があります。仕様面でまだ確定ではなく、開発者からのフィードバックに基いた調整が必要であるため、このようにしてミッションクリティカルなコードを毀損しないようにしているのです。

QCon New Yorkの"Java Futures"という講演では、Java言語アーキテクトのBrian Goetz氏が、Java言語の最新機能と将来機能の目眩く旅に私たちを誘いました。本シリーズの最初の記事ではローカル変数型推論(Local Variable Type Inference)を取り上げましたが、今回の記事では、氏がRecordについて紹介してくれます。

Java SE 14(2020年3月)では、プレビュー機能としてRecord(jep359)が導入されます。Recordの目的は、"プレーンデータ"の集合を、より少ないセレモニーでモデル化できるようにすることです。単純なx-y座標の抽象化であれば、次のように宣言することができます。

record Point(int x, int y) { }

これによって変更不可能(immutable)なコンポーネントであるxyとその適切なアクセサ、コンストラクタ、equalshashCodetoStringの実装を持ったPointというfinalクラスが宣言されます。

これらはいずれも、定形コードで補完されたコンストラクタ実装、Objectメソッド、アクセサを記述する(あるいはIDEが生成してくれる)方法であれば、ごく普通に使用されているものです。

このようなコードを書くのは確かに面倒ですが、もっと重要なのは、それを読むという作業です。クラスを完成させるためには、実際には読む必要のないようなボイラプレートコードもすべて読まなくてはなりません。

では、Recordとは何でしょう?

名前付きtupleである、と考えると一番理解しやすいでしょう。順序付けられた要素の透過的(transparent)で浅い(shallow)、変更不可能なキャリアがRecordなのです。状態要素(state element)の名称と型はRecordヘッダ内で宣言され、状態記述(state description)と呼ばれます。名前付き(nominal)というのは、集合とそのコンポーネントには名称があって単なるインデクスではないこと、透過的だというのは、状態がクライアントからアクセス可能である(実装によってアクセスを制限することも可能)こと、浅く、変更不可能だというのは、Recordの表す値のタプルは一度インスタンス化されると変更できない(ただし、値が変更可能(mutable)なオブジェクトの参照であれば、参照先のオブジェクトは変更される場合があります)ことを意味しています。

Recordは、ある共通な状態のために最適化、制限化された形式のクラスであり、その意味ではEnum(列挙型)と同じです。Enumはある種バーゲンのようなものです — インスタンス化を諦める代償として、ある種の構文的、意味的メリットを提供してくれます。ですから私たちは、特定の状況におけるEnumの提供するメリットとコストを秤にかけることで、Enumか通常のクラスかを自由に選択できるのです。

Recordが提供するものも同じようなバーゲンです。Recordは私たちに表現とAPIとの分離を諦めるように求め、その代償としてAPI、インスタンス生成と状態アクセスと等値比較の実装、そして表現を、状態記述から機械的に導出することを可能にしているのです。

APIと表現を結び付けるというのは、オブジェクト指向の基本原則であるカプセル化に反するように思われるかも知れません。確かに、カプセル化は複雑性を管理する重要なテクニックであって、ほとんどの場合において適切な選択なのですが、xy座標のような単純な抽象化の場合には、カプセル化のコストがメリットを上回ることもあります。コストの中には、単純なドメインクラスでも定型的なコードを書かなければならない、というような明白なものもありますが、もっと分かりにくいもの、例えばAPIの要素間の関係が言語的に表現されるものではないため、規約として扱わなければならない、というようなコストも存在します。これが抽象化に機械的な意味を持たせる能力を低下させ、結果として定型的なコードをさらに増やすことになるのです。

Javaでのデータオブジェクトを使ったプログラミングには、歴史的にひとつの定石がありました。変更可能なデータキャリアをモデリングする次のようなテクニックを、誰でも見たことがあるでしょう。

class AnInt {
    private int val;

    public AnInt(int val) { this.val = val; }

    public int getVal() { return val; }

    public void setVal(int val) { this.val = val; }

    // More boilerplate for equals, hashCode, toString
}

ここでは、valという名前が公開APIの3ヶ所に現れています — コンストラクタの引数と2つのアクセサメソッドです。この3つのvalが同じものを指しているということは、どこにも表現されていませんし、そうしなければならない必然性もありません。単に名称規約でしかないのです。あるいはsetVal()で設定された最新の値をgetVal()が返すというのは、せいぜい設計書に記載される程度のものです(実際には、それさえない場合がほとんどです)。このようなクラスの操作は、定石以外の何ものでもありません。

それに対してRecordは、もっと強力なコミットメントを持っています — x()アクセサとコンストラクタ引数のxは、Recordでは同じ値を示します。その結果、コンパイラがこれらメンバに対して妥当なデフォルト実装を導出できるということだけでなく、フレームワークが構築や状態アクセスプロコトルと、それらのインターフェースを機械的に判断して、JSONやXMLへのマーシャリングなどの動作を導出することが可能になるのです。

細則

前述のように、Recordにはいくつかの制約があります。Recordのインスタンスフィールド(Recordヘッダに記述されたコンポーネントに対応します)は暗黙的にfinalである、他のインスタンスフィールドを持つことはできない、他のクラスをextendすることはできない、Recordクラスは暗黙的にfinalである、といったものです。それらを除けば、コンストラクタやメソッド、静的フィールド、型変数、インターフェースなど、他のクラスとほとんど同じものを持つことが可能です。

これらの制約と引き換えに、Recordは、標準コンストラクタ(状態記述にマッチしたシグネチャを持つもの)の暗黙的実装、状態コンポーネント毎の読み取りアクセサ(コンポーネントと同じ名称)、状態コンポーネントのprivate finalフィールド、状態をベースとしたequals()hashCode()toString()といったObjectメソッドの実装を自動的に提供してくれるのです。(将来的にJava言語がデコンストラクションパターン(deconstruction pattern)をサポートした時には、Recordも自動的にデコンストラクションパターンをサポートすることになります。) 暗黙的なコンストラクタやメソッドの内容が不適切である場合は、Recordの定義で"オーバーライド"したり(ただし暗黙のスーパークラス(java.lang.Recordで指定された制約に従う必要があります)、追加メンバを(制約の範囲内で)記述することも可能です。

その一例として、コンストラクタ内で状態を検証するように実装を変更したい場合が考えられます。例えばRangeクラスでは、範囲の最低値が最高値よりも高くないことをチェックしたいでしょう。

public record Range(int lo, int hi) {
    public Range(int lo, int hi) {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
        this.lo = lo;
        this.hi = hi;
    }
}

実装には何の問題もありませんが、ある意味で残念なことに、単純なバリデーションチェックを行うだけで、コンポーネントの名前をさらに5回繰り返さなくてはなりません。開発者がこのような不変条件のチェックを望まないことは想像に難くありません。Recordを使ってせっかく不要になった定形コードを、また元に戻したいとは思わないからです。

このような要求はごく普通である上に、入力値の妥当性チェックは重要なものなので、Recordでは、標準コンストラクタを明示的に記述するために、コンパクトな形式を特別に用意しています。この形式では、引数リストをすべて省略することが可能(状態記述と同じであると仮定される)で、コンストラクタの引数は、コンストラクタの終了時、暗黙的にフィールドにコミットされます。(コンストラクタ引数自体は変更可能(mutable)です。これにより、例えば最低値に適切な制限を設けるなどの方法で状態を正規化したい場合には、コンストラクタ引数を変更するだけで実現することができます。) 以下に示すのは、先程のRecordの記述をコンパクトにしたバージョンです。

public record Range(int lo, int hi) {
    public Range {
        if (lo > hi)
            throw new IllegalArgumentException(String.format("%d, %d", lo, hi));
    }
}

これにより、状態記述から機械的に導出できないコードのみを読めばよいという、望ましい結果を得ることができるのです。

使用例

すべてのクラスが — たとえデータ中心のクラスであったとしても — Record化するのに相応しい訳ではありませんが、それでもユースケースはたくさんあります。

Javaにおいて一般的に求められる機能のひとつに複数値のリターン、つまりメソッドから一度に複数の値を返したい、というものがあります。これができないために、APIで回避策を取らなくてはならない場合が少なくありません。コレクションを走査して最小値と最大値を返すメソッドのペアを考えてみましょう。

static<T> T min(Iterable<? extends T> elements,
                Comparator<? super T> comparator) { ... }

static<T> T max(Iterable<? extends T> elements,
                Comparator<? super T> comparator) { ... }

どちらも簡単に記述できますが、不満な部分があります — 両方の限界値を取得するためには、リストを2回走査しなければなりません。これは1回のみの走査に比べると非効率な上に、コレクションの走査と並列して変更処理が行われる場合には、一貫性のない値が得られる可能性があります。

意図的にこのようなAPIを公開したかった、という場合もあるかも知れませんが、もっとよいAPIを書くための作業量が多過ぎるのでこのようなAPIになった、という可能性の方が高いでしょう。要するに、両方の限界値をワンパスで返すためには、両方の値を一度に返す方法が必要なのです。クラスを宣言すればもちろん可能ですが、大部分の開発者は別の方法を探そうとするでしょう。その理由となるのは、ヘルパクラスを宣言することの構文的なオーバヘッドに他なりません。専用の集合を記述するコストが低くなれば、当初希望していたであろうAPIに変更するのは簡単です。

record MinMax<T>(T min, T max) { }

static<T> MinMax<T> minMax(Iterable<? extends T> elements,
                           Comparator<? super T> comparator) { ... }

もうひとつの一般的な例は、複合マップキー(compound map key)です。あるユーザがある機能を最後に利用した時間、というように、2つの独立した値を組み合わせたキーを持つMapが必要な場合があります。personとfeatureを組み合わせてキーにするHashMapを使えば、これを簡単に実現できます。しかしながら、既存のPersonAndFeature型がない場合には、コンストラクタや等価比較、ハッシュなどの定形コードを用意して記述しなくてはなりません。これも可能なことですが、楽をしたいという意識が働いて、例えば人名と機能名をつなげてマップのキーにするようなことをすると、可読性の低い、エラーの起きやすいコードが出来上がります。Recordを使えば、これを直接行うことができるのです。

record PersonAndFeature(Person p, Feature f) { }
Map<PersonAndFeature, LocalDateTime> lastUsed = new HashMap<>();

複合データのニーズは、マップキーと同じようにストリームでもよく見られます — その結果、同じような偶発的な問題に直面して、次善のソリューションに到達することになるのです。例えば、得点の多いプレーヤをランク付けするような、派生的な数量に対してストリーム操作を実行する場合を考えてみましょう。これは次のようき記述することができます。

List<Player> topN
        = players.stream()
             .sorted(Comparator.comparingInt(p -> getScore(p)))
             .limit(N)
             .collect(toList());

これは比較的簡単ですが、スコアに何らかの計算処理が必要な場合はどうでしょうか?そうすると、スコアをO(n)回ではなく、O(n^2)回計算することになります。Recordを使えば、ストリームのコンテンツに何らかの派生データを一時的にアタッチして、結合したデータを操作した後に、望む形に再度投影することが簡単にできます。

record PlayerScore(Player player, Score score) {
    // convenience constructor for use by Stream::map
    PlayerScore(Player player) { this(player, getScore(player)); }
}

List<Player> topN
    = players.stream()
             .map(PlayerScore::new)
             .sorted(Comparator.comparingInt(PlayerScore::score))
             .limit(N)
             .map(PlayerScore::player)
             .collect(toList());

このロジックをメソッド内部に収めれば、Recordをメソッド内でローカルに宣言することもできます。

ツリーノード、DTO(Data Transfer Object)、アクタシステムのメッセージなど、Recordが明らかに有効なケースは他にもたくさんあります。

楽をしよう

これまでの例に共通するのは、Recordを使わなくても同じ結果は得られるが、構文上のオーバーヘッドを避けて楽をしたい、ということです。正しい抽象化をコードするよりも、多少難があっても既存の抽象化を再利用したい、Objectメソッドの実装を省略して楽をしたい(このようなオブジェクトをマップキーとして使って微妙なバグを起こしたり、toString()の値が役に立たなくてデバッグが難しくなるような場合もありますが)というのは、誰もが望むものです。

自分たちが望むものを簡潔に記述できる、ということには、2種類のメリットがあります。ひとつは明白で、すでに正しく動作することが分かっているコードに対するメリットですが、そこまで明白ではないメリットとして、正しく動作するコードをより多く手にすることができる、というものがあります。適切なコードを記述するために必要な作業負担が低くなれば、楽をしたいという欲求が少なくなるからです。ローカル変数型推論にも同じような効果がありました。変数宣言のオーバーヘッドが少なくなると、複雑な計算式を単純な式に分割することが多くなり、結果的に可読性が向上し、エラーの起きにくいコードになったのです。

選ばれない道

Javaのデータ集約のモデリング — 頻繁に行うものですが — に約束事が多いということには、誰もが同意してくれるでしょう。しかし残念ながら、その同意は文法レベルまでなのです — Recordがどの程度の柔軟性を備えるべきか、どこまでの制約であれば受け入れられるのか、どのようなユースケースが最も重要か、といった点については、さまざまな意見が広く(そして声高く)喧伝されているという状況です。

選ばれない選択肢(road not taken)の代表的なひとつが、Recordを拡張して変更可能なJavaBeanクラスを代替する、というものです。この選択肢には、Record化の可能なクラス数が大幅に広がるという、明らかなメリットがありますが、それに伴うコストも重大です。複雑でアドホックな機能を正当化するのは難しく、他の機能と相互作用する可能性も驚くほど大きくなります。現在一般的に使用されているさまざまなJavaBeanパターンから機能設計を導き出そうとすれば、きっとそうなるはずです。言語レベルでサポートするほど一般的なユースケースなのか、という議論も当然起きるでしょう。

ですから、Recordの主目的を定形コードの削減に置くというのは、表面的には魅力的なのですが、それよりも意味論的な問題としてアプローチすることを優先したのです。データ集約をより適切に言語レベルで直接モデル化し、開発者が容易に正当化できるような意味論的基盤を提供するにはどうすればよいのか?(これを構文的ではなく、意味的な問題として扱うアプローチは、Enumで成功を収めました。) そして、Javaに対して出した論理的結論が、"Recordは名前付きtupleである"だったのです。

制約はなぜ必要か?

Recordの持つ制約は、最初は偶発的なものに思われるかも知れませんが、実はすべて共通の目標から生じたものなのです。その目標を要約すれば、"Recordは状態(state)であり、状態そのものであり、状態以外の何ものでもない"ということになります。具体的には、Recordの等価性(equality)を状態記述内に宣言された状態からのみ導出されるものにしたいのです。変更可能なフィールドや他のフィールド、スーパークラスなどを可能にすると、Recordの等価性において特定の状態コンポーネントを無視したり(等価性判定に変更可能なコンポーネントを含めることには疑問の余地があります)、あるいは状態記述に含まれない他の状態(インスタンスフィールドやスーパークラスの状態など)に依存する状況が発生することになります。これは機能を非常に複雑なものにする(どのコンポーネントが等価計算に含まれるのか、個別に指定する必要が生じるのは間違いないので)と同時に、不変条件の意味的メリット(状態を取り出して、その結果から新たなRecordを構築した場合、得られたRecordが元のものと等価である、というような)を弱めることにもなります。

構造型tupleではだめなのか?

Recordの設計上の中心が名前付きtupleであることを聞いたある人から、構造型tupleにしなかった理由を聞かれたことがあります。答えは単純で、名前のためです。firstNamelastNameというコンポーネントを持つPerson Recordは、StringStringのtupleよりも明確で安全です。クラスはコンストラクタを通じて状態のバリデーションをサポートできますが、tupleはそうではありません。クラスは状態から派生した振る舞いを追加することが可能ですが、tupleでは不可能です。適切な設計のクラスであれば、クライアントコードに影響することなく、Recordとの間で相互にマイグレーションすることができますが、tupleではできません。また構造型tupleでは、PointRange(いずれも整数のペア)を区別することができません。この2つは意味的にまったく違うにも関わらず、です。(名前付きと構造型という表現の選択に直面したことは、Javaでは以前にもありました。Java 8 では構造型ではなく名前付きの関数タイプが選択されたのですが、その理由の多くは、今回構造型tupleではなく名前付きを選んだのと同じものでした。)

今後の展望

JEP 355ではRecordを独立した機能として述べていますが、Recordの設計は、シールド型(sealed type)やパターンマッチング、インラインクラスといった、現在開発中である他の機能と併用した場合の動作を念頭に行われています。

Recordは直積型(product type)の一形式です。そのように言われるのは、Recordの状態空間が、そのコンポーネントの状態空間のデカルト積(cartesian product)(のサブセット)であるからです。直積型は、一般的に言う代数データ型の半分を形成するもので、残る半分は直和型(sum type)と呼ばれます。直和型は、"ShapeはCircleまたはRectangle"というような区別可能な共用体(discriminated union)ですが、現在のJavaでは(非公開コンストラクタのようなトリックを使わない限り)表現することができません。シールド型はこの制限に対処するもので、クラスやインターフェースが特定の型によってのみ拡張可能であることを、直接的に宣言できるようにします。直積の直和は、複雑なドキュメントのノードのように、複雑なドメインを柔軟性を持って型安全にモデリングする上で、ごく一般的かつ有用なテクニックです。

直積型を持つ言語では、パターンマッチングによる分解をサポートするのが一般的ですが、Recordは簡単な分解をサポートできることを最初から考慮して設計されています(Recordの要件である透過性は、この目標によるものです)。パターンマッチの最初のステージでは型パターンのみがサポートされますが、レコードの分解パターンも間もなく提供される予定です。

最後に、Recordは(常にではありませんが)インライン型と併用されることがよくあります。Recordとインライン型の両方の要件を満たす集約(多くの場合そうですが)であれば、Record性とインライン性をInline Recordとして組み合わせることが可能です。

要約

Recordは、クラスを使ってデータをシミュレートするのではなく、データをデータとして直接的にモデリングする手段を提供するうことにより、一般的なクラスの多くが持つ冗長性を低減します。さらに、複数値のリターンやストリームの結合、複合型キー、ツリーノード、DTOなど、共通的なユースケースをモデリングするさまざまな状況において使用することが可能です。Recordはそれ自体でも有用ですが、シールド型やパターンマッチング、インラインクラスなど、今後登場する機能との連動性も良好です。

著者について

Brian Goetz氏はOracleのJava言語アーキテクトで、JSR-335(Lambda Expressions for the Java Programming Language)の仕様リーダを務めました。ベストセラー"Java Concurrency in Practice"の著者である氏は、Jimmy Carter氏が大統領であった頃からプログラミングに情熱的に取り組んでいます。

この記事に星をつける

おすすめ度
スタイル

こんにちは

コメントするには InfoQアカウントの登録 または が必要です。InfoQ に登録するとさまざまなことができます。

アカウント登録をしてInfoQをお楽しみください。

HTML: a,b,br,blockquote,i,li,pre,u,ul,p

コミュニティコメント

HTML: a,b,br,blockquote,i,li,pre,u,ul,p

HTML: a,b,br,blockquote,i,li,pre,u,ul,p

BT

あなたのプロファイルは最新ですか?プロフィールを確認してアップデートしてください。

Eメールを変更すると確認のメールが配信されます。

会社名:
役職:
組織規模:
国:
都道府県:
新しいメールアドレスに確認用のメールを送信します。このポップアップ画面は自動的に閉じられます。