BT

最新技術を追い求めるデベロッパのための情報コミュニティ

寄稿

Topics

地域を選ぶ

InfoQ ホームページ アーティクル Java 注目の機能: パターンマッチング

Java 注目の機能: パターンマッチング

キーポイント

  • 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.

原文(投稿日:2021/01/22)へのリンク

プレビュー機能

Javaプラットフォームのグローバルなリーチと高い互換性への取り組みを考えると、言語機能の設計ミスのコストは非常に高くなります。言語の誤った機能のコンテキストでは、互換性への取り組みは、機能を削除または大幅に変更することが非常に難しいことを意味するだけでなく、既存の機能も将来の機能で実行できることを制約します -- 今日の輝かしい新機能は明日の互換性の制約です。

言語機能の究極の証明の場は実際の使用です。機能が意図したとおりに動作していることを確認するには、実際に現実のコードベースで試してみた開発者からのフィードバックが不可欠です。Javaに複数年のリリースサイクルがあったとき、実験とフィードバックのために十分な時間がありました。新しい短期のリリース間隔の下で実験とフィードバックのための十分な時間を確保するために、新しい言語機能は、プラットフォームの一部であるが、個別にオプトインする必要があり、まだ永続的ではないプレビューで1回以上のラウンドを通します -- そうすることで、開発者からのフィードバックに基づいて調整する必要がある場合に、ミッションクリティカルなコードを壊すことなくこれを行うことができます。

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%を排除することであったとしても、それは確かに人気がありますが、パターンマッチングの可能性はもっと深くなります。時間が経てば、より複雑な条件付き抽出を実行できる他の種類のパターン、パターンを構成するためのより洗練された方法、およびパターンを使用できる他の構造 (switchcatch など) があります。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宣言です。

バインディング変数の他の新しい側面は、それらのスコープです。「通常の」ローカル変数のスコープは、その宣言から、宣言されたステートメントまたはブロックの終わりまで継続します。ローカルはさらに明確な割り当ての対象となります。これはフローベースの分析であり、すでに割り当てられていることが明確でない場合は読み取ることができません。バインディング変数も明確な割り当ての対象となりますが、さらに一歩進みます。バインディング変数のスコープ、プログラム内で確実に割り当てられる場所のセットです。これはフロースコープと呼ばれます。

フロースコープの簡単な例はすでに見てきました。Pointequals メソッドの宣言では、次のように述べています:

return (o instanceof Point p)
    && x == p.x && y == p.y;

バインディング変数 pinstanceof 式で宣言されており、&& が短絡しているため、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 の不正な再宣言を行うことになり、出現するたびに新しい名前を作成する必要があります。(switchcase ラベルのパターンが到達して真の場合も同じです) 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 は確実に割り当てられるためメソッドの残りの部分のスコープ内にあります。つまり、xFoo でないと、useFoo() 呼び出しに到達する方法がないため、xFoo にキャストした結果を 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 の場合、対応するアクセサメソッドを呼び出すことでこれを行います) 。

分解パターンはまた、構成の機会を提供します。CirclePoint コンポーネントは、それ自体が分解可能な集合体であり、ネストされたパターンを使用して次のように表現できます:

if (s instanceof Circle(Point(int x, int y), int radius) {
    // x, y, and radius all in scope here
}

ここでは、Circlecenter コンポーネントを抽出した後、結果をさらに 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 にパターンマッチングを追加し、パターンをオンにすると、代数的データ型を安全かつ簡単に抽象化できます。(タプル型と合計型が組み込まれている言語にもパターンマッチングが組み込まれている傾向があるのは偶然ではありません)

パターンマッチングの歴史

パターンマッチングはJavaにとって新しいかもしれませんが、新しいものではありません。多くの言語で長い歴史があります (おそらく1960年代のテキスト処理言語 SNOBOL までさかのぼります) 。今日、多くの開発者はパターンマッチングを関数型言語と関連付けていますが、これは主に歴史の偶然です。パターンマッチングは、静的に型付けされた関数型言語に実際に適しています。このような言語には、タプルとシーケンスの構造型が組み込まれている傾向があり、この種の集合体を分解するための理想的なツールです。しかし、パターンマッチングは、関数型言語と同じようにオブジェクト指向言語でも意味があります。ScalaとF#は、オブジェクトと関数型のハイブリッドでパターンマッチングを実験した最初の言語です。Javaは (最終的に) パターンマッチングをオブジェクトモデルにより深く取り入れます。

パターンマッチングのロードマップは、ここで説明するよりもさらに拡張されています。通常のクラスでは、コンストラクタと一緒に分解パターンを宣言し、静的ファクトリと一緒に静的パターン (case Optional.of(var contents) など) を宣言できます。一緒に、これはより「対称的な」API設計の時代の到来を告げるものであり、それらをまとめるのと同じくらい簡単かつ定期的に分解することができます (もちろん、これが必要な場合のみ) 。

選ばざる道

過去の条件に基づいて洗練された型を推測できるようにすること (フロータイピングと呼ばれることが多い) は、コンパイラに対する長年の要求です。たとえば、x instanceof Foo を条件とする if ステートメントがある場合、コンパイラは if の本体の内側で、その型が交差した型 X&Foo (Xxの静的型) にリファインでき推測できます。これにより、今日発行する必要のあるキャストも排除されます。それでは、なぜ私たちはこれをしなかったのでしょうか? 簡単な答えは次のとおりです。これは非常に弱い機能です。フロータイピングはこの特定の問題を解決しますが、そのほとんどすべては、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氏が大統領であった時以来プログラミングに魅了されてきました。

この記事に星をつける

おすすめ度
スタイル

BT