Data Classes and Sealed Types for Java

このエントリは以下の記事を基にしています。
This entry is based on the following article written by Brian Goetz (Java Architect, Oracle).
https://cr.openjdk.java.net/~briangoetz/amber/datum.html

このドキュメントはJava言語におけるデータクラスとsealed typeの可能性を調査したもので、以前記載したData Classes in Javaのアップデートです。調査目的の文書であり、Java言語の特定のバージョンにおける特定の機能の計画ではありません。

Data Classes for Java
http://cr.openjdk.java.net/~briangoetz/amber/datum_2.html

(訳注:2019/2時点であり、2019/10/02現在ではJEP 359とJEP 360でそれぞれRecordとSealed Typeが提示されています)。

JEP 359: Records (Preview)
http://openjdk.java.net/jeps/359
JEP 360: Sealed Types (Preview)
http://openjdk.java.net/jeps/360

Background

よく、「Javaは冗長すぎる」だの、「お作法が多すぎる」だのという苦情を耳にします。これは、クラスが柔軟に様々なプログラミング・パラダイムをモデリングできるものの、それにはモデリングのオーバーヘッドが伴うためです。プレーン・データキャリア(Plain Data Carrier)にすぎないクラスの場合、これらのモデリング・オーバーヘッドは、そのクラスが持つ値に見合っていない可能性があります。シンプルなデータキャリアクラスを責任を持って記述するためには、コンストラクタ、アクセサ、equals()、hashCode()、toString()など、低価値で反復的なコードを大量に記述する必要があります。それゆえ、開発者たちは時としてこうした重要なメソッドを省略する誘惑に駆られることがありますが、それが原因で驚くべき動作や貧弱なデバッグにつながったり、または「正しい形」を持っていてさらに別のクラスを定義したくないため、完全に適切ではない代替クラスを使用したりすることになってしまいます。

IDEはこうしたコードの大部分を作成するのに役立ちますが、コードの作成は問題のほんの一部にすぎません。readerが何十行もの定型コードから「x、y、およびzの単純なデータキャリアです」という設計意図を抽出する支援をIDEはやりません。 ボイラープレート・コードにはよくバグが紛れこみます。可能であれば、隠れ場所を完全に排除するのが最善です。

プレーン・データキャリアに公式の定義はありませんし、”plain”がまさに何を意味しているのかという点には様々な意見があります。誰もSocketInputStreamがただのデータキャリアとは思っていないでしょう。これは (ネイティブリソースを含む) 複雑で不特定の状態を完全に隠蔽し、内部表現のように見えないインターフェイスコントラクトを公開します。

対して、以下は非常に明確です。

final class Point {
    public final int x;
    public final int y;
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
    // state-based implementations of equals, hashCode, toString
    // nothing else
}

上記のクラスは data(x, y) だけです。この表現は (x, y) で、そのインスタンス作成時に(x, y)のペアを受け入れて、この表現に直接格納します。この表現への直接アクセスを提供し、その表現から直接コアのObjectメソッドを導出します。 そして中央には、線を引く必要がある灰色の領域があります。

他のオブジェクト指向言語は、Scalaのcase class、Kotlinのdata class、C#のrecord classといった、データ指向クラスをモデリングするためのコンパクトな構文形式を調査してきました。これらには、クラスの状態の一部またはすべてをクラスヘッダーに直接記述できるという共通点がありますが、セマンティクスは異なります(フィールドの可変性またはアクセシビリティの制約、クラスの拡張性、およびその他の制限など) 。

TOUR OF SCALA – Case Classes
https://docs.scala-lang.org/tour/case-classes.html
Data Classes
https://kotlinlang.org/docs/reference/data-classes.html
Records for C#
https://github.com/dotnet/roslyn/blob/master/docs/features/records.md

クラス宣言で、状態とインターフェースの関係の少なくとも一部にコミットすることにより、多くの一般的なメンバーの適切なデフォルトを導き出すことができます。これらのメカニズム(「データクラス (data class) 」と呼びましょう)はすべて、Pointを次のようなものとして定義できるという目標に近づけようとしています。

record Point(int x, int y) { }

ここでの明快さとコンパクトさは確かに魅力的です。Pointは2つの整数コンポーネントxとyの単なるキャリアであり、この情報から、読者はコアのObjectメソッドの道理に適った正しい実装があることがすぐにわかります。そしてボイラープレートのページを読み解いて、セマンティクスについて自信を持って推論できるようにする必要はありません。ほとんどの開発者は、「もちろん、それが欲しい」と言うでしょう。さらに、存在するコードだけが明白ではないことを実際にするコードである、という状況に近づきます。読む量が減り、各行が有用であるという点で、コードの読み取りが簡単になります。

Meet the elephant

残念ながら、そのような普遍的なコンセンサスは構文の深さだけです。簡潔さを祝い終えた直後に、そのような構造の自然なセマンティクスと、どのような制限を受け入れるかー拡張可能なのか、フィールドは変更可能なのか、生成されたメソッドの動作やフィールドのアクセシビリティを制御できるのか、追加のフィールドとコンストラクターを使用できるのか、といった議論が始まります。

