Le record & array pattern matching avec Java 17

Promis juré, cet article n’est pas une liste des "nouvelles fonctionnalités de Java" (JLS[1] / JSR[2] / JEP[3]).

Depuis sa sortie en septembre 2021, les articles sur Java 17 pleuvent.

Ok, ça y est, on a bien compris que cette version est une LTS[4].

Mais c’est aussi bien plus que cela.
C’est une milestone de l’objectif ambitieux nommé
"Record and Array Pattern Matching".

Cet objectif est un ensemble de fonctionnalités synergiques :

  • Les instanceof avec "Type Patterns" (dispo en 16)

  • Les Switch on Patterns (Java 17 preview, et probablement dispo en 19)

  • La déconstruction de record (Peut-être Java 19 preview)

  • La déconstruction d’array (Java 19+ ?)

  • Les imbrications de patterns (pas de visibilité de dispo)

C’est donc de ces features dont on parle ici :

  • À quoi servent-elles ?

  • Comment et dans quels contextes les utiliser ?

  • Qu’apportent-elles à notre code ?

Et avec bien sûr des exemples de code !

Dans quels langages trouve-t-on déjà du pattern matching ?

Révolutionner Java, oui, mais pourquoi ?

Quand on souhaite améliorer un produit, on commence par se demander où les efforts seraient les plus bénéfiques.
Si on veut améliorer Java, on doit alors se demander "Que fait-on le plus souvent en Java, qui mériterait un upgrade ?"

Aujourd’hui le design de nos backend d’applications de gestion pousse autour d’une problématique :
Faire varier des comportements en fonction de cas d’usage

Dans ce genre d’application, quelle que soit l’architecture choisie ou le style dev, on se retrouve à un moment ou un autre à :

1. Modéliser notre domaine métier

Cela peut être fait dans un package spécifique avec des POJO, ou avec des Entity JPA.
La seconde option est la plus répandue, mais ce n’est pas ma préférée. Je trouve que c’est une erreur de concevoir le business d’une application autour d’une base de données.

2. Écrire des DTO

Dans l’idéal, un DTO est immutable (Il n’y a aucune raison de changer la représentation d’une donnée transmise à un moment T).
Le record est la structure de données la plus appropriée.
Sinon, avant Java 14, on a les @Value de Lombok.
On peut aussi se contenter de POJO mutables.

Dans nos applications modulaires, on peut avoir envie de partager ces structures de données entre des modules :

Modèle du domain Invoice

Et bien pour faire varier les comportements des actions affectant ces classes, la Programation-Orientée-Objets nous incite à ajouter des méthodes sur nos classes de domaine.

Par exemple, pour la domain-class Invoice, le module Letter pourrait vouloir ajouter une méthode calculateRemainingAmountToPay().
Le module Send pourrait vouloir une méthode getRecipients().

Modèle de domaine avec Invoice et cas d’utilisation
class Invoice implements CalculableAmount, Sendable {
  private String label;
  private CodeInvoice code;
  private Client client;
  private Receipt receipt;
  private Devise amount;

  @Override
  public Integer calculateRemainingAmountToPay(){
    ...
  }

  @Override
  public List<Person> getRecipients(){
    ...
  }

  ...
}

Au bout d’un moment, notre domain-class Invoice a beaucoup de méthodes issues de différents modules.
Le module Letter utilise Invoice et se retrouve à pouvoir appeler les méthodes du module Send ; ce qui viole au moins :

Effet bonus : Quand on change Invoice dans le cadre du contexte Letter, on doit recompiler/relivrer aussi le contexte Send.

Solution : séparer la logique métier des structures sur lesquelles elle agit

Pour y parvenir, on utilisait jusque-là au moins ces 3 patterns :

  • Le visitor pattern[5]

  • Le delegate pattern

  • Le pattern service-everywhere avec des méthodes à 8 arguments (un anti-pattern d’après moi), qui naît de la programmation procédurale dans un monde d’https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#beans[inversion de contrôle.]

Mais à présent avec Java 17, une quatrième solution élégante s’offre à nous : Le Pattern Matching.

Mais qu’est-ce que le pattern matching ?

