Why Java's Records Are Better* Than Lombok's @Data and Kotlin's Data Classes

この文章はNicolai Parlogによる「Why Java's Records Are Better* Than Lombok's `@Data` and Kotlin's Data Classes」を日本語に翻訳したものです。
元の文章は https://nipafx.dev/java-record-semantics/ からアクセス可能です。
著者のライセンス表示は https://nipafx.dev/license/  からアクセス可能です。
CC-By-NC 4.0 でライセンスされています。

JavaのRecord、Lombokの@Data、KotlinのData classはどれも定型的なコードを削減してくれる機能を実現しますが、お互いにそれほど似ていません。 そして、Recordの強力なセマンティクスと生成物に関する重要な利益は、他の2種類の機能よりも優れています(クリック数を稼ぎたかったので大げさに書きました。状況によって変化します。どんな場合でも優れているわけではありません)。

伝統的なPOJOクラスをRecordに変換できることはすでにご存じだと思います。

    class Range {
    
            private final int low;
            private final int high;
    
            public Range(int low, int high) {
                    this.low = low;
                    this.high = high;
            }
    
            public int getLow() {
                    return low;
            }
    
            public int getHigh() {
                    return high;
            }
    
            @Override
            public boolean equals(Object o) {
                    if (this == o)
                            return true;
                    if (o == null || getClass() != o.getClass())
                            return false;
                    Range range = (Range) o;
                    return low == range.low &&
                                    high == range.high;
            }
    
            @Override
            public int hashCode() {
                    return Objects.hash(low, high);
            }
    
            @Override
            public String toString() {
                    return "[" + low + "; " + high + "]";
            }
    
    }

これを、次のように1行で表現できるようになるのです。

    //          these are "components"
    record Range (int low, int hight) { }

もちろん、Lombok の @Data@Value アノテーション(必要に応じて使い分けます)は数年前から数行で同じことを表現できるようになっています。

    @Data
    class Range {
    
            private final int low;
            private final int high;
    
    }

Kotlin に慣れている人なら、Data class で同じことを表現できるのをご存じでしょう。

    data class Range(val low: Int, val high: Int)

基本的にはどれも同じ機能であると理解して大丈夫だと思いますか。 もちろんだめです。本質的に異なる機能だからです。 Record は定型的なコードを削減することを目的にしていません。 それはセマンティクスのもたらす単なる(好ましい)結果でしかありません。

実際に、同じ機能ではないのです

残念ながらこの事実は簡単に忘れられてしまう場合がほとんどです。 定型的なコードの削減は疑う余地のない素敵な効果で、実演して見せるのも簡単なため、人目を惹きやすいのです。 一方、セマンティクスやその利益が目立つことはありません。 公式ドキュメントですら定型的なコードを削減できることに着目しています。 JEP 395のほうがセマンティクスをちゃんと説明しているほどです。 生成物に関する利益の説明が本質的に曖昧になりやすいのは原因と言えるでしょう。 だから、この記事でその説明を試みたいと思います。

最初にセマンティクスを、それから利益について説明します。


Record のセマンティクス

JEP 395 には次のように記述されています。

[Records] は不変データを透過的に運搬する運び手となるクラスです

つまり、Record を作るということは、その型がデータを表すものであるということを、コンパイラやあなたの同僚や世界中に表明していることになるのです。 正確に言うと、データに浅い不変性と透過的なアクセスを与えるという意味になります。 これが中心的なセマンティクスで、他の要素は全てここを起点にします。

作りたい型がこのセマンティクスに適合しないなら、Recordとして作るべきではありません。 定型的なコードを削減できるとか、@Data/@ValueData class と同じだとか、そういう嘘に騙されると、設計はぼろぼろになってしまうし、いつかしっぺ返しを食らうことになるでしょう。 気を付けてください。 (きつい言葉を使ってしまい申し訳ありません。どうしても説明しておかなければならないことなんです)

透過性と制約

透過性について詳しく検討してみましょう。 Record の透過性に関する考え方は、プロジェクト Amber の設計ドキュメントに次のように記述されています。

