Pattern matching in Java 8

Many modern languages ​​support pattern matching at the language level.



The Java language is no exception. And Java 16 will add support for pattern matching for the instanceof operator as a final feature.



Hopefully in the future, pattern matching will be extended to other language constructs.



Pattern matching gives the developer the ability to write code more flexibly and prettier, while keeping it understandable.



But what if you can't switch from one reason or another to new versions of Java. Fortunately, using the capabilities of Java 8, you can implement some of the pattern matching capabilities in the form of a library.



Let's look at some of the patterns and how they can be implemented using a simple library.



Constant pattern allows you to check for equality with constants. In Java, the switch statement allows you to check for equality of numbers, enumerations, and strings. But sometimes you want to check for equality of object constants using the equals () method.



switch (data) {
      case new Person("man")    -> System.out.println("man");
      case new Person("woman")  -> System.out.println("woman");
      case new Person("child") 	-> System.out.println("child");        
      case null                 -> System.out.println("Null value ");
      default                   -> System.out.println("Default value: " + data);
};

      
      





Similar code can be written as follows. At the same time, under the hood, values ​​are compared and checked in the if statement. You can use both a statement form and an expression.



It is also very easy to work with ranges of values.



import static org.kl.jpml.pattern.ConstantPattern.*;

matches(data).as(
      new Person("man"),    () ->  System.out.println("man"),
      new Person("woman"),  () ->  System.out.println("woman"),
      new Person("child"),  () ->  System.out.println("child"),       
      Null.class,           () ->  System.out.println("Null value "),
      Else.class,           () ->  System.out.println("Default value: " + data)
);

matches(data).as(
      or(1, 2),    () ->  System.out.println("1 or 2"),
      in(3, 6),    () ->  System.out.println("between 3 and 6"),
      in(7),       () ->  System.out.println("7"),        
      Null.class,  () ->  System.out.println("Null value "),
      Else.class,  () ->  System.out.println("Default value: " + data)
);

      
      





Tuple pattern allows you to check for equality of several variables with constants at the same time.



var (side, width) = border;

switch (side, width) {
      case ("top",    25) -> System.out.println("top");
      case ("bottom", 30) -> System.out.println("bottom");
      case ("left",   15) -> System.out.println("left");        
      case ("right",  15) -> System.out.println("right"); 
      case null         -> System.out.println("Null value ");
      default           -> System.out.println("Default value ");
};

for ((side, width) : listBorders) {
      System.out.println("border: " + [side + "," + width]); 	
}

      
      





In this case, in addition to being used in the form of a switch, you can decompose into matching or pass sequentially in a loop.



import static org.kl.jpml.pattern.TuplePattern.*;

let(border, (String side, int width) -> {
    System.out.println("border: " + side + "," + width);
});