「群盲象を評す」の話のように、開発者はデータクラスのセマンティクスについてまさに異なる仮定を持ち込む可能性があります。これらの暗黙の仮定を公開するために、様々なポジションを命名しましょう。

Algebraic Annieなら、「データクラスは単なる代数的直積型です」と言うでしょう。Scalaのcase classのように、パターンマッチングとペアになっており、イミュータブルなものとして提供されます(そして、デザートには、Annieは密封されたインターフェースを注文するでしょう)。

Boilerplate Billyなら、「データクラスは、より良い構文を備えた単なる普通のクラスである」と言い、可変性、拡張、またはカプセル化の制約にぶつかるでしょう (Billyの兄弟であるJavaBean Jerryであれば「もちろん、これらはJavaBeansの代替品です。なので、可変性とgetter/setterが必要です」というでしょうし、彼の妹POJO Pattyであれば、彼女がエンタープライズPOJOに圧倒されていると述べ、Hibernateのようなフレームワークによってプロキシ可能になることを期待しています)。

Tuple Tommyなら、「データクラスは単なる名目上のタプルである」と言うでしょう。つまり、コアのObjectメソッド以外のメソッドを持つことを期待してはならず、これらは単なる最も単純な集約だ、ということです(彼は同じ名前の2つのデータクラスを自由に変換できるように、名前の消去を期待しているかもしれません)。

Values Victorなら、「データクラスは実際には、より透過的な値タイプです」と言うでしょう。

これらのすべてのペルソナは「データクラス」に賛成して団結していますが、データクラスが何たるかという点で異なる考えを持っているため、全員を満足させるソリューションはありません。

Encapsulation and boundaries

日々取り扱う状態関連のボイラープレートを痛感していますが、ボイラープレートは、より深い問題の症状に過ぎません。つまり、Javaは、すべてのクラスに対しカプセル化のコストに対して平等に支払うことを求めています。とはいえ、すべてのクラスが平等に恩恵を受けられるわけではありません。

確かに、カプセル化は不可欠です。状態へのアクセスを仲介し(なので、監視のない状態で操作できません)、表現をカプセル化する(なので、APIコントラクトに影響を与えずに進化させることができます)ことで、さまざまな境界を越えて安全かつ堅牢に動作できるコードを記述できます。

  • メンテナンスの境界:クライアントが異なるソースベース(または組織)にいる場合
  • セキュリティと信頼の境界:悪意ある方法で意図的に変更または使用しないことを完全に信頼していないため、クライアントに状態を公開したくない場合
  • 整合性の境界:クライアントの意図を信頼し、データをクライアントと共有することを望んではいるが、独自の表現の不変条件を維持するタスクをクライアントに負担したくないために、状態をクライアントに公開したくない場合
  • バージョン管理の境界:あるバージョンのライブラリに対してコンパイルされたクライアントが、後続のバージョンに対して実行されたときに引き続き動作することを保証したい場合。

しかし、すべてのクラスが境界を等しく評価するわけではありません。これらの境界を守ることは、KeyStoreやSocketInputStreamのようなクラスにとって不可欠ですが、Pointのようなクラスや典型的なドメインクラスにとっては価値がありません。packageまたはmoduleに対してprivateで、クライアントと一緒にコンパイルされるクラスや、クライアントを信頼しているクラス、そして保護が必要な複雑な不変条件を持たないクラスなどのような、多くのクラスにとって境界の保護はまったく関心がありません。これらの境界を確立し保護するコスト(コンストラクター引数を状態にマップする方法、状態から同等性のコントラクトを導出する方法など)はクラス全体で一定ですが、メリットはそうではないため、コストがメリットと一致しない場合があります。これは、Java開発者が「多すぎるお作法」として表現しているものです。作法には価値がないということではなく、十分な価値を提供できない場合でも、作法通りに呼び出すことを強制されます。

構築、状態へのアクセス、同等性から表現が完全に切り離されるという、Javaが提供するカプセル化モデルは、多くのクラスが必要とするもの以上のものです。境界との関係が単純なクラスは、クラスをその状態の薄いラッパーとして定義し、そこから状態、コンストラクト、等式、状態アクセスの関係を導出できる単純なモデルの恩恵を受けることができます。

さらに、表現をAPIから分離するコストは、定型的なメンバーを宣言するオーバーヘッドを上回ります。カプセル化は、その性質上、情報を破壊します。引数xを受け取るコンストラクターとx()というアクセサーを持つクラスの場合、それらがおそらく同じものを参照していることを示すための規則しかありません。この規則に頼ることはかなり安全な推測かもしれませんが、単なる推測にすぎません。人間が仕様(もし一つだけとしても)を読み上げてこの期待を確認せずに、ツールとライブラリコードがこの対応に機械的に依存できればよいのですが。

Digression — enums

問題が非常に一般的なもので単純なものをモデリングしているという場合、単純化は制約から生じています。ある程度の自由を手放すことで、すべてを明示的に指定する義務から解放されることを望んでいます。

Java 5で追加されたenumは、このようなトレードオフの優れた例です。型安全なenumパターンはよく理解されており、Java 5より前に(冗長ではあるが)表現しやすいものでした(Effective Java、1st Edition、item 21を参照)。言語に列挙型を追加する最初の動機は、このイディオムに必要な定型句に対するいらだちだったかもしれませんが、本当の利点はセマンティック(意味論)です。