Je pense qu’on ne peut pas couper à la définition de Wikipédia :

In computer science, pattern matching is the act of checking a given sequence of tokens for the presence of the constituents of some pattern.

— https://en.wikipedia.org/wiki/Pattern_matching

On a tendance à penser alors aux expressions régulières, mais non, il ne s’agit pas de cela.

Là, les patterns à matcher sont des structures de données :

  • Des classes

  • Des interfaces

  • Des array

  • Et bien sûr des records !

Je trouve que le cas du matching sur instanceof avec Type-Pattern est le plus facile à comprendre.
Avant Java 17, on avait ça :

if (invoice instanceof PaidInvoice) {
  letterService.letter(((PaidInvoice) invoice));
  return;
}
if (invoice instanceof DueInvoice) {
  recoveryService.remind(((DueInvoice) invoice));
}

Et à présent :

if (invoice instanceof PaidInvoice paidInvoice) {
  letterService.letter(paidInvoice);
  return;
}
if (invoice instanceof DueInvoice dueInvoice) {
  recoveryService.remind(dueInvoice);
}

Ici le pattern à matcher est l’appartenance aux classes PaidInvoice et DueInvoice. On teste si l’instance a un des types, et un cast implicite est fait vers une "binding variable" (paidInvoice ou dueInvoice).

Comment le Pattern Matching remplace-t-il le visitor pattern ?

J’ai promis des exemples de code, les voici.

Voici l’implémentation du visitor pattern avec le modèle de Invoice :

interface InvoiceVisitable {
  default void accept(InvoiceVisitor invoiceVisitor) {
    invoiceVisitor.visit(this);
  }
}

abstract class Invoice implements InvoiceVisitable {
}

class PaidInvoice extends Invoice {
}

class DueInvoice extends Invoice {
  private Integer reminderNumber = 0;

  public void incrementReminderNumber(){
    reminderNumber++;
  }

  public boolean hasAlreadyBeenReminded() {
    return reminderNumber >= 1;
  }
}

interface InvoiceVisitor {
  void visit(PaidInvoice paidInvoice);

  void visit(DueInvoice dueInvoice);
}

interface LetterService {
  void letter(PaidInvoice paidInvoice);
}

interface RecoveryService {
  void remind(DueInvoice dueInvoice);
}

record MainInvoiceVisitor(LetterService letterService, RecoveryService recoveryService) implements InvoiceVisitor {

  @Override
  public void visit(PaidInvoice paidInvoice) {
    letterService.letter(paidInvoice);
  }

  @Override
  public void visit(DueInvoice dueInvoice) {
    recoveryService.remind(dueInvoice);
  }
}

record InvoiceService(MainInvoiceVisitor mainInvoiceVisitor) implements InvoiceProcessing {

  public void handleInvoice(Invoice invoice) {
    invoice.accept(mainInvoiceVisitor);
  }
}

On observe que le rapport code utile / boilerplate n’est pas excellent.

Et maintenant :

record InvoiceService(LetterService letterService, RecoveryService recoveryService) implements InvoiceProcessing {

  public void handleInvoice(Invoice invoice) {
    if (invoice instanceof PaidInvoice paidInvoice) {
      letterService.letter(paidInvoice);
      return;
    }
    if (invoice instanceof DueInvoice dueInvoice) {
      recoveryService.remind(dueInvoice);
    }
  }
}

Le InvoiceService se suffit à lui-même, et la lisibilité me semble très acceptable.

Mais avez-vous remarqué quelque chose dans ce dernier bout de code ?

Le cas où invoice est d’un autre type n’est pas géré !
Il existe une solution alternative (et meilleure je trouve) à lever une NotImplementedException.

Les types scellés

C’est là que la fonctionnalité Java 15 de types scellés intervient.
Modifions un peu notre modèle :

abstract sealed class Invoice permits PaidInvoice, DueInvoice {
}

final class PaidInvoice extends Invoice {
}

final class DueInvoice extends Invoice {
  private Integer reminderNumber = 0;

  public void incrementerNombreReminder(){
    reminderNumber++;
  }

