※本記事は、“Bruce Eckel on pattern matching in Java” の翻訳記事です。

2022年5月21日| 10分読む

Bruce Eckel


現在、instanceofおよびswitchにパターン変数を使用できます。どのように動作するかをご覧ください。

 

[このシリーズでは、Java 8以降にJava言語に追加された新機能について説明します。これは、2021年12月に公開された「On Java 8」の第2版に追加された資料から引用しています。これらの記事のソースコードおよびブック全体については、GitHubから、詳細なインストール手順とともに入手できます。— 編集部]

JDK 16では、JDK Enhancement Proposal(JEP) 394、 instanceofのためのパターン・マッチングが仕上げられました。以下に、同じことをする古い方法と新しい方法を示します。

// enumerations/SmartCasting.java
// {NewFeature} Since JDK 16

public class SmartCasting {
  static void dumb(Object x) {
    if(x instanceof String) {
      String s = (String)x;
      if(s.length() > 0) {
        System.out.format(
          "%d %s%n", s.length(), s.toUpperCase());
      }
    }
  }
  static void smart(Object x) {
    if(x instanceof String s && s.length() > 0) {
      System.out.format(
        "%d %s%n", s.length(), s.toUpperCase());
    }
  }
  static void wrong(Object x) {
    // "Or" は機能しません:
    // if(x instanceof String s || s.length() > 0) {}
    // エラー: 記号が見つかりません   ^
  }
  public static void main(String[] args) {
    dumb("dumb");
    smart("smart");
  }
}
/* Output:
4 DUMB
5 SMART
*/

( {NewFeature}コメント・タグによって、JDK 8を使用するGradleビルドから、この例は除外されます。)

dumb()では instanceof が、xが Stringであることを確認した後、これを String sに明示的にキャストする必要があります。それ以外の場合は、残りの関数全体にキャストを挿入します。しかし、 smart()の中で、 x instanceof String sに注目してください。

String型の新しい変数 s が自動的に作成されています。文字列 sは、 if 条件の残りの部分でもスコープ全体で使用できます。

対照的に、wrong() では、 && のみがパターンマッチング if 式内で利用できることを示しています。 || を利用することは、すなわち xinstanceof String 型であるか、s.length() > 0.であることを意味します。そうすると、xStringではない可能性があるため、その場合、Java はx から sにキャストできません。つまり、 s||の右側で有効ではなくなってしまいます。

JEP 394 では、 s をパターン変数と呼びます。

この機能は、複雑な if 文を綺麗にするものですが、それがこの機能を追加する同期ではありませんでした。この機能は、パターンマッチングの構成要素として含められたものです。これから簡単に見ていきましょう。

ところで、この機能は、以下のような奇妙なスコープの動作を生み出すことがあります。

// enumerations/OddScoping.java
// {NewFeature} Since JDK 16

public class OddScoping {
  static void f(Object o) {
    if(!(o instanceof String s)) {
      System.out.println("Not a String");
      throw new RuntimeException();
    }
    // s はここでスコープに入ります!
    System.out.println(s.toUpperCase());  // line [1]
  }
  public static void main(String[] args) {
    f("Curiouser and Curiouser");
    f(null);
  }
}
/* Output:
CURIOUSER AND CURIOUSER
Not a String
Exception in thread "main" java.lang.RuntimeException
        at OddScoping.f(OddScoping.java:8)
        at OddScoping.main(OddScoping.java:15)
*/

行[1]で、文字列sは例外をスローする文を含まない場合にのみスコープ内になります。新しい RuntimeException()をスローするとコメントアウトすると、コンパイラから[1]行に文字列sが見つからないことが示されます。これは通常予期される動作です。

最初はバグのように見えるかもしれませんが、この動作はJEP 394で明示的に説明されています。これはおそらくコーナーケースですが、この動作によって発生したバグを追跡することがどれほど困難であるか想像できます。

 

パターン・マッチング

すべての基本的な要素の準備が整ったので、Javaでのパターンマッチングの全体像を確認できるようになりました。