Effective Java (2nd Edition) 2nd Edition
https://www.amazon.com/gp/product/0321356683

enumの重要な単純化は、インスタンスのライフサイクルを制限することでした。列挙型定数はシングルトンであり、インスタンス化はランタイムが管理します。シングルトンの認識を言語モデルに組み込むことにより、コンパイラは、タイプセーフな列挙パターンに必要な定型句を安全かつ正しく生成できます。また、enumは構文上の目標ではなく意味論上の目標から始まったため、enumが他の機能と積極的に対話できました。具体的には、enumを有効にしたり、コストなしで比較と安全なシリアル化するといったことです。

驚くべきことに、enumは、クラスが享受する他のほとんどの自由度を放棄せずに、構文およびセマンティックの利点を提供しました。 Javaの列挙は、他の多くの言語にあるような単なる整数の列挙ではなく、本格的なクラスです(いくつかの制限があります)。

このアプローチの成功をデータクラスで再現しようとする場合、最初の質問は次のとおりです。

  • どのような制約によって、必要なセマンティックおよび構文上のメリットがもたらされるか
  • そしてこれらの制約を受け入れるのか。

Priorities and goals

データクラスを主にボイラープレートの削減に関するものとして扱うのは表面的には魅力的ですが、データとしてデータをモデリングするという、セマンティックな目標から始めたいのです。目標を正しく選択すると、ボイラープレートがそれ自体を処理し、簡潔さに加えてさらなるメリットを享受できます。

では、「データをデータとしてモデル化する」とはどういうことでしょうか?また、あきらめなければならないものは何でしょうか?モデルを排除し、それによってモデルを単純化できる「単純な」データ集約は、クラスが享受する自由度のどの程度を必要としないのでしょうか?Javaのオブジェクトモデルは、オブジェクトの表現をそのAPIから完全に切り離したいという前提に基づいて構築されています。コンストラクター、アクセサメソッド、およびオブジェクトメソッドのAPIと動作は、オブジェクトの状態に直接一致する必要はなく、相互に一致する必要さえありませんが、実際には多くの場合、より密接につながっています。Pointオブジェクトには、フィールドxとy、xとyを受け取ってそれらのフィールドを初期化するコンストラクター、xとyのアクセッサ、およびxとyの値だけでポイントを特徴付けるObjectメソッドがあります。「単なるデータの単なるキャリア」であるクラスにとって、この結合は信頼できるものであると主張します。つまり、そのAPIから(パブリックに宣言された)状態を分離する機能を放棄しているということです。データクラスのAPIは、状態、状態全体をモデル化、つまり状態のみをモデル化します。この結果、データクラスは透過的になり、すべての要求者にデータを自由に明け渡します(それ以外の場合、APIは状態全体をモデル化しません)。

この結合を頼りにできると、様々な利点がもたらされます。標準クラスのメンバーに対して、賢明で正しい実装を導出できます。クライアントは、集計を自由に分解および再構築したり、より便利な形式に再構築したりできます(隠蔽されたデータを破棄したり、背後の仮定を損なうことを恐れたりする必要はありません)。フレームワークは、安全かつ機械的にシリアル化またはマーシャリングできます(複雑なマッピングメカニズムを提供する必要はありません)。クラスの状態をそのAPIから切り離す柔軟性を放棄すれば、これらの利点をすべて得ることができます。

Records and sealed types

recordの形式でデータクラスを表示することを提案します。列挙のように、recordはクラスの制限された形式で、その表現を宣言し、その表現に一致するAPIにコミットします。これを別の抽象化であるsealed typeと組み合わせて、他のどの型がそのサブクラスになり得るかを制御できます(finalクラスは、sealed classの最終的な形式であり、subtypeは一切許可されません。)

recordとsealed typeを使用して単純な算術式をモデル化する場合、次のようになります。

sealed interface Expr { }
record ConstantExpr(int i) implements Expr { }
record PlusExpr(Expr a, Expr b) implements Expr { }
record TimesExpr(Expr a, Expr b) implements Expr { }
record NegExpr(Expr e) implements Expr { }

これは、ConstantExpr(1個の整数を保持)、PlusExprとTimesExpr(2つの部分式を保持)、およびNegExpr(1つの部分式を保持)という4つの具象型を宣言しているとともに、式の共通supertypeを宣言し、これらがExprという唯一のsubtypeであるという制約を保存しています。

具象型は、 (final) フィールド、コンストラクタ、アクセッサ、equals()、hashCode()、toString()という、あらゆる通常のメンバーを取得します(デフォルト実装が不適切な場合、明示的に指定できます)。

recordは、ボイラープレートと情報の比率を調整するために列挙型と同じ戦術を使用します。標準メンバーの派生を可能にするより一般的な機能の制約バージョンを提供します。列挙型は、インスタンス制御をランタイムに委譲するようユーザーに要求します。その代わりに、この言語はインスタンスの効率的な宣言を提供するだけでなく、Object::equals、Enum::values、シリアル化といったコア動作の実装を提供できます。recordについても同様の取引を行っていて、高度に合理化された宣言(およびその他)を取得する代わりに、柔軟性を放棄して、クラスAPIをその状態の説明から切り離しています。