Record のための API は個々の状態と総合的な状態をモデル化するためのものです

この考え方を実現するにはいろいろな制約条件が必要になります。

これはいったいなぜでしょうか。 Lombok なら余分なフィールドを定義できます。 Kotlin の Data class でも private コンポーネント(これは Record の用語です。Kotlin では「主コンストラクタ引数(primary constructor parameters)」と呼びます)として余分なフィールドを定義できます。 どうして Record にそれほど厳しい制約が必要なのでしょうか。 その答えを知るには少しばかり数学の知識が必要です。

数学の話(苦手な人はごめんなさい)

多数の要素の集まりを「集合」と呼びます。 C は全ての色 { blue, gold, … }からなる集合、N は全ての自然数 { 0, 1, …} からなる集合、というように使うことができます。 Java では { -2147483648, …, 0, …, 2147483647} からなる有限集合を int と呼びます。そして Integer を期待したところに null が表れると例外を送出します。 同様に、あらゆる全ての文字列と null を加えた無限集合を String と呼びます。

型とは集合のことです。 そして、集合の値はその集合に対応する型の合法的な値です。 集合論「集合に関する学問は、数理論理学の一分野」(Wikipedia の説明)は、型理論「型システムに関する学術的な研究」(Wikipedia の説明)と結びついています。 そして、プログラミング言語の設計は型理論に基づいています。

型は集合

簡単な例として整数の対 { (0, 0), (0, 1), … } について考えてみましょう。 簡単な Java のクラスなら次のようになるでしょう。

    class Pair {
    
            private final int first;
            private final int second;
    
    }

それぞれの対に対応する Pair オブジェクトを用意すれば上手く表現できそうです。 しかし、集合の構造に関するより多くの知識があれば、それ以上の洞察が得られることが分かるでしょう。 具体的には、今表現したい集合は、全ての整数と全ての整数の組み合わせからなる集合だということです。 集合論ではこれを積と呼び、 $$int \times int$$ と記述します(乗算演算子の左右の型は被作用子と呼びます)。

とても簡潔な表現ですね。 集合論は、この積集合に関数を適用することを表現するためのあらゆる道具がそろっています。 例えば、単独の被作用子に作用する1つ以上の関数を合成した関数が、元の性質を維持した全ての被作用子により特徴付けられた関数になる、ということが挙げられます(単射全単射など)。

例えば次のようなコードです。

    // given: bijective function from int to int
    IntUnaryOperator increment =
            i -> i == Integer.MAX_VALUE ? Integer.MIN_VALUE : ++i;
    // then: combining two `increment`s yields a bijective function
    //       (this requires no additional proof or consideration)
    UnaryOperator<Pair> incrementPair =
            pair -> new Pair(
                    increment.applyAsInt(pair.first()),
                    increment.applyAsInt(pair.second()));

Pair::firstPair::second というアクセサに注目してください。 これらのアクセサは前のクラス定義に存在しないため、追加しなければなりません。 そうしないとそれぞれのコンポーネント(被作用子)へ別々に関数を適用できないので、Pair クラスを int の対として扱うことができません。 同様に、対を再構築するため、それぞれのコンポーネントを引数とするコンストラクタも必要です。

もう少し一般的な言葉で説明しましょう。 ある型を集合論で扱うということは、全ての被作用子へアクセスできるようにしなければならないし、被作用子の組(タプル)から型のインスタンスを復元できるようにしなければなりません。 どちらも可能な型のことを型理論では直積型と呼びます(直積型のインスタンスをタプルと呼びます)。 直積型はいくつかの用途で役立ちます。

JEP 395 でも言及されているように、Record はタプルよりも便利です。

Record は名前付きのタプル(nominal tuples)と見做すことができます

Record は構造ではなく名前(クラス名)で識別するから「名前付きの(nominal)」という言葉で修飾しています。 つまり、$$int \times int$$ をモデル化した2種類の record 型があるとしても、同一の集合として扱うことはできないということです(例えば Pair(int first, int second)Range(int low, int hight))。 また、Record のコンポーネントには添え字でアクセスできません(range.get1() はダメです)。名前でアクセスします(record.low() はOKです)。 (他にも、Record のアクセサと正準形コンストラクタは embedding-projection pair を形成することになっています。ちゃんと理解できてないので説明もできません)

まとめ

要点を整理します。 Record は直積型を表現するために設計された機能です。 全てのコンポーネントにアクセスできるし、隠蔽された状態もなく、同じ状態を再構築できます。 だから Record は不変データを透過的に運搬する運び手になれるのです。

Record は直積型だから透過的な振る舞いを実現できるのです

そういう理由があるから、コンパイラーはアクセサを生成します。 そういう理由があるから、アクセサの名前や返り値のデータ型を変更できません。 そういう理由があるから、オーバーライドするときはできるだけ慎重にやらなければなりません。 そういう理由があるから、コンパイラーは正準形コンストラクタを生成します。 そういう理由があるから、継承が禁止されています。


Record の優れているところ

代数構造のもたらす最大の利点は、アクセサと正準形コンストラクタの構成する構造が、情報量を減らすことなく、構造化された方法でインスタンスを再作成できるようになることです。

分解パターン

JEP 405 は、Java のパターンマッチング機能を拡張した Record パターンと配列パターンを提案しています。 この提案が実現すると、Record と配列を構成するコンポーネントを別々に取り出してチェックできるようになります。

    if (range instanceof Range(int low, int high) && high < low)
            return new Range(high, low);

完全な透明性が保証されているため、隠蔽された状態を見逃すことはありません。 つまり、返り値のインスタンスは、渡された rangelowhight を正確に入れ替えた値になるということです。

with ブロック

将来 Java にはインスタンス(基本的には不変オブジェクト)の複製を簡単に作成するための with ブロックの導入が予定されています。 次のような記法になるでしょう。

    Range range = new Range(5, 10);
    // SYNTAX IS MADE UP!
    Range newRange = range with { low = 0; }
    // range: [5; 10]
    // newRange: [0; 10]

with 記法を導入する動機は、Range の API が宣言と結びついていることです。 前の例と同様に、newRangelow コンポーネントを除いて range と同一であることが保証されています。 運搬が失敗するような隠蔽された状態は存在しません。 それに、言語として行う処理も限られています。

(この機能が実際に導入されるのはかなり先のことですし、取り下げられる場合や、劇的に変化する場合もあり得ます)

直列化

あるインスタンスをバイト列、あるいは JSON や XML、あるいは何らかの外部表現へ変換したり、復元したりするには、インスタンスをばらばらの値へ分解したり、ばらばらの値を統合する方法が必要です。 Record を使えばそれが容易に実現できることが分かるでしょう。 全ての状態を公開し、正準形コンストラクタを提供するだけでなく、それらを扱う構造化された方法を提供しているため、そのためのリフレクション API を非常に簡単に利用できるからです。

the Inside Java Podcast のエピソード 14 では、Record がどれだけ直列化の処理を変化させたのか詳しく説明しています(Spotify でも視聴できます)。 この Twitter のスレッドなら短い文章で説明しています。

定型的なコードについて

少しだけ定型的なコードの話をしましょう。 前のほうで説明したように、Record を直積型として扱うためには次のようなコードが必要になります。

明示的に説明していませんが、これらは (0, 0) = (0, 0) の場合でも適切に動作しなければなりません。 つまり適切な equals の実装が必要になるので、同時に適切な hashCode の実装も必要になるということです。 そしてコンパイラーは必要な全てのコードを生成してくれます(toString も生成してくれます)。 代数構造に基づいて考えれば自然に導かれる結論なので、自分で実装するとしてもたいした手間ではありません。


Record の劣っているところ

Record のセマンティクスは利用するツールによって制限されることになります。 前に説明したとおり、不要なフィールドにより隠蔽された状態を追加することはできませんし、アクセサの名前や返り値のデータ型も変更できません。 おそらく、返り値として返す値を変更するのもやめたほうがいいでしょう。 Record はコンポーネントの値を再代入することを認めていません。 つまりコンポーネントを実装するフィールドは final になるということです。 それに、クラスを継承することもできません(インターフェイスを実装することはできます)。

では、そういう禁止されたことが必要になったらどうすればいいでしょうか。 その場合、あなたに必要なのは Record ではなく普通のクラスです。 そのせいで何らかの機能の10%を変更することになるとしたら、残りの90%は Record なら避けられたはずの定型的なコードになるでしょう。

Lombokの **@Data/@Value** のほうが優れている理由

Lombok は単純にソースコードを生成するだけで、何らかのセマンティクスを追加することはありません。 必要に応じて自由にクラスを変更できるようになっています。 より強い保証に基づく恩恵を受けられるわけではありませんが、Lombokは将来的に分解のためのメソッドを生成できるようになる予定です。

Lombok はセマンティクスを追加しません

(著者としてはLombokを使うことを推奨していません。コンパイラーの内部APIに強く依存しているからです。内部APIはいつでも変化する可能性があるので、Javaをマイナーアップデートするだけでプロジェクトはビルドできなくなってしまうかもしれないのです)

Kotlinの Data class のほうが優れている理由

Kotlinのドキュメントには次のように記述されています。

データを保持するためのクラスを作成する場合はよくあります。 そういうクラスの基本的な機能やユーティリティ機能はデータそのものから機械的に導出できる場合がほとんどです。

データを保持するというセマンティクスは理解しているでしょうけど、それよりも、ソースコードの生成など機能を導出することに注目しましょう。 実際のところ、Data classRecord(不変の「コンポーネント」、状態の隠蔽等)よりも強力なクラス構築ツールですが、Lombokのようになんでもできるわけではありません(継承できないし、独自のcopyメソッドも定義できません)。 その代わり、Data classRecord のように強力なセマンティクスを与えてくれません。 だから、KotlinではRecordと完全に同じ機能を構築できないのです。

Data class は弱いセマンティクスを追加します。

頭に血が上ったコメントを入力し始める前に少し落ち着いてください。 価値観を押し付けたいわけじゃありません。 全く別のコストと利点のトレードオフに関する話であって、あなたにとってKotlinが役に立っているならそれで充分です。

注意Kotlinの@JvmRecordには大きな落とし穴があるので注意してください。 「見てよ。Data classRecordになるんだって。完璧じゃん」(ちょっと何を言ってるのか分からないですね) 同じように考えている人がいれば、止めたほうがいいと伝えるし、できるだけ再検討してもらうようにします。 自分が何をしようとしているのかちゃんと理解して欲しいのです。

Data classRecordの要求する全てのルールに従わせたところで、結局Record以上の機能を提供することはできません。 Kotlinにはまだ透過的なタプルという概念が存在しませんし、@JvmRecord data classには普通のData class以上の仕事はできません。 Recordに自由を与え、Data classに強い保証を与えるのは、どちらにとっても最悪の状況になります。

@JvmRecordは相互互換性のためだけに存在しています。提案仕様にもそう書かれています。

次のような2種類のユースケースを除いて、Kotlinで JVM record の宣言が必要になる場面はほとんどありません。 ・ABI(アプリケーションバイナリインターフェイス)を保ちつつ、既存のJava recordをKotlinへ移植するため ・KotlinのクラスにRecordの属性やコンポーネントを生成するのは、将来 Java のリフレクションフレームワークがRecordをイントロスペクションするときに使う可能性があるため


まとめ

今のRecordは、全体的にLombokの@Data/@ValueやKotlinのData class、Scalaのcase classと比べて優れているとも劣っているとも言えません。 しかし、Recordの数学に基づく強力なセマンティクスはクラス設計の対象範囲を狭くしてくれるし、他の候補に提供できない、たとえできても不確かになる強力な機能を実現します。

開発者の自由とプログラミング言語の機能はトレードオフの関係にあります。 著者はそういう関係に満足しているし、今後数年間でその可能性が最大限に発揮されることを楽しみにしています。