matches(side, width).as(
      of("top",    25),  () -> System.out.println("top"),
      of("bottom", 30),  () -> System.out.println("bottom"),
      of("left",   15,  () -> System.out.println("left"),       
      of("right",  15),  () -> System.out.println("right"),         
      Null.class,    () -> System.out.println("Null value"),
      Else.class,    () -> System.out.println("Default value")
);

foreach(listBorders, (String side, int width) -> {
     System.out.println("border: " + side + "," + width); 	
}

      
      





The type test pattern allows you to match the type and extract the value of a variable at the same time.



switch (data) {
      case Integer i  -> System.out.println(i * i);
      case Byte    b  -> System.out.println(b * b);
      case Long    l  -> System.out.println(l * l);        
      case String  s  -> System.out.println(s * s);
      case null       -> System.out.println("Null value ");
      default         -> System.out.println("Default value: " + data);
};

      
      





In Java, for this we need to first check the type, cast to the type and then assign it to a new variable. With this pattern, the code becomes much easier.



import static org.kl.jpml.pattern.VerifyPattern.matches;

matches(data).as(
      Integer.class, i  -> { System.out.println(i * i); },
      Byte.class,    b  -> { System.out.println(b * b); },
      Long.class,    l  -> { System.out.println(l * l); },
      String.class,  s  -> { System.out.println(s * s); },
      Null.class,    () -> { System.out.println("Null value "); },
      Else.class,    () -> { System.out.println("Default value: " + data); }
);

      
      





Guard pattern allows you to simultaneously match the type and check for conditions.



switch (data) {
      case Integer i && i != 0     -> System.out.println(i * i);
      case Byte    b && b > -1     -> System.out.println(b * b);
      case Long    l && l < 5      -> System.out.println(l * l);
      case String  s && !s.empty() -> System.out.println(s * s);
      case null                    -> System.out.println("Null value ");
      default                      -> System.out.println("Default: " + data);
};

      
      





A similar design can be implemented as follows. To make it easier to write conditions, you can use the following comparison functions: lessThan / lt, greaterThan / gt, lessThanOrEqual / le, greaterThanOrEqual / ge, equal / eq, notEqual / ne. And in order to omit the conditions, you can change: always / yes, never / no.



import static org.kl.jpml.pattern.GuardPattern.matches;

matches(data).as(           
      Integer.class, i  -> i != 0,  i  -> { System.out.println(i * i); },
      Byte.class,    b  -> b > -1,  b  -> { System.out.println(b * b); },
      Long.class,    l  -> l == 5,  l  -> { System.out.println(l * l); },
      Null.class,    () -> { System.out.println("Null value "); },
      Else.class,    () -> { System.out.println("Default value: " + data); }
);

matches(data).as(           
      Integer.class, ne(0),  i  -> { System.out.println(i * i); },
      Byte.class,    gt(-1), b  -> { System.out.println(b * b); },
      Long.class,    eq(5),  l  -> { System.out.println(l * l); },
      Null.class,    () -> { System.out.println("Null value "); },
      Else.class,    () -> { System.out.println("Default value: " + data); }
);

      
      





The deconstruction pattern allows you to simultaneously map a type and decompose an object into its components.



let (int w, int h) = figure;
 
switch (figure) {
      case Rectangle(int w, int h) -> out.println("square: " + (w * h));
      case Circle   (int r)        -> out.println("square: " + (2 * Math.PI * r));
      default                      -> out.println("Default square: " + 0);
};
   
for ((int w, int h) :  listFigures) {
      System.out.println("square: " + (w * h));
}

      
      





In Java, for this, we first need to check the type, cast to the type, assign it to a new variable, and only then access the class fields through getters.



import static org.kl.jpml.pattern.DeconstructPattern.*;

Figure figure = new Rectangle();

let(figure, (int w, int h) -> {
      System.out.println("border: " + w + " " + h));
});

matches(figure).as(
      Rectangle.class, (int w, int h) -> out.println("square: " + (w * h)),
      Circle.class,    (int r)        -> out.println("square: " + (2 * Math.PI * r)),
      Else.class,      ()             -> out.println("Default square: " + 0)
);
   
foreach(listRectangles, (int w, int h) -> {
      System.out.println("square: " + (w * h));
});

      
      





Moreover, in order to get the component, the class must have one or more deconstructing methods. These methods should be annotated Extract...

All parameters must be open. Since primitives cannot be passed to a method by reference, you need to use wrappers for IntRef, FloatRef, etc. primitives.



To reduce overhead using reflection, caching and tricks are used with the standard LambdaMetafactory class.



@Extract
public void deconstruct(IntRef width, IntRef height) {
      width.set(this.width);
      height.set(this.height);
 }

      
      





The Property pattern allows you to simultaneously match the type and access the class fields by their names.



let (w: int w, h:int h) = figure;
 
switch (figure) {
      case Rectangle(w: int w == 5,  h: int h == 10) -> out.println("sqr: " + (w * h));
      case Rectangle(w: int w == 10, h: int h == 15) -> out.println("sqr: " + (w * h));
      case Circle   (r: int r) -> out.println("sqr: " + (2 * Math.PI * r));
      default                  -> out.println("Default sqr: " + 0);
};
   
for ((w: int w, h: int h) :  listRectangles) {
      System.out.println("square: " + (w * h));
}

      
      





This is a simplified form of the deconstructing pattern, where you only need to deconstruct specific class fields.



To reduce overhead using reflection, caching and tricks are used with the standard LambdaMetafactory class.



import static org.kl.jpml.pattern.PropertyPattern.*;  

Figure figure = new Rectangle();

let(figure, of("w", "h"), (int w, int h) -> {
      System.out.println("border: " + w + " " + h));
});

matches(figure).as(
      Rect.class,    of("w", 5,  "h", 10), (int w, int h) -> out.println("sqr: " + (w * h)),
      Rect.class,    of("w", 10, "h", 15), (int w, int h) -> out.println("sqr: " + (w * h)),
      Circle.class,  of("r"), (int r)  -> out.println("sqr: " + (2 * Math.PI * r)),
      Else.class,    ()                -> out.println("Default sqr: " + 0)
);
   
