Pattern Matching Essentials
This article is a follow-up of Pattern Matching Starter which described an early state of a pattern matching prototype for Java.
Scala has native pattern matching, one of the advantages over plain Java. The basic syntax is close to Java's switch:
val s = i match {
case 1 => "one"
case 2 => "two"
case _ => "?"
}
Notably match is an expression, it yields a result. Furthermore it offers
- named parameters
case i: Int => "Int " + i
- object deconstruction
case Some(i) => i
- guards
case Some(i) if i > 0 => "positive " + i
- multiple conditions
case "-h" | "--help" => displayHelp
- compile-time checks for exhaustiveness
Pattern matching is a great feature that saves us from writing stacks of if-then-else branches. It reduces the amount of code while focusing on the relevant parts.
The Basics of Match for Java
With Javaslang 2.0 we introduced a new match API that is close to Scala's match. It is enabled by adding the following import to our application:
import static javaslang.API.*;
Having the static methods Match, Case and the atomic patterns
$()
- wildcard pattern$(value)
- equals pattern$(predicate)
- conditional pattern
in scope, the initial Scala example can be expressed like this:
String s = Match(i).of(
Case($(1), "one"),
Case($(2), "two"),
Case($(), "?")
);
⚡ We use uniform upper-case method names because 'case' is a keyword in Java. This makes the API special.
Exhaustiveness
The last wildcard pattern $()
saves us from a MatchError which is thrown if no case matches.
Because we can't perform exhaustiveness checks like the Scala compiler, we provide the possibility to return an optional result:
Option<String> s = Match(i).option(
Case($(0), "zero")
);
Syntactic Sugar
If the first argument of a Case
is a conditional pattern $(predicate)
, it can be simplified by directly writing
Case(predicate, ...)
⚡ Please note that this simplification is not possible for $(value)
because it would raise ambiguities.
Javaslang offers a set of default predicates.
import static javaslang.Predicates.*;
These can be used to express the initial Scala example as follows:
String s = Match(i).of(
Case(is(1), "one"),
Case(is(2), "two"),
Case($(), "?")
);
Multiple Conditions
We use the isIn
predicate to check multiple conditions:
Case(isIn("-h", "--help"), ...)
Performing Side-Effects
Match acts like an expression, it results in a value. In order to perform side-effects we need to use the helper function run
which returns Void
:
Match(arg).of(
Case(isIn("-h", "--help"), o -> run(this::displayHelp)),
Case(isIn("-v", "--version"), o -> run(this::displayVersion)),
Case($(), o -> { throw new IllegalArgumentException(arg); })
);
⚡ It is necessary to use run
to get around ambiguities and because void
isn't a valid return value in Java.
[Update]=> run
must not be used as direct return value, i.e. outside of a lambda body:
// Wrong!
Case(isIn("-h", "--help"), run(this::displayHelp))
Otherwise the Cases will be eagerly evaluated before the patterns are matched, which breaks the whole Match expression. Instead we use it within a lambda body:
// Ok
Case(isIn("-h", "--help"), o -> run(this::displayHelp))
As we can see, run
is error prone if not used right. Be careful. We consider deprecating it in a future release and maybe we will also provide a better API for performing side-effects. <=[Update]
Named Parameters
Javaslang leverages lambdas to provide named parameters for matched values.
Number plusOne = Match(obj).of(
Case(instanceOf(Integer.class), i -> i + 1),
Case(instanceOf(Double.class), d -> d + 1),
Case($(), o -> { throw new NumberFormatException(); })
);
So far we directly matched values using atomic patterns. If an atomic pattern matches, the right type of the matched object is inferred from the context of the pattern.
Next, we will take a look at recursive patterns that are able to match object graphs of (theoretically) arbitrary depth.
Object Decomposition
In Java we use constructors to instantiate classes. We understand object decomposition as destruction of objects into their parts.
While a constructor is a function which is applied to arguments and returns a new instance, a deconstructor is a function which takes an instance and returns the parts. We say an object is unapplied.
Object destruction is not necessarily a unique operation. For example, a LocalDate can be decomposed to
- the year, month and day components
- the long value representing the epoch milliseconds of the corresponding Instant
- etc.
Patterns
In Javaslang we use patterns to define how an instance of a specific type is deconstructed. These patterns can be used in conjunction with the Match API.
Predefined Patterns
For many Javaslang types there already exist match patterns. They are imported via
import static javaslang.Patterns.*;
For example we are now able to match the result of a Try:
Match(_try).of(
Case(Success($()), value -> ...),
Case(Failure($()), x -> ...)
);
⚡ A first prototype of Javaslang's Match API allowed to extract a user-defined selection of objects from a match pattern. Without proper compiler support this isn't practicable because the number of generated methods exploded exponentially. The current API makes the compromise that all patterns are matched but only the root patterns are decomposed.
Match(_try).of(
Case(Success(Tuple2($("a"), $())), tuple2 -> ...),
Case(Failure($(instanceOf(Error.class))), error -> ...)
);
Here the root patterns are Success and Failure. They are decomposed to Tuple2 and Error, having the correct generic types.
⚡ Deeply nested types are inferred according to the Match argument and not according to the matched patterns.
User-Defined Patterns
It is essential to be able to unapply arbitrary objects, including instances of final classes. Javaslang does this in a declarative style by providing the compile time annotations @Patterns
and @Unapply
.
To enable the annotation processor the artifact javaslang-match needs to be added as project dependency.
⚡ Note: Of course the patterns can be implemented directly without using the code generator. For more information take a look at the generated source.
import javaslang.match.annotation.*;
@Patterns
class My {
@Unapply
static <T> Tuple1<T> Optional(java.util.Optional<T> optional) {
return Tuple.of(optional.orElse(null));
}
}
The annotation processor places a file MyPatterns in the same package (by default in target/generated-sources). Inner classes are also supported. Special case: if the class name is $, the generated class name is just Patterns, without prefix.
Guards
Now we are able to match Optionals using guards.
Match(optional).of(
Case(Optional($(v -> v != null)), "defined"),
Case(Optional($(v -> v == null)), "empty")
);
The predicates could be simplified by implementing isNull
and isNotNull
.
⚡ And yes, extracting null is weird. Instead of using Java's Optional give Javaslang's Option a try!
Match(option).of(
Case(Some($()), "defined"),
Case(None(), "empty")
);
Sneak Preview
One of the next releases of Javaslang could contain more default predicates, like
isNull
isNotNull
resp.nonNull
- etc.
The patterns could have a guard-method 'If' (modulo naming) that is able to check a condition that involves all decomposed values:
Case(Pattern(...).If(predicate), function)
We really hope that you are enjoying the new Match API, there are many possibilities.
Happy matching!
- Daniel