Blog Arolla

Pattern matching en Java 8

Le filtrage par motif, en anglais pattern matching, consiste pour une valeur donnée à voir si elle correspond à un motif ou pas. Si c'est le cas une action est déclenchée.

De manière intrinsèque le langage Java possède la structure switch ... case.

On peut l'utiliser avec des entiers (byte, short et int):

    static String toGender(int genderCode) {
         final String gender;
         switch (genderCode) {
             case 1: gender = "man";
                 break;
             case 2: gender = "woman";
                 break;
             default:
                 throw new IllegalArgumentException("Unknown gender for code " + genderCode);
         }
         return gender;
    }

    assertThat(toGender(1)).isEqualTo("man");

On peut utiliser une énumération pour définir les constantes à tester:

    static String toGender(GenderCode genderCode) {
        final String gender;
        switch (genderCode) {
            case MAN: gender = "man";
                break;
            case WOMAN: gender = "woman";
                break;
            default:
                throw new IllegalArgumentException("Unknown gender for code " + genderCode);
        }
        return gender;
    }

    static enum GenderCode {
        MAN,
        WOMAN
    }

    assertThat(toGender(GenderCode.MAN)).isEqualTo("man");

On peut l'utiliser également avec des caractères:

    static String toCivility(char civilityCode) {
        final String civility;
        switch (civilityCode) {
            case 'M' : civility = "Monsieur";
                break;
            case 'W' : civility =  "Madame";
                break;
            default:
                throw new IllegalArgumentException("Unknown civility for code " + civilityCode);
        }
        return civility;
    }

    assertThat(toCivility('M')).isEqualTo("Monsieur");

Depuis le JDK 1.7 on peut l'utiliser également avec des chaînes de caractères:

     static String toCivility(String abbreviation) {
        final String civility;
        switch (abbreviation) {
            case "Mr." : civility = "Monsieur";
                break;
            case "Mrs." : civility =  "Madame";
                break;
            default:
                throw new IllegalArgumentException("Unknown civility for abbreviation " + abbreviation);
        }
        return civility;
     }

     assertThat(toCivility("Mrs.")).isEqualTo("Madame");

D'autres langages permettent de faire du pattern matching sur des choses plus complexes comme les types par exemple.
Nous allons donc voir comment implémenter le pattern matching et comment Java 8 nous permet de réduire le code nécessaire pour le réaliser.

Essayons d'implémenter un pattern matching en fonction du type de la valeur à tester. Il nous faut donc une méthode qui prend une valeur en entrée et nous retourne le résultat d'instruction si la valeur valide une condition (ici le type de la valeur) :

    public interface PatternMatching<T, R> {

        Optional<R> matches(V value);

    }

Le JDK 8 possède une classe java.util.Optional qui permet d'indiquer que la réponse d'une méthode peut être le résultat attendu ou rien. Dans notre exemple si une valeur correspond à un type attendu la méthode matches() retournera un Optional qui contiendra le résultat de l'action exécutée par le pattern matching.

    assertThat(pm.matches(42)).isEqualTo(Optional.of("Integer: 42"));

Dans le cas contraire la méthode matches() retourner un Optional.empty().

    assertThat(pm.matches(43.1)).isEqualTo(Optional.empty());

Maintenant il nous faut pouvoir définir un pattern matching qui pour une condition donnée associe une action et que ce soit appelé par la méthode matches().
Le JDK 8 permet de définir des méthodes statiques dans une interface. On va donc enrichir notre interface d'une méthode de fabrique. Elle prendra en paramètre un prédicat pour la condition et une fonction pour l'action associée:

    public interface PatternMatching<T, R> {

        static <T, R> PatternMatching<T, R> when(Predicate<T> predicate, Function<T, R> action) { ... }

        Optional<R> matches(V value);

    }

Les interfaces java.util.function.Predicate et java.util.function.Function sont fournies par le JDK 8 pour définir respectivement des prédicats et des fonctions.
On peut appeler maintenant cette méthode:

    final PatternMatching<? super Number, String> pm =
                    when(new Predicate<Number>() {
                        @Override
                        public boolean test(Number number) {
                            return Integer.class.isInstance(number);
                        }
                    }, new Function<Number, String>() {
                        @Override
                        public String apply(Number x) {
                            return "Integer: " + x;
                        }
                    });

    assertThat(pm.matches(42)).isEqualTo(Optional.of("Integer: 42"));

