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

2022年6月4日| 8分読む

Bruce Eckel


パターン・マッチング・ガードを使用すると、型に対する照合のみでなく、照合条件を絞り込むことができます。

 

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

このシリーズの以前の記事「Bruce Eckel が語るJavaでのパターン・マッチング」では、パターンマッチングが紹介されました。この記事ではその詳細を掘り下げようと思います。この記事執筆の時点で、スイッチのパターン・マッチングはJDK 18の JEP 420としての2番目のプレビュー機能であり、Javaチームが機能を追加する意向であることに注意してください。

 

ガード

ガードを使用すると、型に対する照合のみでなく、照合条件を絞り込むことができます。これは、型および &&の後に現れるテストです。ガードには任意のブール式を使用できます。セレクタ式が case の型と同じで、ガードがtrueと評価された場合、パターンは次のように一致します。:

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

sealed interface Shape {
  double area();
}

record Circle(double radius) implements Shape {
  @Override public double area() {
    return Math.PI * radius * radius;
  }
}

record Rectangle(double side1, double side2)
  implements Shape {
  @Override public double area() {
    return side1 * side2;
  }
}

public class Shapes {
  static void classify(Shape s) {
    System.out.println(switch(s) {
      case Circle c && c.area() < 100.0
        -> "Small Circle: " + c;
      case Circle c -> "Large Circle: " + c;
      case Rectangle r && r.side1() == r.side2()
        -> "Square: " + r;
      case Rectangle r -> "Rectangle: " + r;
    });
  }
  public static void main(String[] args) {
    List.of(
      new Circle(5.0),
      new Circle(25.0),
      new Rectangle(12.0, 12.0),
      new Rectangle(12.0, 15.0)
    ).forEach(t -> classify(t));
  }
}
/* Output:
Small Circle: Circle[radius=5.0]
Large Circle: Circle[radius=25.0]
Square: Rectangle[side1=12.0, side2=12.0]
Rectangle: Rectangle[side1=12.0, side2=15.0]
*/

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

The first guard for Circle の最初のガードは、 Circle が小さいかどうかを調べます。Rectangle の最初のガードは、 Rectangle が正方形かどうかを調べます。

次に、より複雑な例を示します。Tank にはさまざまな種類の液体を保持でき、タンクの Levelはゼロから100%の間である必要があります。

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

enum Type { TOXIC, FLAMMABLE, NEUTRAL }

record Level(int percent) {
  Level {
    if(percent < 0 || percent > 100)
      throw new IndexOutOfBoundsException(
        percent + " percent");
  }
}

record Tank(Type type, Level level) {}

public class Tanks {
  static String check(Tank tank) {
    return switch(tank) {
      case Tank t && t.type() == Type.TOXIC
        -> "Toxic: " + t;
      case Tank t && (                 // [1]
          t.type() == Type.TOXIC &&
          t.level().percent() < 50
        ) -> "Toxic, low: " + t;
      case Tank t && t.type() == Type.FLAMMABLE
        -> "Flammable: " + t;
      // Equivalent to "default":
      case Tank t -> "Other Tank: " + t;
    };
  }
  public static void main(String[] args) {
    List.of(
      new Tank(Type.TOXIC, new Level(49)),
      new Tank(Type.FLAMMABLE, new Level(52)),
      new Tank(Type.NEUTRAL, new Level(75))
    ).forEach(
      t -> System.out.println(check(t))
    );
  }
}

record Levelには、 percent が有効であることを確認するコンパクト・コンストラクタが含まれています。レコードは、このシリーズの以前の記事「JavaレコードについてBruce Eckelがご紹介」で紹介されています。

行[1]のノートは次のとおりです。ガードに複数の式が含まれている場合は、カッコで囲みます。

コードがObjectではなく Tank に切り替わるため、最後の case Tank は、他のどのパターンにも一致しないすべての Tank ケースを捕捉するため、デフォルトと同じように機能します。

 

ドミナンス

switch 内の case 文の順序は、基底型が最初に出現すると、その後に出現するすべてのものより優位になるため、重要になることがあります。

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

sealed interface Base {}
record Derived() implements Base {}

public class Dominance {
  static String test(Base base) {
    return switch(base) {
      case Derived d -> "Derived";
      case Base b -> "B";            // [1]
    };
  }
}

