Looking at Java 21: Switch Pattern Matching
The switch
control structure has quite an evolution lately since its inception.
First, there were switch
expressions (JEP 325, 354, 361).
And now, we get pattern matching for switch
statements and expressions!
The idea of pattern matching for switch
constructs has been brewing since Java 17 (JEP 406) and got refined with each iteration of the JDK (JEP 406, 420, 427, 433).
With Java 21, it’s finally here as a full-fledged (non-preview) feature.
Table of Contents
Pattern Matching (in Java)
The technique of pattern matching is about testing an expression against certain characteristics.
A variant of pattern matching you’re certainly familiar with is type pattern matching with the instanceof
keyword:
Matching the pattern alone is only half the work, usually, we still want to use the value as the actual type. That means we still need to cast the pattern-verified variable. Thanks to Java 16 and JEP 394, though, type pattern matching got a little easier:
There’s no longer a need to declare a new variable and cast the original value after matching! That’s a great improvement, but how about checking against multiple patterns?
So far, the only option was using as many if-else
as needed:
Even though the improved instanceof
saves us some code, it’s still quite an eyesore in dire need of improvement.
If you look at it closely, though, it has the resemblance of a switch
construct, with multiple different “cases” to be handled.
So instead of further improving instanceof
in if-else
statements, why not use a more fitting language feature instead?
Pattern Matching with switch
Looking at how switch
works so far, it boils down to pattern matching based solely on the equality of its constant case
variants.
The resulting code is easier to follow than a multi-case if-else
eyesore, but the available conditions are limited.
The next logical evolution would be to allow patterns to match values in a switch
, not only constants.
Thinking about it even further, you might want full-blown expression like any if
conditions use.
However, as Java is well-known for smaller, incremental, and most importantly, “as backward compatible as possible” evolution instead of “screw any old code, give me all the features” approach, it started with introducing pattern matching.
switch + instanceof
Like an instanceof
in an if
condition, a switch
case
can now type-check its value and creates a case
-scoped variable:
Now that’s an improvement in readability and reasonability compared to the previous if-else
monstrosity.
And it’s way more optimizable behind the scenes, too.
Where an if-else
will most likely end up with an O(n)
time complexity, even though the problem is usually O(1)
.
As a switch
construct is more well-defined and restricted, it’s inherently more optimizable, and might even reach O(1)
.
The simple addition of instanceof
support creates simpler and more performant code, already.
However, you might have already spotted the possible bug in the previous code: what about null
?
switch + null
The atrocious if-else
construct has one advantage over traditional switch
behavior: it doesn’t require a null
check.
Even though null
is possibly any type, in the case of pattern matching with instanceof
, it’s no type at all, so you will always fall through to the last else
, or at least won’t trigger a NullPointerException
.
The downside, however, is that each if
needs to be checked, which results in the previously mentioned optimization problems.
With Java 21, switch
finally allows case null
to be included:
In good ol’ Java tradition, this behavior change is actually implemented in a backward-compatible fashion:
If a case null
is present, it handles any incoming null
value.
If there’s no case null
, the switch
will still throw a NullPointerException
as before, even if a default
case is present.
This little addition removes the annoying null
-check in front of almost any switch
to make sure it won’t blow up and moves the handling where it belongs.
Handling More Refined Cases
Even though adding the instanceof
keyword and null
handling to the switch
construct is already a quite welcomed improvement, it lacks ceratin functionality compared to pattern matching in other languages.
Thankfully, Java 21 still has a few more improvements up its sleeve.
Multi-Cases
If a value has more than one value, we can already test against it by “falling through” cases:
This approach has two obvious downsides.
First, you can use it with the new arrow syntax for switch
expressions, and second, it’s a code smell in my opinion a possible future bug.
Instead, we can use multiple labels in a single case
:
The intent of the code is now clearer than ever, without relying on fallthrough or repetitive code.
This works with any kind of value, and you can mix it up, too:
One exception, or special case (pun intended), is the handling default
label.
Only case null
can be combined with default
, with the being the second one, as defined in the JSL §14.11.1:
Combining multiple case
labels is another great addition.
But let’s think about what else could improve or simplify the decision-making on what case
to perform?
Guard Clauses
Guard clauses are a way to further refined the base condition of a case
.
They’re appended to the label before the :
(colon) or ->
(arrow) and are separated by the when
keyword: case <pattern> where <guard clause>
.
Let’s look at how to make more precise decisions before guard clauses and the other improvements mentioned in this article, first.
Imagine you need to handle a Integer
value that has to be handled depending on a few magic numbers and its sign:
There’s a lot going on here, creating a hard-to-follow mess of a switch
construct.
So let’s apply what we’ve seen so far:
That’s an improvement and is a little easier to follow, but we can do even better by using a guard clause!
The clause itself is like any other expression used for conditionals.
In this case, it would be where val > 0
.
However, there’s a little more to it.
Thus far, type patterns are the only patterns available to switch
, so we need to include Integer i
if we want to use a guard clause.
This results in the following code:
In this particular example, it might look a little weird to include the type pattern before the guard clause.
But requiring a pattern beforehand makes the case
label, and therefore your code’s intentions, more explicit, and easier to use if there’s more than one type pattern matched.
The refinement of case
labels with guard clauses gives us more freedom to express more dynamic and precise conditions, but they also lead to a new problem of no longer 100% perfect matches for each case regardless of their order.
Case Dominance
When case
labels only consisted of constants, their declaration order didn’t affect the decision process at all.
With pattern case
labels, however, it’s now possible that more than one case
label matches.
Take the String
type for example.
It matches both against case String s
and case CharSequence cs
.
If two labels apply, which one should be chosen?
The previous simple best-fit approach no longer works here. That’s why Java decided to rely on simpler order-based semantics: first come, first serve.
In the case of the String
example, that means we must match against String
first, and CharSequence
later, or else the compile won’t be happy:
The reasoning here is simple.
If a case
label dominates another one, we’ve created unreachable code, because the second type pattern (String
) is a subtype of the first one (CharSequence
).
Thankfully, the compiler checks all case
labels and prevents us from doing so.
To compare it to another Java feature, dominance is analogous to multi-catch`` blocks and the order of caught Exceptions. Declaring
catch` blocks that have no chance of ever being called just doesn’t make sense, and should be prevented.
In essence, the case
labels need to be defined in most to least precise matching order, leading to more predictable and reasonable labels.
Here is the suggested order:
- Constants
- Guarded Patterns
- Unguarded Patterns
Let’s take a close look what that means code-wise.
An unguarded pattern ALWAYS dominates a guarded one with the same pattern
Guarded Pattern CAN dominate a constant case label
Guarded pattern matches unguarded on if when true
Pattern matches CAN dominate default case
What the Future Might Hold For Us
Depending on how you look at it, the changes to switch
were merely incremental and lacks aspects of other languages, even ones available on the JVM.
But they fixed certain pain points many of us had to deal with over the years, and opened the door for so many possibilities to simplify our code.
That doesn’t mean, though, that it needs to end there.
JEP 441 also lists three possible future improvements, so let’s take a look!
Dealing with Primitives
Whenever we talk about Java and all the awesome new features that keep coming since Java 8, we also need to talk about how to handle primitives.
As you might have guessed already from the chosen examples, primitives aren’t supported in type pattern, so no case int i -> ...
yet.
The reasoning behind this is simple.
To support primitives in type patterns, instanceof
has to support it, first.
Although it’s something that should be available, too, it makes more sense to release a feature like “Pattern Matching for switch
” with the tools at hand, and in a timely manner, instead of waiting for other features to improve.
With all the work surrounding Project Valhalla, I’m sure supporting primitives in switch
constructs will definitely be available in a future Java version.
Deconstruction patterns
A new type of pattern to deconstruct types in a specific way. Where type patterns result in a new variable of that type, a deconstruct pattern could result in multiple variables.
Think about a type hierarchy used for a calculator-like system.
The base type would be Expr
, with subtypes for values, negating, adding, etc.
Depending on the type, there could be more than one inner value to work with:
To implement such"ad-hoc polymorphic calculations" right now, we could use the visitor pattern.
Compared to the quite straightforward and easy-to-reason-with switch
construct, it’d be cumbersome, to say the least.
AND and OR Patterns
For more label expressiveness, AND and OR patterns would be a nice addition.
However, a certain degree of combination is possible with multi-cases, or using &&
or ||
in guard clauses.
Conclusion
The language designers did what I believe is the best approach to improving a language: small incremental steps that feel like a natural evolution, instead of forcing a revolution upon us.
Let’s recapitulate what improvements JEP 441 introduced to Java:
null
handling baked right intoswitch
itself- Type pattern matching
- Refine type pattern matching with guard clauses
- Multi-
case
label
For me, that’s a lot of improvement that will be easy to use from the get-go and will result in more straightforward and easier-to-reason with switch
statements and expressions.
Would I prefer to have the same power of Scala’s or Rust’s pattern matching available in Java? Absolutely!
Do I want to fix all my code because it just isn’t compatible with the previous way of doing things? Hell no!
That’s why I really enjoy the direction Java has taken over the last few years and where it’s heading. These little improvements accumulate to something bigger, in a familiar environment with the backward-compatibility we’re used to.
Resources
Looking at Java 21
- Intro
- String Templates (JEP 430)
- Simpler Main Methods and Unnamed Classes (JEP 445)
- Sequenced Collections (JEP 431)
- Scoped Values (JEP 446)
- Switch Pattern Matching (JEP 441)
- Feature Deprecations (JEP 449, 451)
- Record Pattern Matching (JEP 440)
- Generational ZGC (JEP 439)
- Virtual Threads (JEP 444)
- The Little Things