Avec le JDK 8 on peut simplifier cet appel en utilisant une référence de méthode pour tester que la valeur est une instance d'Integer et une expression lambda pour définir l'action à exécuter si le prédicat est vrai.

    final PatternMatching<? super Number, String> pm =
                when(Integer.class::isInstance, x -> "Integer: " + x);

    assertThat(pm.matches(42)).isEqualTo(Optional.of("Integer: 42"));

Le pattern matching peut être créé et défini à partir d'une expression lambda:

    public interface PatternMatching<T, R> {

        static <T, R> PatternMatching<T, R> when(Predicate<T> predicate, Function<T, R> action) {
            Objects.requireNonNull(predicate);
            Objects.requireNonNull(action);

            return value -> {
                if (predicate.test(value)) {
                    return Optional.of(action.apply(value));
                } else {
                    return Optional.empty();
                }
            };
        }

        Optional<R> matches(V value);
    }

Après s'être assurés que le prédicat et l'action sont bien définis, on crée une lambda qui prend la valeur en argument de la méthode matches(), appelle le prédicat puis l'action si celui-ci est vrai. On met le résultat de l'action dans un Optional.
L'action ne peut pas retourner de null, c'est grâce à ça que l'on sait si une condition a été remplie et que l'action correspondante a été exécutée.
Si l'action doit retourner malgré tout null il faut utiliser le design pattern Null Object ou définir le résultat comme un Optional.

    final PatternMatching<? super Number, Optional<String>> pm =
                    when(Integer.class::isInstance, x -> Optional.<String>empty());

    assertThat(pm.matches(42)).isEqualTo(Optional.of(Optional.empty()));

Si le prédicat est faux un Optional.empty() est retourné.

    PatternMatching<? super Number, String> pm =
                    when(Integer.class::isInstance, x -> "Integer: " + x);

    assertThat(pm.matches(43.1)).isEqualTo(Optional.empty());

On veut pouvoir définir plusieurs conditions dans notre pattern matching comme ceci :

    final PatternMatching<? super Number, String> pm =
                when(Integer.class::isInstance, x -> "Integer: " + x)
                .orWhen(Double.class::isInstance, x -> "Double: " + x);

    assertThat(pm.matches(42)).isEqualTo(Optional.of("Integer: 42"));
    assertThat(pm.matches(1.42)).isEqualTo(Optional.of("Double: 1.42"));

En JDK 8 on peut définir des implémentations de méthode par défaut dans une interface. C'est ce qu'on utilise pour orWhen().

    public interface PatternMatching<T, R> {
           ...

         default PatternMatching<T, R> orWhen(Predicate<T> predicate, Function<T, R> action) {
             Objects.requireNonNull(predicate);
             Objects.requireNonNull(action);

             return value -> {
                 final Optional<R> result = matches(value);
                 if (result.isPresent()) {
                     return result;
                 } else {
                     if (predicate.test(value)) {
                         return Optional.of(action.apply(value));
                     } else {
                         return Optional.empty();
                     }
                 }
             };

         }
           ...
    }

Pour faire appelle à orWhen() on doit avoir déjà une instance de PatternMatching, créée par when() par exemple. C'est pour cette raison que la lambda qui implémente matches() dans orWhen() appelle matches(value) pour voir si le motif précédent a déclenché une action.
Si aucune action n'a été déclenchée on appelle le prédicat du second motif, et s'il est vrai on déclenche l'action associée.

On complète pour pouvoir déclencher une action si aucun motif ne correspond à la valeur.

    public interface PatternMatching<T, R> {
           ...

         default PatternMatching<T, R> otherwise(Function<T, R> action) {
             Objects.requireNonNull(action);

             return value -> {
                 final Optional<R> result = matches(value);
                 if (result.isPresent()) {
                     return result;
                 } else {
                     return Optional.of(action.apply(value));
                 }
             };
         }
           ...
    }