Records and pattern matching

単なる定型文を減らしたクラスとしてではなく、パブリックに指定された状態記述にAPIを結合する点で、データクラス定義の大きな利点の1つとして、データクラスを集約形態と展開状態の間で自由に変換できる、という点があります。これは、パターンマッチングと自然に結びつきます。状態記述とAPIを結合することで、明らかなdeconstruction patternを導出することもできます。

JEP 305: Pattern Matching for instanceof (Preview)
https://openjdk.java.net/jeps/305

Expr階層を考えると、式を評価するためのコードは次のようになります。

int eval(Expr e) {
    return switch (e) {
        case ConstantExpr(var i) -> i;
        case PlusExpr(var a, var b) -> eval(a) + eval(b);
        case TimesExpr(var a, var b) -> eval(a) * eval(b);
        case NegExpr(var e) -> -eval(e);
        // no default needed, Expr is sealed
    }
}

recordが自動的に取得する機械的に生成されたパターン抽出器を使用しています。recordとsealed typeの両方に、パターンマッチングとの相乗効果があります。 recordはコンポーネントへの簡単なdeconstructionを許可しますし、sealed typeはコンパイラに網羅的な情報を提供て、すべてのsubtypeをカバーするswitchでdefault句を提供する必要がないようにします。

Records and externalization

データクラスは、安全で機械的な外部化(シリアル化、JSONまたはXMLへのマーシャリングおよびアンマーシャリング、データベース行へのマッピングなど)にも自然に適合します。クラスが状態ベクトルのトランスペアレントキャリアであり、その状態ベクトルのコンポーネントを目的のエンコーディングで外部化できる場合、保証された忠実度でキャリアを安全かつ機械的にマーシャリングおよびアンマーシャリングできます。その際(組み込みのシリアル化が行うように)コンストラクターをバイパスするというセキュリティと整合性のリスクはありません。実際、トランスペアレントキャリアは、外部化をサポートするために特別なことをする必要はありません。外部化フレームワークは、その解体パターンを使用してオブジェクトを解体し、すでにpublicなコンストラクタを使用してオブジェクトを再構築できます。

Why not “just” do tuples?

この時点でタプル(tuple)さえあればデータクラスは必要ない、と考えている人がいらっしゃることでしょう。また、タプルは集計を表現するためのより軽量な手段を提供しますが、結果はしばしば劣った集計になります。

クラスとクラスメンバーには意味のある名前を有していますが、タプルとタプルコンポーネントはそうではありません。 Javaの哲学の中心は、名前が重要であるということです。つまり、プロパティfirstNameとlastNameを持つPersonは、StringとStringのタプルよりも明確で安全です。クラスは、コンストラクタによる状態検証をサポートしていますが、タプルはサポートしていません。クラスの場合、(数値範囲などの)一部のデータ集計には不変式がありますが、コンストラクターによって実施される場合、その後不変式に依存できます。ところがタプルにはこの機能がありません。クラスは、その状態から導出された動作を持つことができます。状態と派生した動作を同じ場所に配置することで、検出しやすくなり、アクセスしやすくなります。

これらすべての理由から、データのモデリングのためにクラスを放棄したくありません。クラスを使ってデータを簡単にモデリングしたいだけです。集計に名前付きクラスを使用する際の問題点は、宣言のオーバーヘッドであり、これを十分に減らすことができれば、より弱く型付けされたメカニズムに手を伸ばしたくなる誘惑が大幅に減ります(recordについて考える上での良い出発点は、それらが名目上のタプルであるということです。)

Are records the same as value types?

Project Valhallaからvalue typeが出てくるので、(不変の)データクラスとvalue type間の重複について、そしてデータ性と値性の交差する場所が居住するのに有用な空間であるかどうかを尋ねてみてもいいでしょう。

Project Valhalla
http://openjdk.java.net/projects/valhalla/

recordとvalue typeには、明確な類似点があります。両方とも不変(immutable)の集合体であり、拡張に制限があります。ではこれらは本当に同じ機能の表現を変えたものなのでしょうか。

両者のセマンティック上のゴールを確認すれば、両者が異なることがわかります。value typeは、メモリ内におけるオブジェクトのフラットで密なレイアウトを可能にすることを主目的としており、オブジェクトのIDを放棄することと引き換えに(これにより、可変性とレイアウトのポリモーフィズムを放棄する必要があります)、ランタイムはヒープのレイアウトを最適化し、値の呼び出し規約を最適化する機能を獲得します。recordを使用すると、クラスAPIをその表現から切り離す機能を放棄する代わりに、表記法およびセマンティック上の多くの利点が得られます。しかし、両者で放棄したものには同じもの(可変性、拡張性)がありますが、value typeの場合は依然として状態のカプセル化の恩恵を受けますし、recordの場合は依然としてアイデンティティの恩恵を受ける可能性があるため、まったく同じ取引というわけではありません。ただし、両方の利点を得るために、両方の制限を容認するクラスがあり、これらをvalue recordと呼びます。そのため、必ずしも一方もしくは他方のメカニズムのみを使用する必要はありませんが、連携するメカニズムがほしいのは確かです。

Digression: algebraic data types

データクラスとsealed typeの組み合わせは代数的データ型(Algebraic data class)の形式であり、直積型(product type)とタグ付き共用型(sum type、Tagged union)の組み合わせを指しています。

Algebraic data type (代数的データ型)
https://en.wikipedia.org/wiki/Algebraic_data_type

直積型(Product Type)の名前がデカルト積に由来しているのは、その値セットが型のベクトルの値セットのデカルト積であるためです。タプルは、(firstNameとlastNameというStringのフィールドを持つPerson型のような)多くのアドホックドメインクラスと同様に、直積型の一種です。タグ付き共用体(sum type、Tagged union)は、固定された型セットの区別された和集合です。 列挙型は共用体の一種です(共用体メンバーは定数です)。

ある言語では代数的データ型を直接サポートしている場合があります。例えば、HaskellではExpr階層に相当するものをdataコンストラクトを使って宣言します。

data Expr = ConstantExpr Int
          | PlusExpr Expr Expr
          | TimesExpr Expr Expr
          | NegExpr Expr
deriving (Show, Eq);

(deriving節は、これらの型が自動的に明示的に相当するObject::equalsとObject::toStringに明示的に相当するものを取得することを示しています。)

Use cases

recordおよびrecordのシールされた階層には、多くのユースケースがあります。 典型的な例をご紹介します。

Tree nodes

前述のExprの例では、recordがどのようにドキュメント、クエリ、式を表すツリーノードを手早く片付けることができるのかを示しています。sealを使うことで、開発者やコンパイラは全てのケースを網羅した時期を推論できます。ツリーノードでのパターンマッチングは、Visitorパターンよりも直接的で柔軟なトラバーサルの代替手段を提供します。

Multiple return values

効率(1回のパスで複数の量を抽出する方が2回のパスを行うよりも効率的な場合があります)または一貫性(可変データ構造で操作する場合、2番目のパスは別の状態で動作している可能性があります)の理由で、メソッドが1個以上返すことが望まれる場合が多々あります。

例えば、配列の最小値と最大値の両方を取り出すとします。2つの整数を保持するようにクラスを宣言するのは過剰に思えるかもしれませんが、宣言のオーバーヘッドを十分に減らすことができる場合、カスタムの直積型を使用してこれらの関連する数量を表せると魅力的で、より効率的な(そして読みやすい)計算が可能になります。

record MinMax(int min, int max);
public MinMax minmax(int[] elements) { ... }

前述の通り、一部のユーザーは、名目上のメカニズム(nominal mechanism)ではなく、構造タプル(structural tuple)を介してこの機能を公開することを確実に好むでしょう。しかし、MinMax型を宣言するコストを合理的なところにまで減らしたため、メリットはコストと並び始めました。minやmaxなどの名目上のコンポーネントは、単純な構造タプルよりも読みやすく、エラーが発生しにくくなっています。Pairでは何を表すか、読者はわかりませんが、MinMaxはわかります(また、コンパイラは、両方がintのペアとしてモデル化されていても、誤ってRangeにMinMaxを割り当てられることを防ぎます)。

Data transfer objects

データ転送オブジェクトは、関連する値をパッケージ化して、単一の操作で別のアクティビティと通信できるようにすることを唯一の目的とする集約です。通常、データ転送オブジェクトには、状態の保存、取得、およびマーシャリング以外の動作はありません。

Joins in stream operations

組み立て量があるとして、その組み立て量を操作するストリーム操作(フィルタリング、マッピング、ソート)を実行したいとします。例えば、(大文字に正規化された)オブジェクトの名前が最大のhashCode()を持つPersonオブジェクトを選択したい場合、recordを使用して1個以上の組み立て量を一時的にアタッチし、それらを操作してから、次のように目的の結果に戻すことができます。

List topThreePeople(List<Person> list) {
    // local records are OK too!
    record PersonX(Person p, int hash) {
        PersonX(Person p) {
            this(p, p.name().toUpperCase().hashCode());
        }
    }

    return list.stream()
               .map(PersonX::new)
               .sorted(Comparator.comparingInt(PersonX::hash))
               .limit(3)
               .map(PersonX::person)
               .collect(toList());
}

ここでは、Personに組み立て量を付け加えることから始め、その組み合わせに対して通常のストリーム操作を実行し、完了したらラッパーを破棄してPersonを抽出します。

余分なオブジェクトを実体化しなくてもこれを実現できたでしょうが、各要素についてさらに何度もハッシュ(と大文字)を計算する必要が生じる可能性がありました。

Compound map keys

2つの異なるドメイン値の組み合わせをキーとするマップが必要な場合があります。例えば、特定の人物が特定の場所で最後に見られた時間を表現したいものとします。キーとしてPersonとPlaceを組み合わせ、値としてLocalDateTimeを持つHashMapでこれを簡単にモデル化できますが、システムにPersonAndPlace型がない場合は、コンストラクタ、equals、hashCodeなどのボイラープレート実装を使用して記述する必要があります。recordは、目的のコンストラクタ、equals()、hashCode()メソッドを自動的に取得するため、 複合マップキーとして使用できます。

record PersonPlace(Person person, Place place) { }
Map lastSeen = ...
...
LocalDateTime date = lastSeen.get(new PersonPlace(person, place));
...
Messages

recordとrecordの合計 (sum) は、アクターベースのシステムおよび(Kafkaのような)他のメッセージ指向システムでメッセージを表現する場合に一般的に有用です。アクターが交換するメッセージは、直積が理想的に表現します。アクターが一連のメッセージに応答する場合、これは直積の合計で理想的に表現します。また、合計タイプの下でメッセージのセット全体を定義できることにより、メッセージングAPIのより効果的なタイプチェックが可能になります。

Value wrappers

クラスOptionalは、変装した代数的データ型です。代数的データ型とパターンマッチングを使用する言語では、Optionalは通常、Some(T value)とNone型(コンポーネントを持たない、縮退した積)の合計として定義されます。同様にEither型は、Left型とRight型の和として記述できます(JavaにOptionalが追加された時点では、代数的なデータ型もパターンマッチングもなかったため、従来のAPIイディオムを使用して公開することは理にかなっています)。

Discriminated entities

より洗練された例は、識別されたエンティティを返す必要があるAPIです。例えば、JEP 348 (Compiler Intrinsics for Java SE APIs) では、Javaコンパイラが、JDK APIの呼び出しをコンパイル時により効率的な表現に変換できるメカニズムで拡張されています。

JEP 348: Compiler Intrinsics for Java SE APIs
https://openjdk.java.net/jeps/348

これには、コンパイラと「組み込みプロセッサ(intrinsic processor)」間の会話が含まれます。 コンパイラはcall siteに関する情報をプロセッサに渡し、プロセッサは呼び出しを変換する方法(またはしない)の説明を返します。その選択肢は以下の通りです。

  • invokedynamicへの変換
  • constantへの変換
  • 何もしない

sealed typeとrecordを使用すると、APIは次のようになります。

interface IntrinsicProcessor {
    sealed interface Result {
        record None() implements Result;
        record Ldc(ConstantDesc constant) implements Result;
        record Indy(DynamicCallSiteDesc site, Object[] args)
            implements Result;
    }
    public Result tryIntrinsify(...);
}

このモデルでは、組み込みプロセッサがcall siteの情報を受け取り、以下のいずれかを返します。

  • None (変換しない)
  • Indy (指定されたinvokedynamicで呼び出しを置き換える)
  • Ldc (指定された定数で呼び出しを置き換える)

この積和は、宣言がコンパクトで、読みやすく、パターンマッチングで簡単にdeconstructionできます。このようなメカニズムがなければ、APIを読みにくくしたり、エラーを起こしやすい方法で構成したくなるかもしれません。

Records

recordは、クラスが通常享受する重要な自由度、つまりクラスAPIをその表現から切り離す機能を放棄します。recordには、名前、状態の説明、および本体があります。

record Point(int x, int y) { }

recordは「状態、状態全体、状態以外の何者でもない」ので、ほとんどのメンバーを機械的に導出できます。

  • 同じ名前と型を持つ、状態記述の各コンポーネントのためのprivatre finalのフィールド
  • 同じ名前と型を持つ、状態記述の各コンポーネントのためのpublicアクセッサメソッド
  • シグネチャが状態記述と同じで、対応する引数から各フィールドを初期化するpublicなコンストラクタ
  • シグネチャが状態記述と同じで、各フィールドを対応するバインディングスロットに抽出するpublicなdeconstruction pattern
  • 型が同じで同じ状態を持つ場合に、2つのrecordが等しいと表現する、equalsとhashCodeの実装
  • すべてのコンポーネント(名前付き)が含まれるtoStringの実装

表現、およびコンストラクタ、deconstruction(deconstruction patternsまたはアクセッサ、あるいは両方)、同値性、そして表示のプロトコルは全て同じ状態記述から派生しています。

Customizing records

列挙型(enum)のようなrecordはクラスです。recordの宣言には、アクセシビリティ修飾子、Javadoc、注釈、implements句、および(レコード自体は暗黙的にfinalですが)型変数など、クラス宣言でできることのほとんどが含まれています。コンポーネント宣言には注釈とアクセシビリティ修飾子を含めることができます(コンポーネント自体は暗黙的にprivateでfinalです)。本体には、静的フィールド、静的メソッド、静的初期化子、コンストラクター、インスタンスメソッド、インスタンス初期化子、およびネストされた型が含まれる場合があります。

コンパイラーが暗黙的に提供するメンバーのいずれについても、これらを明示的に宣言できますが、不注意にアクセッサやequals、hashCodeをオーバーライドすると、recordのセマンティック不変性を損なう可能性があります。

さらに、デフォルトコンストラクタ(そのシグネチャがrecordの状態表現のシグネチャと一致するもの)を明示的に宣言するための特別な考慮事項が提供されています。(状態表現と同一であるため)引数リストは省略されます。 さらに、すべての通常の完了パスで明確に割り当てられていないrecordの任意のフィールドは、終了時に対応する引数(this.x = x)で暗黙的に初期化されます。これにより、コンストラクタ本体では引数の検証と正規化のみを指定し、フィールドの初期化を省略できます。以下はその例です。

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

