Wednesday, July 6, 2022

Pattern matching updates for Java 19’s JEP 427: when and null

The third preview of pattern matching for switch addresses case refinement and the proper handling of null cases.

This article looks at changes to pattern matching for switch in its third preview: case refinement and handling null cases. This discussion assumes that you already know how pattern matching for switch works in the second preview, which is Java 18’s JEP 420.

Core Java, Oracle Java, Java Exam, Java Career, Java Exam Prep, Java Skills, Java Jobs, Java Prep, Java News, Java Certifications, Oracle Java Tutorial and Material

The third-preview changes, targeted for Java 19, are described in JEP 427. Note that these are nitty-gritty details, and they may very well change before pattern matching for switch becomes finalized in a future iteration of the platform.

The big picture is that switch is gaining expressiveness and is becoming a more relevant programming construct in Java. Of course, while switch is an important place to use patterns, it won’t be the only place.

That means that pattern matching functionality is evolving to handle larger considerations such as integrating how these additions work with the more familiar current understanding of switch. It’s important that the semantics of patterns used in switch align with semantics used elsewhere.

Case refinement

The classic switch statement compares a variable to specific values and picks the branch that matches.

With patterns in switch, the variable is matched against types. Each type is essentially a big bag of all the values that are legal for that type. For example, the Integer type includes all integers between about minus 2 billion and about plus 2 billion. The String type includes all character strings.

However, you will not always want to treat all instances of a type exactly the same. You may wish to distinguish between positive and negative integers, or you might want to consider strings that do or don’t contain a specific substring.

These conditions can of course easily be expressed with an if, but in the context of a switch, an if would require a type pattern, an arrow (->), and then the if before, finally, the statements you are actually interested in.

For example, instead of condition, arrow, statements you would get condition-part-one, arrow, condition-part-two, statements, as follows:

switch (object) {

  case Integer i ->

    if (i >= 0)

      // positive integers

    else

      // negative integers

  case String s ->

    if (s.contains("foo"))

      // strings with "foo"

    else

      // strings without "foo"

  default -> // ...

}

This is where guarded patterns come in, or rather, came in. You see, JEP 427 proposes the use of when clauses instead. Both allow adding Boolean conditions to a pattern to identify the desired case, such as positive integers, on the left side and then putting simple statements after the arrow, for example,

switch (object) {

  case Integer i ____ i >= 0 -> // positive integers

  case Integer i -> // negative integers

  default -> // ...

}

(The ____ is a placeholder for something coming later in this article.)

The when clause, as proposed by JEP 427, differs from guarded patterns in the following two aspects:

◉ Which construct owns the refinement

◉ How that refinement is expressed

As the name suggests, guarded patterns were part of the pattern syntax, which was very powerful. For example, once nested patterns were introduced, you could add Boolean conditions inside a large pattern, not just at the end.

Overall, guarded patterns had some weird edge cases, though, that the JDK team wants to avoid. So, now it’s no longer the pattern that owns a refinement. Now, the case owns the refinement, as shown below.

switch (shape) {

  // now-obsolete guarded patterns with

  // record patterns from JEP 405

  case Point(int x && x > 0, int y) -> // use positive x, y

  default -> // ...

}

switch (shape) {

  // refinement owned by 'case' can't be "inside" the pattern

  case Point(int x, int y) ____ x > 0 -> // use positive x, y

  default -> // ...

}

The other aspect is the syntax of how to express a refinement. You may be used to seeing && as a strongly binding operator between equitable terms. This notation worked reasonably well for guarded patterns because they were actually part of the patterns, but it works less well if case owns the refinement.

As Brian Goetz wrote on the Project Amber mailing list, “It’s harder to imagine && as part of the case, and not as part of the pattern.”

Thus, the current proposal is to use the new context-specific keyword when between the pattern and the refining Boolean conditions, and that’s what goes into the ____ placeholder shown earlier.

switch (object) {

  case Integer i when i >= 0 ->

    // positive integers

  case Integer i ->

    // negative integers

  default -> // ...

}

Null values

My favorite topic to rant about is null! Once again, it sullies beauty with its dark presence, specifically, because it’s necessary to deal with null in a pattern switch.

Historically, switch simply throws a NullPointerException when the variable is null. However, the more you use switch over complicated types, the more urgent becomes the need to find a better way to handle that situation than a separate if before the switch.

Ever since the first preview version of pattern matching for switch (unchanged by JEP 427), it has been possible to add a case null for this special situation and even combine that with a default. What happens without that case?

String string = // ???

// JDK 18 and JEP 427

switch (string) {

  case null -> // ...

  case "foo" -> // ...

  case "bar" -> // ...

}

In the second preview in JDK 18, the answer to that depends on the presence of an unconditional pattern, that is, a pattern that matches all possible instances of the switched variable’s type.

Think of a switch over a variable of type Shape where the last case is case Shape s. That code always matches; it’s unconditional on type Shape.

Unconditional patterns even match null. Therefore, in JDK 18 the variable s could be null. However, that would probably lead to several NullPointerException situations, and I wasn’t a fan of silently sweeping null in with the other shapes, as shown below.

Shape shape = // ...

// as previewed in JDK 18

switch (shape) {

  case Point p -> ...

  // unconditional pattern

  //  ~> matches 'null'

  //  ~> 's' can be 'null'

  case Shape s -> ...

}

Fortunately, JEP 427 proposes to change that unpleasant situation. How? Unconditional patterns still match null, but switch won’t let it get that far. If there’s no case null, switch throws a NullPointerException without even looking at the patterns.

Shape shape = // ...

// as proposed by JEP 427:

// no 'case null' ~> NPE

switch (shape) {

  case Point p -> ...

  // unconditional pattern

  //  (still matches 'null')

  case Shape s -> ...

}

Interestingly, this top-level behavior does not extend to nested patterns, though.

An unconditional nested pattern will still match null, which introduces a sharp edge during refactoring. This is inconsistent, but consistently not matching null also has weird effects, such as not being able to write a single pattern that matches all instances of a record, as shown below.

interface Shape { }

record Circle(Point center)

  implements Shape { }

// JEP 427 + JEP 405

Shape shape = // ...

switch (shape) {

  // 'Point center' is unconditional

  // the circle's center 'Point'

  case Circle(Point center) ->

    // 'center' may be 'null'

  case Shape s ->

    // 's' won’t be 'null'

}

A solution to this kerfuffle that I’ll personally be pursuing is to flat-out avoid null. (Actually, I’m already doing that, but that is beside the point.)

Anyway, when null isn’t legal, switch doesn’t have to mention it, and while I would’ve found it nice if switch threw exceptions upon encountering null, it isn’t really a switch’s job to do that.

Source: oracle.com

Related Posts

0 comments:

Post a Comment