基底型は、最後の位置の行[1]にあます。ただし、その行を上に移動すると、ベース・タイプは case Derivedの前に表示されます。これは、派生クラスがcase Baseで捕捉されるため、switch でをテストできないことを意味します。この実験を試みると、コンパイラはエラーを報告します: this case label is dominated by a preceding case label.

順序の繊細さは、ガードを使用するときによく現れます。 Tanks.java の最後のcaseを switch内の前の位置に移動すると、同じドミネーションエラーメッセージが生成されます。同じパターンに複数のガードがある場合は、より一般的なパターンの前に、より具体的なパターンを指定する必要があります。それ以外の場合、より一般的なパターンはより具体的なパターンよりも前に一致し、後者はチェックされません。幸いなことに、コンパイラはドミネーションの問題を報告してくれます。

コンパイラは、パターンの型が別のパターンの型より優位である場合にのみ、優位性の問題を検出できます。コンパイラは、ガード内のロジックで問題が発生するかどうかを認識できません。

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

record Person(String name, int age) {}

public class People {
  static String categorize(Person person) {
    return switch(person) {
      case Person p && p.age() > 40          // [1]
        -> p + " is middle aged";
      case Person p &&
        (p.name().contains("D") || p.age() == 14)
        -> p + " D or 14";
      case Person p && !(p.age() >= 100)     // [2]
        -> p + " is not a centenarian";
      case Person p -> p + " Everyone else";
    };
  }
  public static void main(String[] args) {
    List.of(
      new Person("Dorothy", 15),
      new Person("John Bigboote", 42),
      new Person("Morty", 14),
      new Person("Morty Jr.", 1),
      new Person("Jose", 39),
      new Person("Kane", 118)
    ).forEach(
      p -> System.out.println(categorize(p))
    );
  }
}
/* Output:
Person[name=Dorothy, age=15] D or 14
Person[name=John Bigboote, age=42] is middle aged
Person[name=Morty, age=14] D or 14
Person[name=Morty Jr., age=1] is not a centenarian
Person[name=Jose, age=39] is not a centenarian
Person[name=Kane, age=118] is middle aged
*/

パターン行[2]のガードは118歳の Kane と一致しているように見えますが、そうではなく代わりに Kane は行[1]のパターンと一致します。ガード式のロジックの確認を助けるためにはコンパイラを使用することはできません。

最後の case Person pがないと、コンパイラは、switch expression does not cover all possible input valuesと警告を表示します。この場合、 defaultはまだ必要ないため、もっとも一般的なケースが defaultになります。 switchの引数は Personであるため、すべてのケースが対象となります(nullを除く)。

 

補償

パターン・マッチングは、 sealed キーワードを使うよう誘導します。これにより、セレクタ式に渡される可能性のあるすべての型に対応できます。次の例の演習でどのように機能するかを確認します。:

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

sealed interface Transport {};
record Bicycle(String id) implements Transport {};
record Glider(int size) implements Transport {};
record Surfboard(double weight) implements Transport {};
// これをコメント解除した場合:
// record Skis(int length)は Transport {}を実装します;
// 次のエラーが表示されます: "the switch expression
// does not cover all possible input values"

public class SealedPatternMatch {
  static String exhaustive(Transport t) {
    return switch(t) {
      case Bicycle b -> "Bicycle " + b.id();
      case Glider g -> "Glider " + g.size();
      case Surfboard s -> "Surfboard " + s.weight();
    };
  }
  public static void main(String[] args) {
    List.of(
      new Bicycle("Bob"),
      new Glider(65),
      new Surfboard(6.4)
    ).forEach(
      t -> System.out.println(exhaustive(t))
    );
    try {
      exhaustive(null); // Always possible!  // [1]
    } catch(NullPointerException e) {
      System.out.println("Not exhaustive: " + e);
    }
  }
}
/* Output:
Bicycle Bob
Glider 65
Surfboard 6.4
Not exhaustive: java.lang.NullPointerException
*/

sealed interface Transportは、自動的に final となる record オブジェクトを使用して実装されます。switch は可能性のあるすべての型のTransportをカバーし、新しいタイプを追加すると、コンパイラはそれを検出して、可能性のあるすべてのパターンを網羅していないことを通知します。しかし、行[1]は、コンパイラがカバーできないケースが1つあることを示しています: null。

case nullを明示的に追加することを覚えておけば、例外は発生しません。ただし、既存の switch コードが多すぎる場合があるため、コンパイラはここで役立ちません。

 

より詳細な情報