フィールドのアクセッサから暗黙のdeconstruction patternを派生します。そのため、これらがオーバーライドされるとdeconstructionのセマンティクスにも反映されます。

ネストされたコンテキストで宣言されたrecordは暗黙のうちにstaticです。

Odds and ends

上記はただのスケッチであり、細かい部分でやることがたくさんあります。

Javadoc

フィールドとアクセッサメソッドはクラス宣言の一部として宣言されているため、Javadoc規約を少し調整してこれに対応する必要があります。これは、recordに@paramタグを許可すれば可能で、フィールドとアクセッサのドキュメントに伝播できます。

Annotations

recordコンポーネントは、注釈を付ける新しい場所を構成します。これを反映するために、@Targetメタアノテーションを拡張することになるでしょう。

Reflection

recordとはセマンティックステートメントであるため、recordであること、そして状態コンポーネントの名前と型がリフレクティブに利用可能でなければなりません。(enumクラスにあるように)追加のメソッドや仕様が存在できる、recordの基本型を検討してみるのもよいかもしれません。

Serialization

recordの利点の1つに、マーシャリングとアンマーシャリングのより安全なプロトコルを機械的に導出できることがあります。これを利用する賢明な方法は、Serializableを実装するrecordが状態を抽出し、コンストラクターを使って状態を抽出して状態を巻き戻すreadResolveメソッドを自動的に取得して、悪意のあるストリームが不正なデータを注入しないようにすることです。

Extension

recordが抽象recordの拡張を許可することも可能です。これにより、関連するrecordが特定のメンバーを共有できるようになります。この可能性を保持すべきでしょう。

Compatibility

コンポーネントの引数の個数、名前、および型を直接メンバーのシグニチャおよび名前に直接伝搬するため、状態記述への変更はソース互換またはバイナリ互換ではない場合があります。

Named invocation

コンポーネントの名前はrecord APIの一部を形成するため、コンストラクタの名前付き呼び出しへの門戸を開きます。これにより、ほとんどの場合、ドメインクラスに伴うコンパニオンビルダーを廃止できます(すべてのクラスでこれをサポートするのはよいのですが、recordにはこれをそれほど複雑にしない特別なプロパティがあります。recordから始めて拡張することを検討するかもしれません)。

Restrictions

注意深い読者は、いくつかの制限に気付くでしょう。

  • recordのフィールドは変更できません。
  • 状態記述にあるフィールド以外は許可されません。
  • recordは他の型を拡張したり、recordを拡張したりすることはできません

こうした制限のそれぞれが息苦しく感じられて、その適用性を広げるために構造をより柔軟にしようとする誘惑に駆られるような状況は簡単に想像できます。しかし、そもそも単一の状態記述からすべてを引き出すことができるという状況を犠牲にするべきではありません。

Extension

recordの状態記述が「状態、状態全体、状態以外の何者でもない」ことをモットーにしている場合、スーパークラスに隠された状態がないかどうかを確認できないため、(抽象recordを除き) recordは何も拡張できません。同様に、recordを拡張できる場合、その状態記述は、その状態を完全に記述するものではありません(これは、通常の拡張を除外することに加えて、動的プロキシも除外することに注意してください)。

Mutability

理論的には、目標に反しない例を想像できるため、可変性に対する制約はより複雑です。ただし、可変性は、状態とAPIの間の整合に影響を与えます。例えば、equals()およびhashCode()のセマンティクスが可変状態に基づくことは一般に正しくありません。可変状態に基づくと、そのような要素がHashSetやHashMapから静かに消えてしまうリスクが生じます。したがって、recordへの可変性の追加は、状態記述とは異なる等値プロトコルが必要になる可能性がありますし、別のconstructプロトコルが必要な場合もあります(多くのドメインオブジェクトは、引数なしのコンストラクタで作成されてsetterを使って状態を変更したり、「主キー」フィールドのみを取るコンストラクタで状態を変更したりします)。今では、単一の状態記述から主要なAPI要素を導き出すことができる、という主要な差別化機能を見失ってしまいます(そして、可変性を導入すると、recordの目標との調整が困難になるスレッド・セーフについて考える必要があります)。

変更可能なJavaBeansのボイラープレートを自動化することは素晴らしいことではありますが、 任意のコードのボイラープレート削減のみに焦点を当てたアプローチは、単に新しいボイラープレートを作成するだけに過ぎないことを認識するために、これまでの多くの試み(Lombok、Immutables、Joda Beansなど)や、長年にわたって取得した「ノブ」の数を調べるだけで十分です。これらのクラスは単純に自由度が高すぎて1個のシンプルな記述で把握できません。

セマンティクスが明確に定義された名目上のタプルは、多くのユースケースでプログラムをより簡潔かつ信頼性の高いものにすることができますが、それでもタプルの制限を超えるユースケースがあります(これらのクラスに対してできることがないという意味ではなく、単にこの機能ではできない、ということです)。したがって、明確にするために、recordはJavaBeansやその他の変更可能な集合を置き換えるものではありませんし、それで問題ありません。

Additional fields

関連する懸念に、recordが状態記述の一部であるフィールド以外のフィールドを宣言できるかどうかがあります(また、これが安全な例を簡単に想像できます)。それに対し、この機能は、「状態、状態全体、状態以外の何者でもない」のモットーに反する誘惑、避けた方がよい誘惑が再度出てきます。

Sealed types

sealed typeは、型宣言で指定されたガイダンスに従ってサブクラス化が制限されている型です (finalityは縮退したsealと考えることができます)。

sealには2つの明確な目的があります。1つ目は、subtypeにできる人を制限することです。これは主に、API所有者がAPIの整合性を守ることを望む、declaration-siteの懸念事項です。もう1つの、あまり明白ではない利点は、sealed typeの型パターンを切り替える場合など、use-siteで徹底的な分析が可能になることです。

クラスにsealedという修飾子を適用してクラスや抽象クラス、インターフェースをsealします。オプションとしてpermitsリストを付加できます。

sealed interface Node
     permits A, B, C { ... }

この明示的な形式では、permitsリストで列挙された型によってのみNodeを拡張できます(さらに、同じパッケージやモジュールのメンバーである必要があります)。多くの状況では、これは過度に明示的である可能性があります。例えば、全てのsubtypeを同じコンパイル単位で宣言すると、permits句を省略できます。この場合、コンパイラは現在のコンパイル単位にあるsubtypeを列挙して推論します。

sealed typeの匿名subclass(とlambda)は禁止されています。

Sealingは、finalityと同様に、言語とJVMの両方の機能です。型の密閉性と許可されるsubtypeのリストは、実行時に強制できるようにクラスファイルで具体化されます。

許可されたsubtypeのリストも、何らかの方法でJavadocに組み込む必要があります。これは、現在Javadocに含まれている現在の「すべての実装クラス」リストとまったく同じではないため、「すべての許可されたsubtype」のようなリストが追加される可能性があります(おそらくsubtypeが親よりもアクセスし辛い場合に何らかの指示があるか、もしくはリストになっていない他のものがそこに存在するという注釈が含まれます)。

Exhaustiveness

隠蔽のメリットの一つに、コンパイラがsealed typeの許可されたsubtypeを列挙できることです。これにより、sealed typeを含むパターンの切り替え時に徹底的な分析を実行できます。

(注意)permits packageや、略記としてpermits moduleを宣言することは魅力的で、これを使えば、すべてをリストアップせずに、パッケージの仲間またはモジュールの仲間が型を拡張できます。ただし、これはパッケージとモジュールが必ずしも同時にコンパイルされるとは限らないため、網羅性について推論するコンパイラの能力を損なうことになります。

対して、subtypeはシールされた親のようにアクセス可能である必要はありません。この場合、クライアントの中には徹底的に切り替える機会を得られない場合があります。default句またはその他の全体パターンを使用して、これらのswitchで網羅する必要があります。このようなsealed typeを使うswitchをコンパイルすると、コンパイラが有用なエラーメッセージ(”I know this is a sealed type, but I can’t provide full exhaustiveness checking here because you can’t see all the subtypes, so you still need a default.”「これがsealed typeであることはわかりますが、すべてのsubtypeを確認できるわけではないため、ここでは完全な完全性チェックを提供できません。そのためやはりdefaultが必要です。」)を提供できます。

Inheritance

特に指定がない限り、sealed typeの抽象subtypeは暗黙的に封印され、具象subtypeは暗黙的にfinalです。これは、subtypeを明示的にnon-sealで変更すると元に戻すことができます(ただしrecordは対象外で、recordは全てfinalです)。

階層内のsubtypeを開封しても、明示的に許可されたsubtypeの(推定される可能性がある)セットが依然として完全なカバーを構成するため、シーリングのすべての利点が損なわれるわけではありませんが、シールされていないsubtypeを知っているユーザーは、この情報を活用できます(必要に応じてIOExceptionとは別にFileNotFoundExceptionをキャッチできます)。

明示的にシールされていないprivateのsubtypeが有用な例が、JEP-334にあります。

sealed interface ConstantDesc
    permits String, Integer, Float, Long, Double,
            ClassDesc, MethodTypeDesc, MethodHandleDesc,
            DynamicConstantDesc { }

sealed interface ClassDesc extends ConstantDesc
    permits PrimitiveClassDescImpl, ReferenceClassDescImpl { }

private class PrimitiveClassDescImpl implements ClassDesc { }
private class ReferenceClassDescImpl implements ClassDesc { } 
sealed interface MethodTypeDesc extends ConstantDesc
    permits MethodTypeDescImpl { }

sealed interface MethodHandleDesc extends ConstantDesc
    permits DirectMethodHandleDesc, MethodHandleDescImpl { }
sealed interface DirectMethodHandleDesc extends MethodHandleDesc
    permits DirectMethodHandleDescImpl { }

// designed for subclassing
non-sealed class DynamicConstantDesc extends ConstantDesc { ... }

Summary

recordとsealed typeの組み合わせにより、強力でよく理解された構造化データの関係するグループの表現パターンに従います。そのため、多くの状況で一般的なコードの可読性と簡潔性を向上させる可能性があります。recordは宣言の定型文の記述を避けたいコードの全てに適しているわけではありません。こうしたユースケースにおける状況の改善が可能な機能の検討を引き継き計画しています。