Introduced with the Java 17 release, pattern matching enhances the instanceof operator so Java developers can better check and object’s type and extract its components, and more efficiently deal with complex data structures such as abstract layers and complex class hierarchies. The result is code that is more concise and readable, has fewer errors and is easier to maintain.
In a previous tutorial we introduced the basics of pattern matching and its integration with switch expressions. Now, we’ll dive deeper into more advanced Java pattern-matching techniques and applications: dealing with null values in switch expressions, deconstructing nested records, and streamlining code with type inference, variables and generics.
Handling null values in switch expressions
Traditionally, switch statements and expressions throw a NullPointerException if the selector expression evaluates to null. In the past, developers could handle this case separately, such as follows:
public String processGreeting(String greeting) {
if (greeting == null) {
return “The value is null!”;
}
return switch (greeting) {
case “hello” -> “You said hello!”;
case “goodbye” -> “See you later!”;
default -> “Unknown greeting: ” + greeting;
};
}
From Java 17 and onward, developers can use a null value as a case label directly, like so:
public String processGreeting(String greeting) {
return switch (greeting) {
case null -> “The greeting is null!”; // Directly handle null values
case “hello” -> “You said hello!”;
case “goodbye” -> “See you later!”;
default -> “Unknown greeting: ” + greeting;
};
}
Guarded patterns
Another Java pattern matching example is with the when clause. This clause introduces guarded patterns, with which developers can write for more sophisticated pattern-matching logic.
Consider the following example:
switch (shape) {
case Triangle t -> System.out.println(t + ” is a Triangle”);
case Rectangle r when r.height() == r.width() -> System.out.println(r + ” is a Square”);
case Rectangle r -> System.out.println(r + ” is a Rectangle”);
default -> System.out.println(“Unknown shape”);
}
In this example, the first case matches any Triangle. The second case also matches a Rectangle (r), but the when clause adds an extra requirement of r.height() == r.width(). Therefore, this case is selected only if the rectangle is actually a square. Lastly, the third case acts as a catchall for rectangles that don’t fulfill the square condition.
The when clause enables us to create guarded patterns, ensuring that a pattern match must also satisfy an additional condition.
Pattern matching with nested records
Pattern matching in Java isn’t limited to just top-level records. Developers can elegantly navigate nested record structures as well. Consider the following example in which a Book record contains an Author record:
record Book(String name, Author author){}
record Author(String name, String email){}
One can use a switch expression to extract information from a nested record structure. Here’s how:
Object book = findBook(); // Assume findBook() returns a Book object
String description = switch(book) {
case Book(String title, Author(String name, String email)) ->
“Title: ” + title + “, Author: ” + name + “, Email: ” + email;
default -> “Book information unavailable.”;
};
System.out.println(description);
This switch expression lets a developer match and deconstruct nested records in a single step, and thus directly access values including title, author name and email within the case block. Using the var keyword further streamlines the code and makes it more readable, especially with complex nesting.
Pattern matching with type inference
In the above example, we have used nested record deconstruction. However, we specified the type in each case. This is not necessarily needed; the Java compiler can automatically infer the type. Let’s use the var keyword instead of the explicit type. Consider the following code snippet:
String description = switch(book) {
case Book(var title, Author(var name, var email)) ->
“Title: ” + title + “, Author: ” + name + “, Email: ” + email;
default -> “Book information unavailable.”;
};
In this improved code, we’ve replaced explicit type declarations with var.
Unnamed patterns and variables
Java empowers us to streamline pattern matching even further with the use of unnamed patterns and variables. This feature lets us selectively focus on the specific data we need within record structures.
Consider this modified example:
var description = switch (book) {
case Book(_, Author(var name, _)) -> “Author: ” + name;
default -> “Book information unavailable.”;
};
System.out.println(description);
This code gives us more clarity as we focus on what matters only; the rest we can just omit.
Pattern matching and generics
JEP 440 further introduced enhancement to pattern matching by enabling type inference within generic record patterns. Consider this example of a generic Box record:
record Box<T>(T t) { }
static void test(Box<String> box) {
if (box instanceof Box(var s)) { // Box<String> is inferred
// Do stuff with s
}
}
Pattern matching in Java has evolved significantly, offering elegant ways to handle complex data structures. Key features like the when clause for guarded patterns; nested record matching; and unnamed pattern, type inference and enhanced integration with generics provide us with powerful tools. These features promote more concise, readable and type-safe code.
In the next article, we’ll delve into the intricate relationship between record patterns along with sealed classes and exhaustive switch expressions.
A N M Bazlur Rahman is a Java Champion and staff software developer at DNAstack. He is also founder and moderator of the Java User Group in Bangladesh.