  public boolean hasAlreadyBeenReminded() {
    return reminderNumber >= 1;
  }
}
Traduction en français :

Il n’existe que 2 types de Invoice possibles : PaidInvoice et DueInvoice.
Ces dernières ne peuvent être étendues.
Point.

Cela donne donc :

record InvoiceService(LetterService letterService, RecoveryService recoveryService) implements InvoiceProcessing {

  public void handleInvoice(Invoice invoice) {
    switch (invoice) {
      case PaidInvoice paidInvoice -> letterService.letter(paidInvoice);
      // case DueInvoice dueInvoice -> recoveryService.remind(dueInvoice);
    }
  }
}

J’ai commenté le cas de la DueInvoice afin d’observer ce que nous disent le compilateur et l’IDE :

java: the switch statement does not cover all possible input values IntelliJ
java compile error java 17 the switch statement does not cover all possible input values

On doit alors déclarer le Consumer<? extends Invoice> de tous les cas restants, ou bien les grouper dans un default :

record InvoiceService(LetterService letterService, RecoveryService recoveryService) implements InvoiceProcessing {

  public void handleInvoice(Invoice invoice) {
    switch (invoice) {
      case PaidInvoice paidInvoice -> letterService.letter(paidInvoice);
      // case DueInvoice dueInvoice -> recoveryService.remind(dueInvoice);
      default -> LOGGER.info("Cool y a rien à faire pour le cas là !");
    }
  }
}

Avec cette syntaxe, le langage nous apporte une validation métier de plus à la compile time (soit plus tôt qu’à la runtime. Tout ce qui réduit la boucle de feedback est bénéfique).
C’est les TDDistes qui sont contents.

Et si on allait encore plus loin ?

Les Guarded Pattern

Allez, ajoutons une feature preview de Java 17 : un "Guarded Pattern"

record InvoiceService(LetterService letterService, RecoveryService recoveryService) implements InvoiceProcessing {
    public void handleInvoice(Invoice invoice) {
        switch (invoice) {
            case PaidInvoice paidInvoice -> letterService.letter(paidInvoice);
            case DueInvoice dueInvoice && dueInvoice.hasAlreadyBeenReminded() -> recoveryService.startRecovery(dueInvoice);
            case DueInvoice dueInvoice -> recoveryService.remind(dueInvoice);
        }
    }
}

Un "Guarded Pattern" permet d’ajouter à notre pattern des conditions sur les valeurs de l’objet matché en plus de son type.

Alors c’est très bien tout ça, mais l’objectif à terme du pattern matching va encore plus loin en ce qui concerne les records.

Reprenons notre exemple de Invoice, mais considérons qu’elle vient d’arriver d’un Controlleur sour forme de DTO (et donc de record) :

record Invoice(String code, String label, Integer amount, ZonedDateTime creationDate, ...){}

Je ne lui donne que quelques champs, mais considérons en plus qu’il y a en une vingtaine, une centaine, beaucoup…​

Quand je veux mapper cette invoice vers un usecase, alors ce dernier n’a très certainement besoin que de seulement quelques-uns de ces champs. Le code suivant serait donc une erreur de design :


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@RestController
class InvoiceControlleur {

  @PostMapping
  @ResponseStatus(HttpStatus.CREATED)
  public Long create(@RequestBody Invoice invoice) {
    Preconditions.checkNotNull(invoice);
    notifyNewInvoiceUseCase.handle(invoice);
    return invoiceService.handle(invoice)
  }
}

Après Java 18 (En preview de Java 19 avec un peu de chance), on va pouvoir déconstruire des structures de données.

Qu’est-ce que la "déconstruction" ?

Ce concept a un objectif similaire au I de SOLID : la ségrégation.

Si je reçois un objet avec 43 champs alors que j’en ai besoin que de 2, la "deconstruction on pattern" va m’aider.

Regardons ça avec du code.

J’ai mon énorme dto Invoice :

record Invoice(
  String code,
  String libellé,
  Integer amount,
  ZonedDateTime dateCréation,
  ... // imaginez ici 39 autres champs
){}

Mais la règle métier que je veux appliquer ne porte que sur le code et le amount. Je peux alors étendre le concept de instanceof précédent, en lui ajoutant une déconstruction du Record "Invoice" :

if (object instanceof Invoice(String code, Integer amount)) {
  myUseCase.handle(code, amount);
}

Ici, type et price sont des "binding variables" générées implicitement si l’object match le pattern Product.

Et ça sert à quoi ?

Cela apporte 2 bénéfices :

  1. Découplage

  2. Expressivité

Comparez plutôt le précédent code avec la méthode habituelle :

if (object instanceof Invoice) {
    Invoice invoice = ((Invoice) object);
    String type = invoice.getType();
    String price = invoice.getPrice();
    myUseCase.handle(type, price);
}

La déconstruction d’array

De la même manière que pour les record, on va bientôt pouvoir déconstruire des array afin de :

  • Matcher sur sa structure (exemple : myArray.size() == 3)

  • Binder ses éléments vers des variables

Voyons ce binding avec l’exemple d’un array d’Object.

Mettons que, par convention :

  • Le premier élément "1345" est par convention le montant

  • Le deuxième élément "FAC" est le code du document

Alors voilà comment on pourrait appeler invoiceService.handle, qui n’a besoin que de ces 2 champs, mais pas des suivants :

Object[] fields = { 1345, "FAC", "9834765", "user9475", "e45737645"  }

if (fields instanceof Object[] { Integer Price, String code }) {
  invoiceService.handle(price, code);
}

Et Java 18/19 alors ?

À l’écriture de ces lignes, Java 18 est en phase de release candidate.
Cela signifie que la liste de ces features est fixée.
En ce qui concerne le pattern matching, on y retrouve la JEP 420 : Pattern Matching for switch (Second Preview)
Cette seconde preview apporte des corrections de syntaxe et de compilation mineures, qui n’affectent pas les explications précédentes. Java 19 est en early-access avec une seule JEP.
J’espère y trouver la déconstruction de record/array/méthode en preview.

Conclusion

Qu’apporte à notre code ces nouvelles fonctionnalités ?

  • Plus de validation à la compile-time, et donc une boucle de feedback plus rapide.

  • Développer plus intuitivement (le compilateur nous dis ce qu’on a oublié)

  • Faire émerger de meilleurs designs

J’ai passé en revue les fonctionnalités phares du "record and array pattern matching", en appuyant sur "dans quels contextes les utiliser ?", "pourquoi les utiliser ?".
Les principales sont déjà dans Java 17, d’autres sont dans sa preview, et les restantes ne tarderont pas.
Cette révolution du langage est probablement au niveau de la révolution des Stream et de l’API Function de Java 8.


1. JLS : Java Language Specification
2. JSR : Java Specification Request
3. JEP : JDK Enhancement Proposal
4. LTS : Long Term Support
5. "Today, to express ad-hoc polymorphic calculations like this we would use the cumbersome visitor pattern". source : https://openjdk.java.net/jeps/405

Antoine

Si ça parle Java chez Younup, soyez sûr qu'Antoine n'est jamais très loin !
Spécialiste de Spring Boot et fan de Groovy - pour son langage intuitif, haut niveau et qui permet la métaprogrammation - il est toujours curieux de découvrir de nouvelles technos ou outils qui pourraient booster sa productivité.
L'élu de son coeur ? IntelliJ Ultimate. Pourquoi ? Parce qu'il a tout de ce qu'il attend d'un IDE : tous les outils dans un seul outil, et interopérables.

Un peu de chocolat, du café et un pc qui tient la route, c'est tout ce dont il a besoin pour coder ! Pas possible de poireauter 4h par jour devant des loaders...
Heureusement qu'un bon vieux tableau blanc fait toujours l'affaire pour concevoir des designs en équipe !

Mais Antoine n'est pas seulement fan de Bob Martin ou de designs modulaires et découplés, laissez lui un après-midi pour customiser son jardin avec un nouveau cabanon ou karchériser sa terrasse. Et prévoyez un peu d'entraînement si il vous défie au palet; sur planche en plomb s'il vous plaît ! (pas en bois, désolé les bretons).

Retours aux publications