foreach(listRectangles, of("x", "y"), (int w, int h) -> {
      System.out.println("square: " + (w * h));
});

      
      





You can also use another method with method references to simplify the naming of fields.



Figure figure = new Rect();

let(figure, Rect::w, Rect::h, (int w, int h) -> {
      System.out.println("border: " + w + " " + h));
});

matches(figure).as(
      Rect.class,    Rect::w, Rect::h, (int w, int h) -> System.out.println("sqr: " + (w * h)),
      Circle.class,  Circle::r, (int r)  -> System.out.println("sqr: " + (2 * Math.PI * r)),
      Else.class,    ()                  -> System.out.println("Default sqr: " + 0)
);
   
foreach(listRectangles, Rect::w, Rect::h, (int w, int h) -> {
      System.out.println("square: " + (w * h));
});

      
      







The position pattern allows you to simultaneously match the type and check the value of the fields in the order of declaration.



switch (data) {
      case Circle(5)   -> System.out.println("small circle");
      case Circle(15)  -> System.out.println("middle circle");
      case null        -> System.out.println("Null value ");
      default          -> System.out.println("Default value: " + data);
};

      
      





In Java, for this, we first need to check the type, cast to the type, assign it to a new variable, and only then access the class fields through getters and check for equality.

To reduce overhead using reflection, caching is used.



import static org.kl.jpml.pattern.PositionPattern.*;

matches(data).as(           
      Circle.class,  of(5),  () -> { System.out.println("small circle"); },
      Circle.class,  of(15), () -> { System.out.println("middle circle"); },
      Null.class,            () -> { System.out.println("Null value "); },
      Else.class,            () -> { System.out.println("Default value: " + data); }
);

      
      





Also, if the developer does not want to validate some fields, these fields should be marked with annotations Exclude... These fields should be declared last.



class Circle {
      private int radius;
      	  
      @Exclude
      private int temp;
 }

      
      





Static pattern allows you to simultaneously match type and deconstruct an object using factory methods.



 
switch (some) {
      case Result.value(var v) -> System.out.println("value: " + v)
      case Result.error(var e) -> System.out.println("error: " + e)
      default                    -> System.out.println("Default value")
};

      
      





Similar to the deconstructing pattern, but the name of the deconstructing methods that are annotated Extractmust be specified explicitly.



To reduce overhead using reflection, caching and tricks are used with the standard LambdaMetafactory class.



import static org.kl.jpml.pattern.StaticPattern.*;

matches(figure).as(
      Result.class, of("value"), (var v) -> System.out.println("value: " + v),
      Result.class, of("error"), (var e) -> System.out.println("error: " + e),
      Else.class, () -> System.out.println("Default value")
); 

      
      





Sequence pattern makes it easier to process data sequences.



List<Integer> list = ...;
  
switch (list) {
      case empty()     -> System.out.println("Empty value")
      case head(var h) -> System.out.println("list head: " + h)
      case tail(var t) -> System.out.println("list tail: " + t)         
      default          -> System.out.println("Default value")
};

      
      





Using library methods, you can simply work with data sequences.



import static org.kl.jpml.pattern.SequencePattern.*;

List<Integer> list = List.of(1, 2, 3);

matches(figure).as(
      empty() ()      -> System.out.println("Empty value"),
      head(), (var h) -> System.out.println("list head: " + h),
      tail(), (var t) -> System.out.println("list tail: " + t),      
      Else.class, ()  -> System.out.println("Default value")
);   

      
      





Also, to simplify the code, you can use the following functions, which can be seen in modern languages ​​as language features or functions.



import static org.kl.jpml.pattern.CommonPattern.*;

var rect = lazy(Rectangle::new);
var result = elvis(rect.get(), new Rectangle());
   
with(rect, it -> {
   it.setWidth(5);
   it.setHeight(10);
});
   
when(
    side == Side.LEFT,  () -> System.out.println("left  value"),
    side == Side.RIGHT, () -> System.out.println("right value")
);
   
repeat(3, () -> {
   System.out.println("three time");
)
   
int even = self(number).takeIf(it -> it % 2 == 0);
int odd  = self(number).takeUnless(it -> it % 2 == 0);

      
      





As you can see, pattern matching is a powerful tool that makes writing code a lot easier. Using the capabilities of Java 8, you can emulate the capabilities of pattern matching by the very means of the language.



The source code of the library can be viewed on github: link . I would be glad to receive feedback and suggestions for improvement.



All Articles