@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) o;
Range range return low == range.low &&
== range.high;
high }
@Override
public int hashCode() {
return Objects.hash(low, high);
}
@Override
public String toString() {
return "[" + low + "; " + high + "]";
}
}
これを、次のように1行で表現できるようになるのです。
// these are "components"
Range (int low, int hight) { } record
もちろん、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/@Value
や Data 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 == Integer.MAX_VALUE ? Integer.MIN_VALUE : ++i;
i // then: combining two `increment`s yields a bijective function
// (this requires no additional proof or consideration)
<Pair> incrementPair =
UnaryOperator-> new Pair(
pair .applyAsInt(pair.first()),
increment.applyAsInt(pair.second())); increment
Pair::first
と Pair::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);
完全な透明性が保証されているため、隠蔽された状態を見逃すことはありません。 つまり、返り値のインスタンスは、渡された range
の low
と hight
を正確に入れ替えた値になるということです。
with
ブロック将来 Java にはインスタンス(基本的には不変オブジェクト)の複製を簡単に作成するための with
ブロックの導入が予定されています。 次のような記法になるでしょう。
= new Range(5, 10);
Range range // SYNTAX IS MADE UP!
= range with { low = 0; }
Range newRange // range: [5; 10]
// newRange: [0; 10]
with
記法を導入する動機は、Range
の API が宣言と結びついていることです。 前の例と同様に、newRange
は low
コンポーネントを除いて range
と同一であることが保証されています。 運搬が失敗するような隠蔽された状態は存在しません。 それに、言語として行う処理も限られています。
low
とhight
)を宣言し、アクセサを介して値を代入しますwith
ブロックを実行します(この機能が実際に導入されるのはかなり先のことですし、取り下げられる場合や、劇的に変化する場合もあり得ます)
あるインスタンスをバイト列、あるいは 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
なら避けられたはずの定型的なコードになるでしょう。
**@Data/@Value**
のほうが優れている理由Lombok は単純にソースコードを生成するだけで、何らかのセマンティクスを追加することはありません。 必要に応じて自由にクラスを変更できるようになっています。 より強い保証に基づく恩恵を受けられるわけではありませんが、Lombokは将来的に分解のためのメソッドを生成できるようになる予定です。
Lombok はセマンティクスを追加しません
(著者としてはLombokを使うことを推奨していません。コンパイラーの内部APIに強く依存しているからです。内部APIはいつでも変化する可能性があるので、Javaをマイナーアップデートするだけでプロジェクトはビルドできなくなってしまうかもしれないのです)
Data class
のほうが優れている理由Kotlinのドキュメントには次のように記述されています。
データを保持するためのクラスを作成する場合はよくあります。 そういうクラスの基本的な機能やユーティリティ機能はデータそのものから機械的に導出できる場合がほとんどです。
データを保持するというセマンティクスは理解しているでしょうけど、それよりも、ソースコードの生成など機能を導出することに注目しましょう。 実際のところ、Data class
はRecord
(不変の「コンポーネント」、状態の隠蔽等)よりも強力なクラス構築ツールですが、Lombokのようになんでもできるわけではありません(継承できないし、独自のcopy
メソッドも定義できません)。 その代わり、Data class
は Record
のように強力なセマンティクスを与えてくれません。 だから、KotlinではRecord
と完全に同じ機能を構築できないのです。
Data class
は弱いセマンティクスを追加します。
頭に血が上ったコメントを入力し始める前に少し落ち着いてください。 価値観を押し付けたいわけじゃありません。 全く別のコストと利点のトレードオフに関する話であって、あなたにとってKotlinが役に立っているならそれで充分です。
注意:Kotlinの@JvmRecordには大きな落とし穴があるので注意してください。 「見てよ。Data class
がRecord
になるんだって。完璧じゃん」(ちょっと何を言ってるのか分からないですね) 同じように考えている人がいれば、止めたほうがいいと伝えるし、できるだけ再検討してもらうようにします。 自分が何をしようとしているのかちゃんと理解して欲しいのです。
Data class
をRecord
の要求する全てのルールに従わせたところで、結局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
の数学に基づく強力なセマンティクスはクラス設計の対象範囲を狭くしてくれるし、他の候補に提供できない、たとえできても不確かになる強力な機能を実現します。
開発者の自由とプログラミング言語の機能はトレードオフの関係にあります。 著者はそういう関係に満足しているし、今後数年間でその可能性が最大限に発揮されることを楽しみにしています。