You are currently viewing Pattern matching for switch

Pattern matching for switch

According to some surveys such as that of JetBrains, version 8 of Java is currently the most used by developers all over the world, despite being a 2014 release.

What you are reading is the one in a series of articles titled “Going beyond Java 8”, inspired by the contents of my books “Java for Aliens” (English) and “Il nuovo Java” (Italian). These articles will guide the reader step by step to explore the most important features introduced starting from version 9. The aim is to make the reader aware of how important it is to move forward from Java 8, explaining the enormous advantages that the latest versions of the language offer.

In this article, we will see an interesting novelty introduced in version 17 as a feature preview and which will probably be made official in version 20. This is the second part of a complex feature known as Pattern Matching. If the first part has changed forever how we used the instanceof operator for the better (see dedicated article), the second improves the switchconstruct, actually already improved in version 14 with the introduction of a new syntax based on the arrow notation, and the possibility of using it as an expression (see dedicated article).

This post is quite technical and requires knowledge of some features recently added to the language. If necessary, therefore, we recommend that you first read the articles on pattern matching for instanceof, the new switch, feature preview, and sealed types, which are preparatory to full understanding of the following.

 

Pattern matching

With the introduction of pattern matching for instanceof in Java 16, we have defined a pattern as composed of:

  • A predicate: a test that looks for the matching of an input with its operand. As we will see, this operand is a type (in fact we call it type pattern).
  • One or more binding variables (also called constraint variables or pattern variables): these are extracted from the operand depending on the test result. With pattern matching, a new scope for variables has been introduced, the binding scope, which guarantees the visibility of the variable only where the predicate is verified.

In practice, a pattern is therefore a synthetic way of presenting a complex solution.

The concept is very similar to that concept behind regular expressions. In this case, however, the pattern is based on the recognition of a certain type using the instanceof operator, and not on a certain sequence of characters to be found in a string.

 

The new switch

The switch construct was revised in version 12 as a feature preview and made official in version 14. The revision of the construct introduced a less verbose and more robust syntax based on the arrow operator ->, and it is also possible to use switch as an expression. You can learn more about it in the dedicated article. The construct thus became more powerful, useful, and elegant. However, we still had the constraint to pass only certain types to a switch as input:

  • The primitive types byte, short, int and char.
  • The corresponding wrapper types: Byte, Short, Integer and Character.
  • The String type
  • An enumeration type.

In the future, it is planned to make the construct even more useful by adding new types to the above list, such as the primitive types float, double and boolean. Also, in the next versions we will have a switch construct that will allow us to preview the deconstruction of objects feature. Currently it is still early to talk about it, but in the meantime, Java is advancing quickly step by step and from version 17 it is already possible to preview a new version of the switch construct, which allows us to pass an object of any type as input. To enter a certain case clause, we will use pattern matching for instanceof.

 

The (brand) new switch

Let’s consider the following method:

public static String getInfo(Object object) {
  if (object instanceof Integer integer) {
    return (integer < 0 ? "Negative" : "Positive") + " integer";
  }
  if (object instanceof String string) {
    return "String of length " + string.length();
  }
  return "Unknown";
}

It takes an Object type parameter as input and therefore accepts any type, and using the instanceof operator returns a particular descriptive string. Although the pattern matching for instanceof has allowed us to avoid the usual step that included the declaration of a reference and its cast, the code is still not very readable, is inelegant and error-prone. So, we can rewrite the previous method using the pattern matching applied to a switch expression:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

The code is now more concise, readable, applicable, functional, and elegant, but let’s take our time to analyze it.

Note that, unlike the switch construct we have always used, in the previous example the validation of a certain case definition will not be based on the equals operator whose second operand is a constant, but on the instanceof operator whose second operand is a type.

In practice, the code that follows the arrow operator -> of the case Integer i will be executed, if the object parameter is of the Integer type. Within this code, the Integer type binding variable i will point to the same object that the reference object points to.

Instead, will be executed the code that follows the arrow operator -> of the case String s if the object parameter is of type String. Within this code, the binding variable s of type String will point to the same object that the reference object points to.

Finally, we will enter the default clause if the object parameter is neither of type String nor of type Integer.

The construct is completed by the default clause whose code will be executed if the object variable points to an object other than both Integerand String .

In order to master the pattern matching for switch , however, it is also necessary to know a series of properties that will be presented in the next sections.

Remember that in versions 17, 18 and 19, this feature is still in preview. This means that to compile and execute an application that makes use of pattern matching for switch, certain options must be specified as described in the article dedicated to feature preview.

 

Exhaustiveness

Note that the default clause is required in order not to get a compilation error. In fact, the switch construct with pattern matching includes exhaustiveness (also known as completeness: completeness of coverage of all possible options), among its properties. In this way, the construct is more robust and less prone to errors.