この記事執筆の時点で、 instanceof のパターンマッチングがJDK 16でJEP 394として提供されました。 switch のパターン一致は、JDK 17ではJEP 406としてプレビュー機能で、JDK 18ではJEP 420として2番目のプレビューがありました。これは、パターンマッチングが将来のバージョンで大幅に変更または大幅に変更されない可能性があるが、最終決定されていないことを意味します。これを読むまでに、ここに示す機能はプレビューでなくなる可能性があるため、コメント例に示されているコンパイラおよび実行時フラグを使用する必要がなくなっているかもしれません。

 

Liskov Substitution Principleの違反

継承ベースのポリモフィズムは、型ベースの動作をしますが、これらの型は同じ継承階層内にある必要があります。パターン・マッチングを使用すると、すべてのインタフェースが同じでない型や、同じ階層にない型に対しても型ベースの動作が可能になります。

これは、リフレクションを使用する別の方法です。実行時でも型は決定しますが、これはリフレクションよりも形式的で構造化された方法です。

もしある型のすべてが共通の基底型を持ち、その共通基底型で定義されたメソッドのみを使用する場合は、 Liskov Substitution Principle (LSP)に準拠します。この場合、次のように通常の継承ポリモフィズムのみを使用できるため、パターン一致は必要ありません。

// enumerations/NormalLiskov.java
import java.util.stream.*;

interface LifeForm {
  String move();
  String react();
}

class Worm implements LifeForm {
  @Override public String move() {
    return "Worm::move()";
  }
  @Override public String react() {
    return "Worm::react()";
  }
}

class Giraffe implements LifeForm {
  @Override public String move() {
    return "Giraffe::move()";
  }
  @Override public String react() {
    return "Giraffe::react()";
  }
}

public class NormalLiskov {
  public static void main(String[] args) {
    Stream.of(new Worm(), new Giraffe())
      .forEach(lf -> System.out.println(
        lf.move() + " " + lf.react()));
  }
}
/* Output:
Worm::move() Worm::react()
Giraffe::move() Giraffe::react()
*/

すべてのメソッドは LifeForm

インタフェースに適切に定義され、実装されたどのクラスにも新しいメソッドは追加されていません。

ただし、基底型に簡単に配置できないメソッドを追加する必要がある場合はどうすればよいでしょうか。例えば、虫の中には、分裂すると繁殖できるものがありますが、キリンは絶対にそのようなことはできません。キリンはキックすることはできますが、Wormの実装に問題が生じないように、それを基底クラスで表現する方法を想像することは困難です。

Javaコレクション・ライブラリはこの問題に遭遇し、いくつかのサブクラスに実装されているが、他のサブクラスには実装されていない基底型にオプション・メソッドを追加して解決しようとしました。このアプローチはLSPに準拠していましたが、混乱する設計になりました。

Javaは、Smalltalkという既存のクラスにメソッドを追加することでコードを再利用する動的な言語から基本的なインスピレーションを受けています。SmalltalkのPet階層の設計では、次のようになります。

// enumerations/Pet.java

public class Pet {
  void feed() {}
}

class Dog extends Pet {
  void walk() {}
}

class Fish extends Pet {
  void changeWater() {}
}

基本的な Pet機能を使用し、必要に応じてメソッドを追加してそのクラスを拡張できます。これは、通常はJavaで推奨されているもの(および NormalLiskov.javaに示されているもの)とは異なります。Javaでは、階層全体に必要なすべてのメソッドを含めるように基底型を慎重に設計し、LSPに適合させます。これは素晴らしい目標ですが、現実的ではないかもしれません。

動的に型付けされたSmalltalkモデルを静的に型付けされたJavaシステムに強制的に組み込もうとすると、妥協が生じます。場合によっては、これらの妥協は機能しない可能性があります。パターン・マッチングでは、LSPの形式の大部分を維持しながら、派生クラスに新しいメソッドを追加するSmalltalkのアプローチを使用できます。基本的に、パターン・マッチングでは、管理しにくいコードを作成せずにLSPに違反できます。

パターン・マッチングを使えば、可能性のあるタイプごとにチェックし、異なるコードを書くことで、 Pet 階層の非LSPの性質に対処することができます。

// enumerations/PetPatternMatch.java
// {NewFeature} Preview in JDK 17
// Compile with javac flags:
//   --enable-preview --source 17
import java.util.*;

public class PetPatternMatch {
  static void careFor(Pet p) {
    switch(p) {
      case Dog d -> d.walk();
      case Fish f -> f.changeWater();
      case Pet sp -> sp.feed();
    };
  }
  static void petCare() {
    List.of(new Dog(), new Fish())
      .forEach(p -> careFor(p));
  }
}

switch(p) p はセレクタ式と呼ばれます。パターン一致の前に、セレクタ式は整数プリミティブ型(char, byte, short, または int)、対応するボックス形式(Character, Byte, Short, または Integer), Stringまたは enum 型のみになります。パターン・マッチングでは、任意の参照タイプが含まれるようにセレクタ式が展開されます。ここで、セレクタ式は Dog, Fish,または Petです。

これは継承階層内の動的バインディングに似ていますが、オーバーライドされたメソッド内に異なるタイプのコードを入れるかわりに、異なるcase式に記述することに注意して下さい。

コンパイラが強制的に case Pet を追加したのは、そのクラスが Dog またはFishではなく正当に存在しうるためです。 case Petがないと、 switchは使用可能なすべての入力値をカバーすることはできませんでした。基底型にインタフェースを使用すると、この制約はなくなりますが、別の制約が追加されます。次の例は、名前の衝突を防ぐために独自のパッケージに配置されています。

// enumerations/PetPatternMatch2.java
// {NewFeature} Preview in JDK 17
// Compile with javac flags:
//   --enable-preview --source 17
package sealedpet;
import java.util.*;

sealed interface Pet {
  void feed();
}

final class Dog implements Pet {
  @Override public void feed() {}
  void walk() {}
}

final class Fish implements Pet {
  @Override public void feed() {}
  void changeWater() {}
}

public class PetPatternMatch2 {
  static void careFor(Pet p) {
    switch(p) {
      case Dog d -> d.walk();
      case Fish f -> f.changeWater();
    };
  }
  static void petCare() {
    List.of(new Dog(), new Fish())
      .forEach(p -> careFor(p));
  }
}

Petsealedされていない場合、コンパイラは switch文で可能なすべての入力値をカバーしていないとクレームを出します。この場合、インタフェース Pet はほかの任意のファイルでも実装できるため、 switch 文による網羅的なカバレージを破ることができるためです。 Pet をシールすると、コンパイラはswitchが可能なすべての Pet タイプをカバーするよう保証することができます。

パターン・マッチングでは、継承ポリモフィズムの方法で単一の階層に制限されません。つまり、どんな型に対してもマッチングすることができます。たとえば、これを行うには、次のようにオブジェクトを switch に渡します。

// enumerations/ObjectMatch.java
// {NewFeature} Preview in JDK 17
// Compile with javac flags:
//   --enable-preview --source 17
// Run with java flag: --enable-preview
import java.util.*;

record XX() {}

public class ObjectMatch {
  static String match(Object o) {
    return switch(o) {
      case Dog d -> "Walk the dog";
      case Fish f -> "Change the fish water";
      case Pet sp -> "Not dog or fish";
      case String s -> "String " + s;
      case Integer i -> "Integer " + i;
      case String[] sa -> String.join(", ", sa);
      case null, XX xx -> "null or XX: " + xx;
      default -> "Something else";
    };
  }
  public static void main(String[] args) {
    List.of(new Dog(), new Fish(), new Pet(),
      "Oscar", Integer.valueOf(12),
      Double.valueOf("47.74"),
      new String[]{ "to", "the", "point" },
      new XX()
    ).forEach(
      p -> System.out.println(match(p))
    );
  }
}
/* Output:
Walk the dog
Change the fish water
Not dog or fish
String Oscar
Integer 12
Something else
to, the, point
null or Object: XX[]
*/

switch に Object パラメータを渡す場合、コンパイラでは、可能なすべての入力値を網羅するためにデフォルトが必要になります(ただし、nullは、起こりうることではありますが、コンパイラが要求するケースではありません)。

null, case null, XX xxのように、nullのケースをパターンと組み合せることもできます。これは、オブジェクト参照がnullになる可能性があるためです。

 

さらに詳しい情報