Le pattern matching en Java : un nouveau paradigme pour davantage d’expressivité

Depuis sa création il y a presque 30 ans, le langage Java n’a cessé d’évoluer. Côté développeur, une mini révolution a été introduite avec la paramétrisation (implémentée par les  génériques Java depuis le JDK 5 en 2004). Plus récemment, les pointeurs de fonction ont été ajoutés dans le JDK 8. Cela a notamment permis l’introduction des streams et des lambdas qui sont à présent omniprésents dans nos bases de code.

Le projet Amber a pour objectif de poursuivre cette dynamique, et d’apporter au langage de nouvelles fonctionnalités pour améliorer son expressivité et sa lisibilité.

Il a déjà fourni plusieurs améliorations :

À présent, la grosse nouveauté que le projet Amber introduit peu à peu est le pattern matching. Pour expliquer de quoi il retourne, nous allons commencer par présenter une fonctionnalité déjà disponible depuis le JDK 16 : le pattern matching avec instanceof. Cela nous permettra de comprendre, à partir de cet exemple, les concepts clés qui structurent ce paradigme. Puis, nous expliquerons comment le pattern matching se prépare à être étendu à toute une gamme d’autres cas. Pour cela, nous verrons comment utiliser le pattern matching avec les switch. Nous passerons enfin à un autre type de pattern matching : le pattern matching record, qui se destine à être utilisé dans les boucles for, les instanceof et les switch. En conclusion, nous verrons les pistes envisagées pour introduire de nouveaux types de pattern matching dans le JDK.

Pattern matching avec instance of

Si vous lisez cet article, vous avez très probablement déjà écrit le code suivant :

Object o = …;
if (o instanceof String) {
  String s = (String) o;
  // maintenant, utilisons 's'
}

Dans cette situation, nous sommes obligés d’exprimer à deux reprises que la variable o est de type String : une première fois lors du test et une seconde lors du transtypage.

C’est la raison pour laquelle le JDK 16 (avec la JEP 394) nous permet d’écrire plus simplement.

Object o = …;
if (o instanceof String s) {
  // maintenant, utilisons 's'
}

Ce sucre syntaxique n’a l’air de rien, mais il s’agit du premier pattern matching introduit en Java. Cela nous donne ainsi l’occasion d’introduire plusieurs notions clé.

Le pattern matching teste s’il est possible de mettre en relation une cible (ici, la variable o) avec un pattern (ici, String s). Dans le cas présent, la correspondance se fait sur le type. On parle donc de type pattern. Nous verrons par la suite qu’une autre JEP propose le record pattern.

Concernant l’endroit où le type pattern peut être utilisé, la JEP 394 prévoit que cela soit exclusivement dans le cadre de instanceof. De la même manière, nous verrons des propositions pour utiliser des patterns dans le switch et dans les boucles for.

Enfin, ce pattern dispose d’une unique variable de liaison s. Nous verrons que le record pattern, quant à lui, permet de mettre en relation plusieurs variables de liaison à la fois.

Pattern matching avec switch

Le type pattern peut actuellement être utilisé dans le contexte instanceof. Mais pourquoi ne pas l’utiliser dans le contexte switch ? C’est ce que propose (en preview) la JEP 433.

class Shape {}
class Rectangle extends Shape {...}
class Triangle extends Shape {...}

double surface = switch (Shape shape) {
  case Rectangle rect -> rect.getLargeur() * rect.getLongueur();
  case Triangle tri -> tri.getBase() * tri.getHauteur() / 2;
  default -> 0d;
};

Ici, les classes Rectangle et Triangle implémentent l’interface Shape. Le pattern matching est alors utilisé pour tenter de mettre en relation la variable shape avec le pattern Rectangle rect et le pattern Triangle tri. D’un point de vue conceptuel, cela signifie que le switch peut utiliser les patterns, en plus des types habituels.

Bien entendu, il est également possible de supprimer le default si la classe Shape est “scellée” avec Rectangle et Triangle comme seules implémentations.

Enfin, cerise sur le gâteau, la JEP 433 propose la gestion des null, pour le cas où l’on ne souhaite pas qu’une exception soit automatiquement lancée. En combinant tout cela, nous avons donc :

sealed class Shape permits Rectangle, Triangle {...}
class Rectangle extends Shape {...}
class Triangle extends Shape {...}

double surface = switch (Shape shape) {
  case Rectangle rect -> rect.getLargeur() * rect.getLongeur();
  case Triangle tri -> tri.getBase() * tri.getHauteur() / 2;
  case null -> 0d;
};

Pour les curieux, la JEP 433 propose également de raffiner les patterns avec une clause when… mais il n’est pas certain que cette proposition subsistera quand la JEP sera en version finale.

Destructurons les records avec le pattern matching

À titre personnel, j’apprécie énormément les records. Ils ont été introduits en version finale dans le JDK 16, et permettent de créer rapidement des objets dont les attributs ont des références immuables avec getters, toString, equals, hashCode selon une syntaxe des plus compactes. Voici l’exemple que l’on trouve dans tout article qui se respecte : le record Point.

public record Point (double x, double y) {}

Le côté pénible des records, c’est quand on veut en extraire l’état.

List<Point> points = ...;
for (Point p : points) {
  double x = p.x();
  double y = p.y();
  // ...
}

Ici, le code reste acceptable, car nous n’avons que deux attributs (x et y). Mais quand leur nombre augmente, le code d’extraction des attributs augmente en conséquence, ce qui pollue le code.

Alors pourquoi ne pas utiliser le pattern matching avec les record ? La JEP 432 (en preview) propose d’utiliser le pattern matching pour faire correspondre notre Point à deux doubles. Et cela est justement possible dans les boucles for :

List<Point> points = …;
for (Point(double x, double y) : points) {
  // ...
}

Attention tout de même, si l’objet de classe Point est null, il ne pourra pas être déstructuré et une exception sera lancée.

L’utilisation du pattern matching record est également possible partout où le pattern matching est autorisé, c’est-à-dire dans les instanceof et les switch.

Object o = …;
if (o instanceof Point(double x, double y) {
  // ...
}```
double surface = switch (Shape shape) {
  case Rectangle(double largeur, double longueur) -> largeur * longueur;
  case Triangle(double base, double hauteur) -> base * hauteur / 2;
  case null -> 0d;
};

Conclusion

Arrivé sans bruit et en version finale dans le JDK 16, le pattern matching a fait son apparition avec le type pattern dans le cadre de instanceof. À présent, plusieurs JEP sont à l’étude (en preview) pour utiliser davantage le pattern matching selon deux axes. Tout d’abord, en augmentant les différents types de pattern matching. Après le type pattern, le record pattern est à l’étude. Plus récemment, l’Array pattern a également été ajouté à la JEP 432 (en preview). Selon un autre axe, l’utilisation dans davantage de contextes est à l’étude. Initialement limitée à instanceof, les patterns pourraient dans un futur proche être disponibles dans le cadre de switch et des boucles for (sous la forme des enhanced loops).

Type patternRecord pattern
Instance of if (o instanceof String s) if (o instanceof Point(int x, int y)
Switch double surface = switch (Shape shape) {
    case Rectangle rect ->
double surface = switch (Shape shape) {
    case Rectangle(int larg, int lon) -> …
Enhanced loops / for (Point(int x, int y) : points)

Cette évolution du langage est bien évidemment lente. Les versions en preview se succèdent, car il est capital de ne pas changer la syntaxe et la sémantique du code existant. Mais déjà, il est certain que cette fonctionnalité rendra plus simple certaines implémentations, comme le design pattern visiteur. Quant à moi, je suis impatient de pouvoir disposer de la déstructuration des records qui améliorera la lisibilité du code !