- 2023年9月19日にリリースされたJava 21は、record patterns と switch pattern matching によって、Kotlin・Rust・C#に近い関数型パターン表現をJavaの中に取り込んだ
- Java 14のswitch expressions、Java 16のrecordsと
instanceof pattern matching、Java 17のsealed classesが積み重なり、代数的データ型 を扱うための基盤がJava 21でかみ合った
- records はfinal・不変参照・定型的なgetterといった制約により、データを安定して分解できるようにし、record patternは入れ子になったデータをswitch内でそのまま取り出せるようにする
- sealed classes/interfaces は許可された下位型だけを開放してsum typeに近いモデルを作り、sealed interfaceとrecordを併用するとRGB・CMYK・YUV・HSLのようなバリエーションを制限できる
- Java 21のswitchは
null caseと when guardまでサポートするが、不正なrecord accessorやguard実行中の例外は java.lang.MatchException につながる可能性がある
Java 21で正式化されたパターンマッチング
- Java 21は2023年9月19日にリリースされ、switchブロックとswitch式で record patterns をサポートする
- この構文は、JavaでもKotlin、Rust、C#に近い形で関数型プログラミングのパターンを表現できる転換点として評価されている
- 近年のJavaバージョンにおける主要な構文変更は、Java 21のパターンマッチングへとつながっている
- Java 14: switch expressions 正式化
- Java 16: records、
instanceof pattern matching 正式化
- Java 17: sealed classes 正式化
- Java 21: record patterns、switch pattern matching 正式化
- この一連の変更により、Javaはこれまで表現しにくかった 代数的データ型(algebraic data types) と、その慣用的な利用方法を扱えるようになった
型理論で必要な最小限の概念
- Java 21の機能を理解するには、いくつかの型理論の概念が必要になる
- bottom/empty type は計算可能でない値の集合を表し、一般的なプログラミング言語では通常は空集合である
- Kotlinの
Nothing はコンストラクタがprivateのため、インスタンスを作成できない
- Javaの
Void はコンストラクタがprivateだが null を入れられるため、真のbottom typeとは言いにくい
- Javaのprimitive
void は変数型として使えないため、この点ではより近い振る舞いをする
- top type はすべての型のすべての値を表す普遍集合である
- Kotlinでは
Any がその役割を担う
- Javaの
Object はprimitiveがオブジェクトモデルから分離されているため、他言語のtop typeと同じ意味では捉えにくい
- unit type は値が1つしかない型である
- Javaの
void はメソッド戻り値ではunit typeのように扱えるが、引数の型として渡すことはできない
- Kotlinの
Unit は object として定義され、メソッド引数としても使える
- boolean type は
true と false の2つの値を持ち、nullableなunit typeとして表現することもできるが、実用的ではない
Product typeとJava records
- product type は2つ以上の構成型を束ねた型であり、構成型の数はarityまたはdegreeとなる
- Cの
struct はproduct typeの一例である
int, char *, double, int のように構成型が重複してもよい
- 重複する型は、フィールド名と組み合わせたordered pairとして考えれば区別できる
- PythonやRustのtupleもproduct typeと見なせ、この場合はインデックスが構成要素名の役割を果たす
- Java 16で正式化された record class はproduct typeの好例である
- recordのフィールドはfinalであり、recordは継承できない
- recordの状態は生成時に設定され、その後は維持される
- ただし、record内部にmutableなデータ型を入れても、その内容の不変性まで保証されるわけではない
- 通常のJava classは、public/privateな状態、継承で生じる隠れた状態、mutable/static field、非標準getterが混在しうるため、構成要素を一般化しにくい
- recordsは次の制約により、パターンマッチングのような言語機能が安定して動作する構造を保証する
- recordは暗黙的にfinal classであり、継承できない
java.lang.Record 以外のクラスをextendできない
- record componentにはvisibility modifierを付けられない
- component参照は常にfinalで、不変として扱われる
- デフォルトgetterはフィールド名をそのまま使い、
a フィールドのgetterは a() になる
- backing fieldは暗黙的にprivateで、getter経由でアクセスされる
record patternで入れ子データを分解する
- Java 21のswitch patternは、
instanceof チェックと明示的なcastを繰り返さずに 入れ子になったrecordデータ を分解できる
- 例では
record A(Record inner)、record B(char b)、record SomeOtherRecord() を使う
- 従来の方法では
if (r instanceof A) の後にcastし、内部の値に対して再び instanceof とcastを繰り返す必要があった
- switch patternなら
case A(B(char a)) -> String.valueOf(a) のように入れ子の値をそのまま抽出できる
- switchブロックはif-elseの連鎖より構造が明確で、深くネストしたデータを素早く取り出すのに向いている
- Java 21で直接実行するには、
main.java にコードを入れ、次のコマンドを使う
java --enable-preview --source 21 main.java
Sum typeとsealed classes/interfaces
- 限られた選択肢を表現するならJavaのenumも使えるが、RGB・HSL・YUV・CMYKのように異なるデータ構造を持つ色表現は、enumだけでは扱いにくい
- 継承ベースの多相性で
Color 抽象クラスと RGB、CMYK、YUV、HSL クラスを作ることもできるが、通常のclass hierarchyは開いている
- ライブラリ利用者が
RYB のような新しいクラスを作って Color を継承できてしまう
- APIが拡張を意図していないなら、新しいバリアントがクラッシュや離れた場所の微妙なバグを引き起こす可能性がある
- sealed classes はsum typeの概念をJavaで表現するために使われる
- sum typeは、ある時点で構成要素のうち1つになりうる型である
- tagged union typeとも呼ばれる
sealed modifierと permits clauseを使うと、特定のclassだけに継承を許可できる
public sealed class Color permits RGB, CMYK, YUV, HSL {
}
- sealed class hierarchyでは、直接または間接の継承先が
sealed、non-sealed、final のいずれかを持たなければならず、なければcompile errorになる
sealed: permits に名前のある型だけが継承可能
non-sealed: 通常のclassのように継承可能
final: 継承ツリーのleafとなり、これ以上拡張できない
sealed interfaceとrecordを併用する方法
- switch pattern matchingのdestructuringはrecordsに対しては機能するが、recordsは
Record 以外のクラスを継承できない
- 解決策は sealed interface を使うことにある
- sealed interfaceはsealed classと同様に動作する
- recordsやenumsもsealed interfaceをimplementできる
- 例では
Color をsealed interfaceにし、RGB、CMYK、YUV、HSL をrecordとして実装している
public sealed interface Color permits RGB, CMYK, YUV, HSL {
String getDescription();
}
record RGB(int red, int green, int blue) implements Color {
public String getDescription() {
return "RGB Color: (" + red + ", " + green + ", " + blue + ")";
}
}
- その後、switchで各recordの値をそのまま取り出せる
switch (color) {
case RGB(int red, int green, int blue) -> {
}
case CMYK(double cyan, double magenta, double yellow, double black) -> {
}
case YUV(int y, int u, int v) -> {
}
case HSL hsl -> {
System.out.println(hsl.getDescription());
}
case null -> {
System.out.println("How did color become null?!");
}
}
- Java 21ではswitchブロックと式の中で
null case を扱えるため、switchの前に別途nullチェックを入れる必要がない
Color がsealed typeなら、Javaはすべてのcaseが処理されているかを把握できるため、default caseなしでもexhaustiveなswitchが可能になる
Guard clauseと when
- Java 21は、switch armに追加条件を付けられる guard clause をサポートする
- guard clauseは
when キーワードでcase labelに条件を統合する
switch (color) {
case RGB(int red, int green, int blue) when red > 200 -> {
System.out.println("Very red.");
}
case RGB rgb when rgb.green > 100 -> {
System.out.println("Sort of green...");
}
case RGB rgb -> {
System.out.println("Not that red...");
}
}
- 従来は
case RGB(...) の本体内に再び if (red > 200) を書く必要があった
- Javaはtrueと評価される最初のcaseに貪欲にマッチするため、より具体的なcaseを先に置き、より一般的なcaseを後ろに置く必要がある
- guard付きのRGB caseの後ろには、exhaustive性を保つために通常の
case RGB rgb が必要になる
MatchException が発生する場合
- Java 21のパターンマッチングでは
java.lang.MatchException も新たに関係してくる
- record accessorが例外を投げるとswitch patternが失敗し、
MatchException が発生することがある
record R(int i) {
public int i() {
return i / 0;
}
}
static void exampleAnR(R r) {
switch(r) {
case R(var i): System.out.println(i);
}
}
- 上の例では
i() accessorが ArithmeticException を投げるため、switchブロックが MatchException を投げる
- JEP 441 によれば、常に例外を投げるrecord accessorはきわめて異常であり、exhaustive pattern switchが
MatchException を投げるケースも非常にまれである
- exhaustive switchでも、selectorに対して指定されたvariantが1つもマッチしなければ例外が発生することがある
- JEP 441は、enumに対するexhaustive switchがマッチに失敗する例として、switchのコンパイル後にenum classが変更された状況を挙げている
- guard clauseの実行中に例外が発生した場合も
MatchException が発生しうる
static void example(Object obj) {
switch (obj) {
case R r when (r.i / 0 == 1): System.out.println("It's an R!");
default: break;
}
}
残された範囲
- Java 21のrecords、sealed types、switch pattern matching、guard clauseを組み合わせることで、関数型プログラミングのビルディングブロックをJavaコードに適用できる
- genericsがswitch patternsとどう相互作用するかのような一部の話題は扱わない
- 次の記事では、Javaコードの書き方を改善するために使えるquirksや実践的な例を扱う予定である
1件のコメント
Hacker Newsの意見
Java 21の最大の機能は仮想スレッドのリリースです: https://openjdk.org/jeps/444
なぜか記事では抜けています。既存のGo開発者をJavaに引き戻せる機能があるとすれば、これかもしれませんし、リアクティブスタイルの並行処理パターンを嫌っていた人たちも説得できそうです
Javaアプリケーションやライブラリは、継承、パッケージング、オブジェクト指向、ビルドツールなどのために、Goに比べて推論して理解するのが難しすぎるからです
Goはシンプルで、理解・読解・保守が容易です。パッケージングはコンピュータ上でファイルを単一フォルダに整理する方法に似ていて、ツールも言語に組み込まれています。IntelliJのようなIDEがあってようやく使いものになる、という感じでもありません
今は変わっているかもしれませんが、最近目にするJavaライブラリの大半は、いまだにこうした姿に見えます
JVMとそのツール体系には好きな点が多いものの、今さらJavaコードを書くのはあまり気が進みません。JRubyは両方の長所をある程度あわせ持っています
発表はこちらで、仮想スレッドのデモは45分あたりからです
https://youtu.be/pzm6I4liJlg?si=GtxQ4MThEaNDfC67
sync.Mapでさえ、Javaの汎用的なConcurrentMapのようなものではなく、2つの特定ユースケースに特化しています。Javaには並行処理用の集合、キュー、バリア、フェイザー、フォークジョインプールなどがあります。goroutineがあっても、こうしたコンテナは十分有用なはずですし、少なくともフォークジョインは実装がそれほど些細ではありません。どこでもミューテックスを使うのは、あまりに低レベルに感じますサードパーティ実装があるのは知っていますが、並行処理は正しく作るのが非常に難しいので、JavaのJCToolsやGoogle Guavaのように成熟しており、多くのユーザーと開発者に支えられている水準でなければ、サードパーティパッケージを採用するのはためらわれます
Executor.newVirtualThreadPerTaskExecutorとgoの対比は、Go開発者がJavaへ移らないだろうと見る核心をよく示しています訂正すると、実際には
goの代わりにtry (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(...) }に近いですブログ記事のタイトルはあまり良い選択ではなかったと思います。隠れた副題は「Algebraic data types in Java」ですが、こちらのほうが内容の説明としてはずっと合っています。より良いタイトルはAlgebraic data types in Java 21だったでしょう
おそらくタイトルのせいか、ここのコメントのかなり多くが話題から外れています。代数的データ型、Java実装の長所と短所、他言語との技術的比較をもっと見たかったです
既存のJavaコードがなくなるわけではないので、こういうコードが無作為に混ざると本当に良くなるのか、と思います
ここで説明されている sealed classes 機能は、何か完全に間違っているように感じる
通常のインターフェースがあると、誰でもそれを実装する新しいクラスを作ることができ、
if (x instanceof Foo) { ... } else if (x instanceof Bar) { ... }のようなコードは、誰かが新しいクラスを追加すると実行時に壊れる、という理屈だ。そこで新しい sealed インターフェース機能によって、そのインターフェースを実装する新しいクラスを誰にも作れないようにすれば、if文は壊れない、という話になるしかしオブジェクト指向プログラミングは、すでにこうした問題を考慮して解決していたのではないか。今やオブジェクト指向が流行から外れているのは分かるが、Java はオブジェクト指向言語だ
解決策は、インターフェースにメソッドを追加し、すべてのクラスにそれを実装させることだ。そうすれば、すべての選択肢を巨大な if/switch 文で列挙する代わりに、メソッドを呼び出せばよい
この方式は、コードを拡張できないように妨げるよりも優れており、むしろ拡張可能にしてくれる。新しい実装者はそのメソッドだけを実装すればよく、コンパイラが強制するので、うっかり抜かすこともない
記事の色空間の例(RGB、CMYK など)は、非常によい反例だ。色空間を使うコードを書いたとして、ユーザーや顧客が、自分の想定していなかった奇妙で珍しい色空間を使う必要があるかもしれない。巨大な if/switch 文に列挙した色空間だけをサポートし、そのような構造のせいで拡張不能なコードを作りたいとは思わない
この問題を sealed classes が解決する。しかしその代わりに、「拡張クラスがさらに必要で、そのすべてを事前に知ることができないならどうするのか?」という新しい問題が生じる。結局、両方を達成する方法はあるのか、という問いになる
この問題は 式問題(expression problem) と呼ばれる [1]
式問題を解ける静的型付け言語はいくつかあり、Java もその一つだ [2]。ただし Java でそれを行う方法は依然として非常に複雑で不便なので、ほとんど使われていない。Haskell、あるいは JVM の世界にとどまりたいなら、Scala の方がこの点ではずっと優れている
[1] https://en.wikipedia.org/wiki/Expression_problem
[2] https://koerbitz.me/posts/Solving-the-Expression-Problem-in-...
インターフェースメソッドと仮想呼び出しの方式は、新しいクラスを追加する代わりに新しい演算を追加したい場合には、非常に柔軟性に欠ける。新しい演算を一つ追加するだけでも、すべての実装に行って新しいメソッドを入れなければならず、アクセス権のない実装を壊してしまう可能性もある。互いに関係のないメソッドも一つのクラス内に定義されなければならないため、コードの可読性は大きく悪化し、仮想呼び出しも無料ではないので性能にも影響する
この場合、sealed class の方がはるかにうまく拡張できる。一か所に新しい switch を追加すれば終わりで、後方互換性も壊さない
これが有名な 式問題 だ
https://pkolaczk.github.io/in-defense-of-switch/
instanceofの代わりに多相ディスパッチを使えという推奨は理解しているし、Bob Martin がその話を長々としているのも見たが、同意はしないこのような 多相ディスパッチ を行うには、オブジェクトが複数の関心事を自分で処理しなければならない
ビデオゲームでは、
Carは.render()、.collide()、.playSound()を持つことができる。後でDogを追加しても、この三つのメソッドを実装すればよく、Renderer、PhysicsEngine、SoundEngineを修正したり再コンパイルしたりする必要はない。他のプログラマーも、私の大事なコードにバグを入れずにエンティティを追加できる。よさそうに見えるしかし、これで
CarとDogはグラフィックス、物理、サウンドのすべてを知らなければならなくなる。そしてエンティティは孤立して存在しているわけではない。車と犬は正しい順序でレンダリングされなければならず、互いを隠すこともある。衝突も互いに確認しなければならない。実際のゲームジャムで経験したように、サウンド担当者が音の挙動を追加しようとして、すべてのオブジェクトの中に入っていかなければならない状況も生じるむしろ物理を考えるときは
Physics.collideAll()の中で作業し、必要ならinstanceofで特別扱いし、グラフィックスを考えるときはGraphics.renderAll()の中で作業する方がはるかによい日常的なバックエンド Java Web 開発でも同様だ。REST コントローラで Java オブジェクトを HTTP レスポンスに変換する方法を決めるとき、一つのメソッドの中ですべてを見て、
{instanceof Forbidden}は 403、{instanceof NotFound}は 404 にマッピングする方がよい。Java クラス自体にgetCode()や REST 専用の内容を入れたいとは思わないStringがfinalであるのには理由があり、むしろfinalをデフォルトにして、サブクラス化を許すクラスだけをopenと明示すべきだ、と考えることもできる関数型プログラミングにおける直和型の典型例はリストだ。そこには
Element(T head, List tail)とNil()しかない。これを拡張する理由はなく、実際に拡張すると、リストを扱うすべての関数と組み合わせたときに誤ったコードになり得るまた、パターンマッチングに似た Visitor パターン は非常に冗長で、Java の通常のメソッドディスパッチの意味論を利用したハックに依存している。ここでは、パターンマッチングの方が何倍も読みやすいと思う
例えば、セキュリティトークンを検証するセキュリティインターフェースを考えられる
通常のインターフェースなら、実装してトークンを無視したり(全許可)、トークンを抜き取ったり、バックドアを仕込んだりすることが容易だ。そのようなクラスがセキュリティチェックの行われる場所に注入されると、セキュリティが破られる可能性がある
sealed インターフェースなら、承認されていない新しい実装は存在できない。そのインターフェースを実装していると主張するオブジェクトを受け取ったなら、それは実際にセキュリティチェックを行う検証済みの実装の一つであることが保証される。セキュリティバグやエクスプロイトの一分類を丸ごと取り除いたことになる
直和型については知っているものの、Java の直和型についてはあまり知らない立場としては良い記事。
ただ、直和型だけで Java を好きになれるかは分からない。広範な null 可能性は依然として残っており、この記事でも何度も顔を出している。
Google、Meta、Microsoft などが
@Nullableから始めてアノテーションを標準化しようとしている https://jspecify.dev/ には本当に期待している。「なぜ product type と呼ぶのか?」に対する記事の答えが間違っているわけではないが、より直感的かつ簡潔に言えば、product type で取り得る値の総数は、構成する型それぞれの取り得る値の数を掛け合わせたものになる。
product を sum に置き換えても同じように成り立つ。
興味深いことに、
a -> bという形の固有の関数の総数は、入力と出力だけで見ると指数で求められる。つまり(b の取り得る値の数) ^ (a の取り得る値の数)である。数学的に書くと、Bool のリストにおいて左側は要素数、右側は全体の可能性の数である。
0 : 1
1 : 2
2 : 4
3 : 8
4 : 16
5 : 32
このように続く。
Project Valhalla が完了し、Java にいよいよ値型が入るのを待っている。そうなれば直和型、値型、コルーチンまで揃い、かなり良い言語の一つになりそうだ。
Java はもともと本当に悪い言語ではなかった。
問題は人々だった。巨大な過剰エンジニアリング、コードベースの把握を難しくする多すぎる抽象概念、逆方向 GOTO 文のようなアノテーション形式のコード呪術、DI フレームワークが問題だった。
直すべきなのは言語ではなくエコシステムである。Java エコシステムの中に一種の「宗教改革」運動が必要だ。
Kotlin、Clojure、Scala に移るだけでは十分ではない。
HammerFactoryを量産するHammerFactoryFactoryは作れる。しかし Java エコシステムは、こうした問題解決の仕方を助長し、奨励している。C# も似ていると思う。Java に本当に必要なものの一つは、独立した関数、あるいは名前空間付きの関数だ。時にはクラスは不要で、モジュールや名前空間の中の関数で十分なことがあるのに、なぜそれができないのか分からない。
著者は Records が必要な理由を説明し、ほとんどの Java オブジェクトがすべてのフィールドを private にし、読み書き用のアクセサメソッドだけでアクセスさせている点を挙げている。
しかしアクセサを定義するための言語レベルの強制的な規約はないため、
fooの getter にgetBarという名前を付けても動作はするが、barにアクセスしようとする人を混乱させる可能性がある。Scala は
unapplyメソッドを実装したオブジェクトに対するパターンマッチングをサポートしている。この方式は有害だと見なされているのだろうか? Java はなぜこの道をたどらなかったのだろう?unapplyのようなものが準備中かもしれないと示唆しているので、まだ希望が完全に消えたわけではない。Java は常に優れた言語だった。吐き気を催させるのはエンタープライズ式エコシステムである。1 行のロジックを実装するために、数十個のクラスとインターフェースを使うのを見たことがある。