Due to the backward compatibility that has always characterized Java, it was not possible to modify the compiler in such a way that it claims completeness even with the original switch construct. In fact, such a change would prevent the compilation of many pre-existing projects. However, it is foreseen for the next versions of Java that the compiler prints a warning in the case of implementations of the “old” switch that do not cover all possible cases. All in all, the most important IDEs already warn programmers in these situations.

Note that in theory we could also substitute the clause:

default -> "Unknown";

with the equivalent:

case Object o -> "Unknown";

In fact, even in this case we would have covered all the possible options. Consequently, the compiler will not allow you to insert both of these clauses in the same construct.

 

Dominance

If we try to move the case Object o clause before the clauses related to the Integer and String types:

public static String getInfo(Object object) {
  return switch (object) {
    case Object o -> "Unknown";
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
  };
}

we will get the following compile-time errors:

error: this case label is dominated by a preceding case label
       case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
            ^
error: this case label is dominated by a preceding case label
       case String s -> "String of length " + s.length();
            ^

In fact, another property of the pattern matching for switch known as dominance, causes the compiler to consider the case Integer i and case String s unreachable, because they are “dominated” by the case Object o. In practice, this last clause includes the conditions of the next two which would therefore never be reached.

This behavior is very similar to conditions on the catch clauses of a try statement. A more generic catch clause could dominate subsequent catch clauses, causing a compiler error. Also, in such cases we need to place the dominant clause after the others.

 

Dominance and default clause

Unlike ordinary case clauses, the default clause does not necessarily have to be inserted as the last clause. In fact, it is perfectly legal to insert the default clause as the first statement of a switch construct without altering its functioning. The following code compiles without errors:

public static String getInfo(Object object) {
  return switch (object) {
    default -> "Unknown";
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
  };
}

This actually also applies to the classic switch construct, and it is also the reason why it is advisable to insert a break statement in the defaultclause as well. In fact, inadvertently adding a new clause after the defaultwithout the break statement, could cause an unwanted fall-through.

 

Guarded pattern

We can also specify patterns composed with boolean expressions using the && operator, that are called guarded patterns (and the boolean expression is called guard). For example, we can rewrite the previous example as follows:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i && i < 0 -> "Negative integer"; // guarded pattern
    case Integer i -> "Positive integer";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

The code is more readable and intuitive.

In version 19 (third preview) based on developer feedback, the && operator has been replaced by the when clause (new contextual keyword). So, the previous code from version 19 onwards needs to be rewritten like this:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i when i < 0 -> "Negative integer"; // guarded pattern
    case Integer i -> "Positive integer";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

Note that if we invert the clauses concerning the integers as follows:

case Integer i -> "Positive integer"; //questo pattern "domina" il successivo
case Integer i when i < 0 -> "Negative integer"; 

we will get a dominance error:

error: this case label is dominated by a preceding case label
            case Integer i && i < 0 -> "Negative integer";
                 ^

 

Fall-through

We have already seen in the article dedicated to the new switch, how the new syntax based on the arrow operator -> allows us to use a unique case clause to manage multiple case clauses in the same way. In practice, we simulate the use of an OR operator || avoiding using a fall-through. For example, we can write:

Month month = getMonth();
String season = switch(month) {
    case DECEMBER, JANUARY, FEBRUARY -> "winter";
    case MARCH, APRIL, MAY -> "spring";
    case JUNE, JULY, AUGUST -> "summer";
    case SEPTEMBER, OCTOBER, NOVEMBER -> "autumn";
};

The syntax is much more concise, elegant and robust.

When we use pattern matching, however, the situation changes. It is not possible to use multiple patterns in the clauses of the switch construct to handle different types in the same way. For example, the following method:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i, Float f -> "This is a number";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

would produce a compilation error:

error: illegal fall-through to a pattern
            case Integer i, Float f -> "This is a number";
                            ^

In fact, due to the definition of binding variables we talked about in the article dedicated to pattern matching for instanceof, the code after the arrow operator could use both the variable i and the variable f, but one of them will certainly not be initialized. So, it was therefore chosen not to make this code compile in order to have a more robust construct.

Note that the error message points out that this code is invalid because it defines an illegal fall-through. This is because the previous code is equivalent to the following which, not using the syntax based on the arrow operator ->, makes use of the fall-through:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i: // manca il break: fall-through
    case Float f: 
      yield "This is a number";
    break;
    case String s: 
      yield "String of length " + s.length();
    break;
    default: 
      yield "Unknown";
    break;
  };
}

Obviously, also this code can be compiled successfully.

 

Null Check

Since the possibility of passing any type to a switchconstruct has been introduced, we should first check that the input reference is not null. But rather than preceding the switchconstruct with the usual null check:

if (object == null) {
  return "Null!";
}

we can instead use a new elegant clause to handle the case where the object parameter is null:

public static String getInfo(Object object) {
  return switch (object) {
    case null -> "Null!"; // controllo di nullità
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

The case null clause allows us to avoid the usual tedious nullcheck we are used to. This clause is optional, but since it is now always possible to pass a null reference to a switch, if we do not insert one explicitly, the compiler will insert one for us implicitly, whose code will throw a NullPointerException.

 

Dominance and case null

Note that, as the default clause does not have to be the last of a switchclause, the case null clause does not need to be at the top of the construct. Consequently, even for this clause the rule of dominance is not applicable. It is perfectly legal to move the case null as the last line of the switch, as it is legal to have the default clause as the first clause without affecting the functionality of the construct:

public static String getInfo(Object object) {
  return switch (object) {
    default -> "Unknown";
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
    case null -> "Null!";
  };
}

However, this practice is not recommended: it is better to maintain the readability of the construct following common sense and leave the various clauses in the positions in which we expect to find them.

In conclusion, the order of the clauses is important, but not for the default clause and the case null clause.

 

Fall-through with case null

The case null is the only case that can be used in a clause that groups multiple patterns. For example, the following clause is valid:

case null, Integer i -> "This is a number or null";

More likely we will pair the case null with the defaultclause:

case null, default -> "Unknown or null";

In this case, the case null must be specified before the default clause. In fact, the following code will produce a compile-time error:

default, case null -> "Unknown or null";

 

Exhaustiveness with sealed types

The concept of exhaustiveness, which we have already mentioned previously, must be revised when dealing with sealed type hierarchies (sealedclasses and interfaces, see dedicated article). Let’s consider the following classes:

public sealed abstract class OpticalDisk permits CD, DVD {
    // code omitted
}

public final class CD extends OpticalDisk {
    // code omitted
}

public final class DVD extends OpticalDisk {
    // code omitted
}

The following code compiles successfully despite not specifying the default clause:

public class OpticalReader {
  public void insert(OpticalDisk opticalDisk) {
    switch(opticalDisk) {
      case CD cd -> playDisk(cd);
      case DVD dvd -> loadMenu(dvd);
     }
  }
  // rest of the code omitted
}

Note that we don’t need to add a default clause here. In fact, the use of the abstract sealed class OpticalDisk guarantees us that as input this switch can only accept CD and DVD objects, and therefore it is not necessary to add a default clause because all cases have already been covered.

In case of using sealed hierarchies, it is therefore not recommended to use the default clause. In fact, its absence would allow the compiler to report you any changes to the hierarchy during the compilation phase.

For example, let’s now try to modify the OpticalDisk class by adding the following BluRayclass in the permits clause:

public sealed abstract class OpticalDisk permits CD, DVD, BluRay {
  // code omitted
}

public final BluRay implements OpticalDisk {
  // code omitted
}

If we now try to compile the OpticalReader class we will get an error:

.\Opticalreader.java:3: error: the switch statement does not cover all possible input values switch(opticalDisk) { ^

which highlights that the construct violates the exhaustiveness rule.

If instead we had also inserted the default clause, the compiler would not have reported any errors

Note that if the OpticalDisk class had not been declared abstract, we could have passed as input objects of type OpticalDisk to the switch. Consequently, we should also have added a clause for objects of type OpticalDisk to comply with the exhaustiveness. Furthermore, for the rule of dominance this clause should have been positioned as the last one to comply with the dominance rule.

The alternative would have been to add a default clause.

 

Compilation Improvement

Java 17 implements an improved compilation behavior to prevent any issue due to partial code compilation. If in the previous example we compile only the OpticalDisk class and the BluRay class without recompiling the OpticalReader class, then the compiler would have implicitly added a default clause to the OpticalReader switch construct, whose code will launch an IncompatibleClassChangeError:

public class OpticalReader { 
  public void insert(OpticalDisk opticalDisk) { 
    switch(opticalDisk) { 
      case CD cd -> playDisk(cd); 
      case DVD dvd -> loadMenu(dvd); 
      default -> throw new IncompatibleClassChangeError(); // implicit code
    } 
  } // rest of the code omitted 
}

So, in cases like this, the compiler will automatically make our code more robust.

 

Conclusion

In this article we have seen how Java 17 introduced pattern matching for switch as a feature preview. This new feature increases the usability of the switch construct, updating it with new concepts such as exhaustiveness, dominance, guarded patterns, a null check clause, and improving compilation management. Pattern matching for switch, therefore, represents another step forward for the language, which is on the way to becoming more robust, complex, powerful and less verbose. In the future, we will be able to exploit the pattern matching for switch to deconstruct an object by accessing its instance variables. In particular, with a single line of code we will recognize the type of the object and access its variables. In short, the best is yet to come!

 

Author Notes

Even ignoring the increased security and performance that the latest versions of Java offer, there are plenty of reasons to upgrade your knowledge of Java, or at least your own Java runtime installations. My book “Java for Aliens”, which inspired the “Going beyond Java 8” series, contains all the information you need to learn Java from scratch, and uses a well-tested teaching method that has been perfected over 20 years of experience, which makes learning simple and exciting. It is also structured to deepen the topics and have superior knowledge that can make a difference in your career.

For more information, visit https://www.javaforaliens.com.

Leave a Reply