Pattern Matching for Switch
Let's see how this feature preview has evolved up to Java 19
Join the DZone community and get the full member experience.
Join For FreeAccording 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 this dedicated article), the second improves the switch
construct, 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 this 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 newswitch
, feature preview, andsealed
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
andchar
. - The corresponding wrapper types:
Byte
,Short
,Integer
andCharacter
. - 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 Integer
and 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 atry
statement. A more genericcatch
clause could dominate subsequentcatch
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 abreak
statement in thedefault
clause as well. In fact, inadvertently adding a new clause after thedefault
without thebreak
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 when 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 switch
construct has been introduced, we should first check that the input reference is not null
. But rather than preceding the switch
construct 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 null
check 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 switch
clause, 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 default
clause:
case null, default -> "Unknown or null";
In this case, the case null
must be specified before the default
clause. 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 (sealed
classes and interfaces, see this 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 the 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 BluRay
class 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 declaredabstract
, we could have passed as input objects of typeOpticalDisk
to theswitch
. Consequently, we should also have added a clause for objects of typeOpticalDisk
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
.
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!
Published at DZone with permission of Claudio De Sio Cesari. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments