キーポイント
- Java SE 14 (March 2020) introduced a limited form of pattern matching as a preview feature, which becomes a permanent feature in Java SE 16 (March 2021).
- The first phase of pattern matching is limited to one kind of pattern (type patterns) and one language construct (instanceof), but this is only the first installment in a longer feature arc.
- At the simplest level, pattern matching allows us to reduce the ceremony of conditional state extraction, where we ask a question about some object (such as "are you a Foo"), and, if the answer is positive, we extract some state from the target: e.g. "if (x instanceof Integer i) { ... }" where i is the binding variable
- Binding variables are subject to definite assignment, but take this one step further: the scope of a binding variable is the set of places in the program where it would be definitely assigned. This is called flow scoping.
- Pattern matching is a rich feature arc that will play out over several Java versions. Future installments will bring us patterns in switch, deconstruction patterns on records, and more, with the aim of making destructuring objects as easy as (and more structurally similar to) constructing them.
Java SE 14 (2020年3月) では、プレビュー機能として限定された形式のパターンマッチングが導入されました。これは、Java SE 16 (2021年3月) の永続的な機能になります。
パターンマッチングの最初のフェーズは、1種類のパターン (型パターン) と1つの言語構造 (instanceof
) に制限されていますが、これは、長期に渡って増加する機能の最初のインストールにすぎません。
最も単純なレベルでは、パターンマッチングにより、条件付き状態抽出の式を減らすことができます。条件付き状態抽出では、オブジェクト (「あなたは Foo
ですか」など)に質問し、答えが正の場合は、ターゲットから状態を抽出します。
instanceof
によりオブジェクトの型をクエリすることは、条件付き抽出の形式です。そして、常に次に行なわれるのは、厳密化された型の参照を抽出するためにターゲットをその型にキャストすることです。
典型的な例は、java.util.EnumMap
のコピーコンストラクタにあります:
public EnumMap(Map<K, ? extends V> m) {
if (m instanceof EnumMap) {
EnumMap<K, ? extends V> em = (EnumMap<K, ? extends V>) m;
// optimized copy of map state from em
} else {
// insert elements one by one
}
}
コンストラクタは別の Map
を取得します。これは EnumMap
である場合と異なる場合があります。そうである場合、コンストラクタはそれを EnumMap
にキャストし、より効率的な方法でマップの状態をコピーできます。異なる場合は、一般的なアプローチにフォールバックします。
テストとキャストの慣用句は不必要に冗長に見えます -- m instanceof EnumMap
の結果を得た後、他に何かすることがありますか? パターンマッチングを使用すると、(特に) テストとキャストを1つの操作にまとめることができます。型パターンは、型名とバインディング変数の宣言を組み合わせたもので、instanceof
が成功すると、ターゲットの厳密化された型にバインドされます:
public EnumMap(Map<K, ? extends V> m) {
if (m instanceof EnumMap<K, ? extends V> em) {
// optimized copy of map state from em
} else {
// insert elements one by one
}
}
上記の例では、EnumMap<K, ? extends V> em
は型パターンです。(変数宣言は誤りではないように見えます) instanceof
を拡張して、プレーンな型だけでなくパターンも受け入れます。m
がこのパターンに一致するかの確認は、if
ステートメントの最初のアームで、最初に EnumMap
であるかをテストし、一致する場合は EnumMap
にキャストし、結果を em
にバインドすることを意味します。
instanceof
の後にキャストしなければならないことは、いつも少し残念な作法でしたが、これらの操作を融合することの利点は、単なる簡潔さ (簡潔さは素晴らしいですが) ではありません。一般的なエラーの原因も排除します。instanceof
/cast ペアをカットアンドペーストし、instanceof
のオペランドを変更し、キャストの変更を忘れるのはよくある間違いです。このような繰り返しはバグが潜む場所を提供します。それらの棲みかを排除することにより、バグのカテゴリー全体を排除することができます。
型通りにテストしてキャストするもう1つの場所は、Object::equals
の実装です。IDEは、Point
クラスに対して次の equals()
メソッドを生成する場合があります:
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
このコードは十分に簡素ですが、制御フローが短絡すると、コードの動作を追跡するのが少し難しくなります。パターンマッチを使用した同等のコードは次のとおりです:
public boolean equals(Object o) {
return (o instanceof Point p)
&& x == p.x && y == p.y;
}
このコードは同じように効率的ですが、アドホックな制御フローを使用したステートメントとしてではなく、単一の複合ブール式として等式条件を表現できるため、より簡単です。バインディング変数 p
のスコープは、フローに依存します。&&
で結合された式など、確実に割り当てられるスコープ内にのみ存在します。
すべてのパターンマッチングがJavaコードのキャストの99%を排除することであったとしても、それは確かに人気がありますが、パターンマッチングの可能性はもっと深くなります。時間が経てば、より複雑な条件付き抽出を実行できる他の種類のパターン、パターンを構成するためのより洗練された方法、およびパターンを使用できる他の構造 (switch
や catch
など) があります。Recordの関連機能と一緒に Sealedクラス、パターンマッチングは、今日作成するコードの多くを単純化および合理化する可能性を秘めています。
バインディング変数のスコープ
パターンは、テスト、テストが成功した場合のターゲットからの状態の条件付き抽出、および抽出の結果を受け取るためのバインディング変数を宣言する方法を具体化します。これまでに、型パターンという1種類のパターンを見てきました。これらは T t
として表されます。ここで、適合性テストは instanceof T
であり、抽出される状態の単一の要素があり (T
へのターゲット参照をキャストします)、t
はキャストの結果を受け取る新しい変数の名前です。現在、パターンは instanceof
の右側でのみ使用できます。
パターンのバインディング変数は「通常の」ローカル変数ですが、宣言の場所とスコープという2つの新しい側面があります。トップレベルのステートメント (Foo f = new Foo()
) によって、または for
ループや try-with-resources ブロックなどのステートメントの冒頭で、「左マージンで」宣言されているローカル変数に慣れています。パターンは、ステートメントまたは式の「途中」でローカル変数を宣言します。これには慣れるのに少し時間がかかるかもしれません:
if (x instanceof Integer i) { ... }
instanceof
の右側にある i
は、実際はローカル変数 i
の宣言です。
バインディング変数の他の新しい側面は、それらのスコープです。「通常の」ローカル変数のスコープは、その宣言から、宣言されたステートメントまたはブロックの終わりまで継続します。ローカルはさらに明確な割り当ての対象となります。これはフローベースの分析であり、すでに割り当てられていることが明確でない場合は読み取ることができません。バインディング変数も明確な割り当ての対象となりますが、さらに一歩進みます。バインディング変数のスコープは、プログラム内で確実に割り当てられる場所のセットです。これはフロースコープと呼ばれます。
フロースコープの簡単な例はすでに見てきました。Point
の equals
メソッドの宣言では、次のように述べています:
return (o instanceof Point p)
&& x == p.x && y == p.y;
バインディング変数 p
は instanceof
式で宣言されており、&&
が短絡しているため、instanceof
式がtrueの場合にのみ x == p.x
に到達できます。したがって、p
は式 x == p.x
で確実に割り当てられます。したがって、この時点で p
はスコープ内にあります。ただし、&&
を ||
に置き換えた場合、||
の2番目の節に到達する可能性があるため、p
がスコープ内にないというエラーが発生します。最初の節が真でない式であるため、その時点で p
は明確に割り当てられません。(仕様の明確な割り当てルールはやや厄介なスタイルで書かれていますが、何が何の前に起こるかについての私たちの直感には適合しています)
同様に、パターンマッチングが if
ステートメントのヘッダに存在する場合、バインディングは if
の一方または他方のアームのスコープ内にはありますが、両方ではありません:
if (x instanceof Foo f) {
// f in scope here
}
else {
// f not in scope here
}
同様に:
if (!(x instanceof Foo f)) {
// f not in scope here
}
else {
// f in scope here
}
バインディング変数のスコープはコントロールフローに関連付けられているため、if
条件の反転やド・モルガンの法則の適用などのリファクタリングは、コントロールフローを変換するのとまったく同じ方法でスコープを変換します。
古い「ブロックの終わりまでスコープを継続する」というローカル用に常にあったルールを続けることができたのに、なぜこのより複雑なスコープアプローチが選択されたのか不思議に思うかもしれません。そして答えは次のとおりです: 続けることができましたが、おそらく結果は気に入らなかったことでしょう。Javaは、ローカルによるローカルのシャドウイングを禁止しています。バインディング変数のスコープが含まれているブロックの終わりまで継続されたとすると、次のようにチェーンします:
if (x instanceof Integer num) { ... }
else if (x instanceof Long num) { ... }
else if (x instanceof Double num) { ... }
num
の不正な再宣言を行うことになり、出現するたびに新しい名前を作成する必要があります。(switch
の case
ラベルのパターンが到達して真の場合も同じです) else
に到達するまでに num
をスコープ内に含めないようにすることで、else
節で (新しい型で) 新しい num
を自由に再宣言できます。
フロースコープは、バインディング変数のスコープが宣言ステートメントをエスケープするかどうかに関して、両方の長所を活用することもできます。上記の if-else
の例では、バインディング変数は if-else
の一方のアームまたは他方のスコープ内にありましたが、両方にはなく、if-else
に続くステートメントにはありませんでした。ただし、if
のいずれかのアームが常に (リターンや例外のスローなどで) 途中で終了する場合、これを使用してバインディングの範囲を拡張できます -- これは通常、必要なものであることがわかります。
次のようなコードがあるとします:
if (x instanceof Foo f) {
useFoo(f);
}
else
throw new NotFooException();
このコードは問題ありませんが、面倒です。多くの開発者は、次のようにリファクタリングすることを好みます:
if (!(x instanceof Foo f))
throw new NotFooException();
useFoo(f);
2つは同じことをしますが、後者はいくつかの点で読者の認知的負荷を軽減します。「ハッピー」コードパスははっきりと目立ちます。if
(または、さらに悪いことに、if
の場合は深くネストされたセットがある) に従属するのではなく、トップレベルに配置することで、認識の中心になります。さらに、メソッドへのエントリ時に前提条件を確認し、前提条件が失敗した場合はスローすることで、読者は「しかし、Foo
でない場合はどうなるか」というシナリオを頭に入れておく必要はありません。前提条件の失敗は事前に処理されています。
後者の例では、f
は確実に割り当てられるためメソッドの残りの部分のスコープ内にあります。つまり、x
が Foo
でないと、useFoo()
呼び出しに到達する方法がないため、x
を Foo
にキャストした結果を f
にバインドします -- if
の本体では常にスローされます。明確な割り当て分析では、途中の終了が考慮されます。フロースコープがなければ、次のように書く必要がありました:
if (!(x instanceof Foo f))
throw new NotFooException();
else {
useFoo(f);
}
一部の開発者は、ハッピーパスが else
ブロックに追いやられることに苛立ちを感じるだけでなく、前提条件の数が増えると (特にあるものが別のブロックに依存している場合)、構造が次第に複雑になり、ハッピーパスコードがどんどん右に追いやられます。
もう1つの新しい考慮事項は、ジェネリックとのパターンマッチングの相互作用です。EnumMap
の例では、ターゲットのクラスだけでなく、その型パラメーターについてもテストしているように見えたかもしれません:
public EnumMap(Map<K, ? extends V> m) {
if (m instanceof EnumMap<K, ? extends V> em) {
// optimized copy of map state from em
}
...
しかし、私たちはJavaのジェネリックが消去されることはわかっており、ランタイムが答えられない質問をすることは許可されていません。では、ここで何が起こっているのでしょうか? ここでの型テストには、静的コンポーネントと動的とがあります。コンパイラは、EnumMap<K, V>
が Map<K, V>
のサブタイプであることを認識しています (EnumMap
の宣言から: class EnumMap<K, V> implements Map<K, V>
) 。したがって、Map<K, V>
が EnumMap
(動的テスト) であれば、それは、EnumMap<K, V>
でなければなりません。コンパイラは、型パターンで提案された型パラメータをチェックして、ターゲットについて既知のものとの整合性を確認します。変換が確認されていないテスト対象の型にターゲットをキャストする場合、パターンは許可されません。したがって、instanceof
で型パラメータを使用できますが、整合性を静的に検証できる程度に限られます。
どこに向かっているのか?
これまでのところ、このパターンマッチング機能は非常に制限されています。パターンを使用できるのは、1種類のパターン (型パターン) と1つのコンテキスト (instanceof
) です。この限られたサポートでも、すでに大きな利点があります。冗長なキャストがなくなり、冗長なコードが排除され、より重要なコードに焦点が絞られ、同時にバグを隠す場所が排除されます。しかし、これはパターンマッチングがJavaにもたらすすばらしいことの始まりにすぎません。
パターンマッチングを追加する可能性のある明らかな次のコンテキストは switch
ステートメントです。これは現在、厳密な型のセット (数値、文字列、列挙型) と、それらの型で表現できる厳密な条件のセット (定数比較) に制限されています。switch
の表現力を劇的に向上させる case
ラベルで、定数だけではないパターンを許可します。次に、すべての型を切り替えて、定数のセットとの単なる比較よりもはるかに興味深い多様な条件を表現できます。
Javaにパターンマッチングを追加する大きな理由は、Javaが集約を状態コンポーネントに分解するためのより原理的な手段を提供することです。Javaのオブジェクトは、集約とカプセル化によって抽象化を提供します。集約により、特定のデータから一般的なデータまでデータを抽象化でき、カプセル化により、集約データの整合性を確保できます。しかし、私たちはしばしばこの完全性に対して高い代償を払います。多くの場合、コンシューマはオブジェクトの状態をクエリできるようにしたいので (アクセサーメソッドなどの) 制御された方法で実行するAPIを提供します。ただし、状態アクセス用のこれらのAPIはアドホックであることが多く、オブジェクトを作成するためのコードは それを分解するためのコードのようには見えません (new Point(x, y)
を使用して (x,y)
の状態から Point
を構築しますが、getX()
および getY()
を呼び出すことによって状態を回復します)。パターンマッチングはオブジェクトモデルに 分解 (構築の二重化) をもたらすことによって、オブジェクトモデルの長年のギャップに取り組みます。
この典型的な例は、Recordの分解パターンです。Records は、透過的なデータ保有クラスの簡潔な形式です。この透明性は、それらの構造が可逆的であることを意味します。Recordが多数のメンバ (コンストラクタ、アクセサー、Object
メソッド) を自動的に取得するのと同様に、それらは「逆のコンストラクタ」と考えることができる分解パターンを自動的に取得することもできます -- コンストラクタは状態を取得してオブジェクトに集約します。分解パターンはそのオブジェクトを取得し、分解して状態に戻します。Shape
を作成する場合:
Shape s = new Circle(new Point(3, 4), 5);
結果の Shape を次のように分解できます:
if (s instanceof Circle(Point center, int radius)) {
// center and radius in scope here
}
パターン Circle(Point center, int radius)
は分解パターンです。ターゲットが Circle
であるかどうかを尋ね、そうである場合は、Circle
にキャストし、中心 (center) と半径 (radius) コンポーネントを抽出します (record
の場合、対応するアクセサメソッドを呼び出すことでこれを行います) 。
分解パターンはまた、構成の機会を提供します。Circle
の Point
コンポーネントは、それ自体が分解可能な集合体であり、ネストされたパターンを使用して次のように表現できます:
if (s instanceof Circle(Point(int x, int y), int radius) {
// x, y, and radius all in scope here
}
ここでは、Circle
の center
コンポーネントを抽出した後、結果をさらに Point(var x, var y)
パターンに一致させます。ここにはいくつかの重要な対称性があります。まず、構築と分解の構文表現は構造的に類似しています。類似のイディオムを使用して、物事を構築し、分解することができます。(Recordの場合、両方ともRecordの状態記述から導出できます) これまでは、大きな非対称性がありました。コンストラクタを使用して構築して、集約のイディオムとはまったく異なるアドホックなAPI呼び出し (getter など) を使用してそれらを分解しました。この非対称性は、開発者に認知的負荷を課し、バグを隠す場所を提供しました。こんどは、構築と分解が同じ方法で構成されます。つまり、Point
コンストラクタ呼び出しを Circle
コンストラクタ呼び出しにネストでき、Point
分解パターンを Circle
分解パターンにネストできます。
Records、Sealed 型、および分解パターンは、心地よい方法で連携します。式ツリーに対して次の一連の宣言があるとします:
sealed interface Node {
record ConstNode(int i) implements Node { }
record NegNode(Node n) implements Node { }
record AddNode(Node left, Node right) implements Node { }
record MultNode(Node left, Node right) implements Node { }
}
パターンスイッチを使用してこれを評価するコードは次のように記述できます:
int eval(Node n) {
return switch (n) {
case ConstNode(int i) -> i;
case NegNode(var node) -> -eval(node);
case AddNode(var left, var right) -> eval(left) + eval(right);
case MulNode(var left, var right) -> eval(left) * eval(right);
// no default needed, Node is sealed and we covered all the cases
};
}
この方法で switch
を使用すると、対応する if-else
テストのチェーンよりも簡潔でエラーが発生しにくくなります。また、switch
式は、Sealed クラスの許可されたサブタイプをすべてカバーした場合、それは完全であり、すべてをキャッチする default が必要ないことを認識します。
Records と Sealed クラスを合わせて、代数的データ型 (algebraic data types) と呼ばれることもあります。Records にパターンマッチングを追加し、パターンをオンにすると、代数的データ型を安全かつ簡単に抽象化できます。(タプル型と合計型が組み込まれている言語にもパターンマッチングが組み込まれている傾向があるのは偶然ではありません)
パターンマッチングのロードマップは、ここで説明するよりもさらに拡張されています。通常のクラスでは、コンストラクタと一緒に分解パターンを宣言し、静的ファクトリと一緒に静的パターン (case Optional.of(var contents)
など) を宣言できます。一緒に、これはより「対称的な」API設計の時代の到来を告げるものであり、それらをまとめるのと同じくらい簡単かつ定期的に分解することができます (もちろん、これが必要な場合のみ) 。
選ばざる道
過去の条件に基づいて洗練された型を推測できるようにすること (フロータイピングと呼ばれることが多い) は、コンパイラに対する長年の要求です。たとえば、x instanceof Foo
を条件とする if
ステートメントがある場合、コンパイラは if の本体の内側で、その型が交差した型 X&Foo
(X
は x
の静的型) にリファインでき推測できます。これにより、今日発行する必要のあるキャストも排除されます。それでは、なぜ私たちはこれをしなかったのでしょうか? 簡単な答えは次のとおりです。これは非常に弱い機能です。フロータイピングはこの特定の問題を解決しますが、そのほとんどすべては、instanceof
の後のキャストを取り除くだけで、劇的に低い投資回収を提供します -- それは、より豊富な switch
、分解、または、より良いAPI有効化のストーリーを提供しません。(言語機能が進むにつれて、それは実際の機能というよりも「バンドエイド」のようなものです)
同様に、もう1つの長年の要求は「型 switch」です。この場合、定数値だけでなく、ターゲットの型を切り替えることができます。繰り返しになりますが、これには具体的な利点があります。つまり、一部の if-else
チェーンを switch に変えることですが、言語全体を改善するための滑走路がはるかに少なくなります。パターンマッチングはこれらの利点をもたらしますが、それだけではできません。
まとめ
パターンマッチングは、いくつかのバージョンで豊富な機能が順次追加されます。最初のインストールでは、instanceof
で型パターンを使用できるため、このようなコードの作法が減りますが、今後のインストールでは、オブジェクトを構築するように (より構造的には同じであるように) 簡単にオブジェクト分解をすることを目的として、switch のパターン、Record の分解パターンなどを提供します。
著者について
Brian Goetz氏 は、OracleのJava言語アーキテクトであり、JSR-335 (Javaプログラミング言語のラムダ式) の仕様リーダでした。彼は、ベストセラーの Java Concurrency in Practice (Java並行処理プログラミング) の作成者であり、Jimmy Carter氏が大統領であった時以来プログラミングに魅了されてきました。