Wednesday, June 8, 2022

Bruce Eckel on Java pattern matching guards and dominance

Core Java, Java Career, Java Jobs, Java Skills, Java Preparation, Oracle Java Certification, Java Guides, Oracle Java Tutorial and Materials

Pattern matching guards let you refine the matching condition beyond simply matching on the type.

The previous article in this series, “Bruce Eckel on pattern matching in Java,” introduced pattern matching and this article delves into the details. Keep in mind that, at the time of this writing, pattern matching for switch is a second preview feature in JDK 18 as JEP 420, and the Java team intends to add additional features.

Guards

A guard allows you to refine the matching condition beyond simply matching on the type. It is a test that appears after the type and &&. The guard can be any Boolean expression. If the selector expression is the same as the type for the case and the guard evaluates to true, the pattern matches, as follows:

// 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]

*/

(The {NewFeature} comment tag excludes this example from the Gradle build that uses JDK 8.)

The first guard for Circle determines whether that Circle is small. The first guard for Rectangle determines whether that Rectangle is a square.

Here’s a more complex example: A Tank can hold different types of liquids, and the Level of the tank must be between zero and 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))

    );

  }

}

The record Level includes a compact constructor that ensures that percent is valid. Records were introduced in a previous article, “Bruce Eckel on Java records,” in this series.

Here’s a note for line [1]: If a guard contains multiple expressions, simply enclose it in parentheses.

Since the code switches on Tank rather than Object, the final case Tank acts the same as a default because it catches all Tank cases that don’t match any of the other patterns.

Dominance

The order of the case statements in a switch can be important because if the base type appears first, it dominates anything appearing afterwards.

// 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]

    };

  }

}

The base type Base is in last place, at line [1]—and that’s where it should be. But if you move that line up, the base type will appear before case Derived, which would mean that the switch would never be able to test for Derived because any derived class would then be captured by case Base. If you try this experiment, the compiler reports an error: this case label is dominated by a preceding case label.

Order sensitivity often appears when you use guards. Moving the final case in Tanks.java to a higher position in the switch produces that same domination error message. When you have multiple guards on the same pattern, more-specific patterns must appear before more-general patterns. Otherwise, a more-general pattern will match before more-specific patterns, and the latter will never be checked. Fortunately, the compiler reports dominance problems.

The compiler can detect dominance problems only when the type in a pattern dominates the type in another pattern. The compiler cannot know whether the logic in guards produces problems.

// 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

*/

The guard in pattern line [2] seems like it would match Kane at age 118, but instead Kane matches with the pattern at line [1]. You cannot rely on the compiler to help with the logic of your guard expressions.

Without the last case Person p, the compiler complains that the switch expression does not cover all possible input values. With that case, a default is still not required, so the most general case becomes the default. Because the argument to the switch is a Person, all cases are covered (except for null).

Coverage

Pattern matching naturally guides you toward using the sealed keyword. This helps ensure that you’ve covered all possible types passed into the selector expression. See how that works in practice with the following example:

// 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 {};

// If you uncomment this:

// record Skis(int length) implements Transport {};

// You get an error: "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

*/

The sealed interface Transport is implemented using record objects, which are automatically final. The switch covers all possible types of Transport, and if you add a new type, the compiler detects it and tells you that you haven’t exhaustively covered all possible patterns. But line [1] shows that there’s still one case that the compiler doesn’t insist you cover: null.

If you remember to explicitly add a case null, you’ll prevent the exception. But the compiler doesn’t help you here, possibly because that would affect too much existing switch code.

Source: oracle.com

Related Posts

0 comments:

Post a Comment