benjiPosted under Java.

One of the language features many people miss in Java is pattern matching, and/or an equivalent of Scala case classes.

In Scala we can match on types and structure. We have “switch” in Java, but it’s much less powerful and it can’t even be used as an expression. It’s possible to simulate matching in Java with some degree of success.

Matching on Type

Here’s an example matching on type. Our description method accepts a shape which can be one of three types – Rectangle, Circle or Cube. It is a compile failure to not specify a handler for each of Rectangle/Circle/Cube.

@Test
public void case_example() {
    Shape cube = Cube.create(4f);
    Shape circle = Circle.create(6f);
    Shape rectangle = Rectangle.create(1f, 2f);
 
    assertEquals("Cube with size 4.0", description(cube));
    assertEquals("Circle with radius 6.0", description(circle));
    assertEquals("Rectangle 1.0x2.0", description(rectangle));
}
 
public String description(Shape shape) {
    return shape.match()
        .when(Rectangle.class, rect -> "Rectangle " + rect.width() + "x" + rect.length())
        .when(Circle.class, circle -> "Circle with radius " + circle.radius())
        .when(Cube.class, cube -> "Cube with size " + cube.size());
}
interface Shape extends Case3<Rectangle, Circle, Cube> { }
interface Cube extends Shape {
    float size();
    static Cube create(float size) {
        return () -> size;
    }
}
//...

If Java didn’t already have an Optional type we could use this to implement our own, although type-erasure causes us to need a hack to match on the raw (unparameterised) type – to convert from a Class<Some<String>> to a Class<Some> for matching.

@Test
public void some_none_match_example() {
    Option<String> exists = some("hello");
    Option<String> missing = none();
 
    assertEquals("hello", describe(exists));
 
    assertEquals("missing", describe(missing));
}
 
private String describe(Option<String> option) {
    return option.match()
        .when(erasesTo(Some.class), some -> some.value())
        .when(erasesTo(None.class), none -> "missing");
}
interface Option<T> extends Case2<Some<T>, None<T>>{ }

Matching on Structure

Another thing we might want to do is match on particular values. This means we’re be unable to ensure that all values are handled and need to provide a default case.

Here’s an example. The underscore character is used to denote any value, like in Scala.

@Test
public void constructor_matching_any() {
    Person so = person("Some", "One");
    Person an = person("Ann", "Other");
 
    String another = an.match()
        .when(person("Some", _), p -> "someone")
        .when(person(_, "Other"), p -> "another")
        ._("Unknown Person");
 
    assertEquals("another", another);
}

A more practical example might be handling command line arguments

@Test
public void parse_arguments_example() {
    applyArgument(arg("--help", "foo"));
    assertEquals("foo", this.helpRequested);
 
    applyArgument(arg("--lang", "English"));
    assertEquals("English", this.language);
 
    applyArgument(arg("--nonsense","this does not exist"));
    assertTrue(badArg);
}
 
private void applyArgument(Argument input) {
    input.match()
        .when(arg("--help", _), arg -> printHelp(arg.value()))
        .when(arg("--lang", _), arg -> setLanguage(arg.value()))
        ._(arg -> printUsageAndExit());
}

Decomposition

With a bit more effort we can even match on the structure of the type and pull out the bits we are interested in. Here’s an example. We match on certain attributes of Person and then consume the other attributes in the following function to build the result.

@Test
public void decomposition_variable_items_example() {
    Person a = person("Bob", "Smith", 18);
    Person b = person("Bill", "Smith", 28);
    Person c = person("Old", "Person", 90);
 
    assertEquals("first_Smith_18", matchExample(a));
    assertEquals("second_28", matchExample(b));
    assertEquals("unknown", matchExample(c));
}
 
String matchExample(Person person) {
    return person.match()
        .when(person("Bob", _, _), (surname, age) -> "first_" + surname + "_" + age)
        .when(person("Bill", "Smith", _), age -> "second_" + age)
        ._("unknown");
}

Making it Work

To implement this we create an interface for each number of cases we want to be able to match on, parameterised by the possible types it can take. This is effectively giving us the “Type A OR Type B” restriction. This interface provides a default method match() which returns a builder which lets you specify a handler for each and all of the cases.

When evaluated, match() compares its own type to the passed in Class<?>.

public interface Case3<T,U,V> {
    default MatchBuilderNone<T,U,V> match() {
        return new MatchBuilderNone<T, U, V>() {
            public <R> MatchBuilderOne<T, U, V, R> when(Class<T> clsT, Function<T, R> fT) {
                return (clsU, fU) -> (clsV, fV) -> {
                    if (clsT.isAssignableFrom(Case3.this.getClass())) return fT.apply((T)Case3.this);
                    if (clsU.isAssignableFrom(Case3.this.getClass())) return fU.apply((U)Case3.this);
                    if (clsV.isAssignableFrom(Case3.this.getClass())) return fV.apply((V)Case3.this);
 
                    throw new IllegalStateException("Match failed");
                };
            }
        };
    }
}

For the matching on parts of a value to work (as in the argument parsing example) we need equals/hashCode to work. This would be a lot easier with actual value type support.

We can extend the base Case interface to allow matching on value as well class, and then building on the autoEquals/Hashcode I explained previously add additional constructor functions that create value objects that ignore specific fields for equality checking.

This is still far more verbose than it should have to be, and it requires adding suitable constructors to each value type you want to use match() with, but I think it’s quite neat.

Here’s what a Person type that’s matchable on either firstname or lastname or both looks like.

interface Person extends Case<Person> {
    String firstname();
    String lastname();
 
    static Person person(String firstname, String lastname) {
        return person(firstname, lastname, Person::firstname, Person::lastname);
    }
    static Person person(String firstname, MatchesAny lastname) {
        return person(firstname, null, Person::firstname);
    }
    static Person person(MatchesAny firstname, String lastname) {
        return person(null, lastname, Person::lastname);
    }
    static Person person(String firstname, String lastname, Function<Person, ?>... props) {
        abstract class PersonValue extends Value<Person> implements Person {}
        return new Person() {
            public String firstname() { return firstname; }
            public String lastname() { return lastname; }
        }.using(props);
    }
}

Decomposition uses pretty much the same approach, but now the factory method has to record the properties that we want to capture as follows. .missing() is overloaded with different numbers of fields to return different types. This allows our .when() match method to also be overloaded based on how many fields we are extracting and therefore accept a lambda with the correct number of parameters. i.e. when(OneMissing, Function), when(TwoMissing, BiFunction)

static OneMissing<Person, String> person(String firstname, MatchesAny lastname, Integer age) {
    return person(firstname, null, age, Person::firstname, Person::age)
        .missing(Person::lastname);
}

Further reading

More examples and implementation on github

Leave a Reply

  • (will not be published)