Si on appelle otherwise() en dernière position le contrat est rempli.

    PatternMatching<? super String, String> pm = when(x-> false, x -> "")
                    .otherwise(x -> "got this object: " + x);

    assertThat(pm.matches("foo")).isEqualTo(Optional.of("got this object: foo"));

Voilà on vient d'implémenter un pattern matching en 60 lignes environ :

    import java.util.Objects;
    import java.util.Optional;
    import java.util.function.Function;
    import java.util.function.Predicate;

    @FunctionalInterface
    public interface PatternMatching<T, R> {

        static <T, R> PatternMatching<T, R> when(Predicate<T> predicate, Function<T, R> action) {
            Objects.requireNonNull(predicate);
            Objects.requireNonNull(action);

            return value -> {
                if (predicate.test(value)) {
                    return Optional.of(action.apply(value));
                } else {
                    return Optional.empty();
                }
            };
        }

        default PatternMatching<T, R> otherwise(Function<T, R> action) {
            Objects.requireNonNull(action);

            return value -> {
                final Optional<R> result = matches(value);
                if (result.isPresent()) {
                    return result;
                } else {
                    return Optional.of(action.apply(value));
                }
            };
        }

        default PatternMatching<T, R> orWhen(Predicate<T> predicate, Function<T, R> action) {
            Objects.requireNonNull(predicate);
            Objects.requireNonNull(action);

            return value -> {
                final Optional<R> result = matches(value);
                if (result.isPresent()) {
                    return result;
                } else {
                    if (predicate.test(value)) {
                        return Optional.of(action.apply(value));
                    } else {
                        return Optional.empty();
                    }
                }
            };

        }

        Optional<R> matches(T value);

    }

On peut l'utiliser avec des chaînes de caractères:

    PatternMatching<? super String, String> pm = when("world"::equals, x -> "Hello, " + x);

    assertThat(pm.matches("world")).isEqualTo(Optional.of("Hello, world"));

    assertThat(pm.matches("Bob")).isEqualTo(Optional.empty());

On peut l'utiliser avec des entiers:

    PatternMatching<? super Integer, String> pm = when(x -> x == 42, x -> "forty-two");

    assertThat(pm.matches(42)).isEqualTo(Optional.of("forty-two"));

    assertThat(pm.matches(43)).isEqualTo(Optional.empty());

On peut faire des combinaisons:

    PatternMatching<Object, String> pm = when("world"::equals, x -> "Hello, " + x)
                   .orWhen(Double.class::isInstance, x -> "Double: " + x)
                   .otherwise(x -> "got this object: " + x);

    assertThat(pm.matches("world")).isEqualTo(Optional.of("Hello, world"));
    assertThat(pm.matches("foo")).isEqualTo(Optional.of("got this object: foo"));
    assertThat(pm.matches(1.42)).isEqualTo(Optional.of("Double: 1.42"));

J'espère vous avoir convaincu que faire du pattern matching en Java 8 n'est pas une difficulté insurmontable, et c'est au passage l'occasion de jouer avec les nouveautés de Java 8 !

Plus de publications

4 comments for “Pattern matching en Java 8

  1. Kevin
    21 janvier 2015 at 0 h 07 min

    Salut, merci pour cet article très intéressant, petite faute de typo cela dit “Pour faire appelle à orWhen() on do…”

  2. patrick giry
    21 janvier 2015 at 1 h 08 min

    Salut Kevin, merci de commentaire, mais j’ai pas compris quel été le problème. Tu peux être plus précis pour que je puisse corriger.

  3. Eldc
    29 octobre 2015 at 1 h 07 min

    Euh… C’est clair en fait. Ca s’écrit “faire appel à”. Un appel.

  4. Lolo101
    13 décembre 2018 at 18 h 48 min

    C’est un vieux post mais je tombe dessus seulement maintenant 🙂

    Au sujet du pattern matching et des switch, voici une idée de ce qui nous attend dans Java 12 : https://cr.openjdk.java.net/~briangoetz/amber/pattern-match.html