De grandes nouveautés ont été annoncées au Devfest Nantes 2021 où nous étions présent les 21 et 22 octobre derniers ! José Paumard (@JosePaumard), de chez Oracle, a présenté une roadmap de nature à challenger l’idée d’un langage sclérosé (voir les articles Feature match : Java 15 vs les autres langages de la JVM et Feature match : Java 15 vs les autres langages de la JVM (Ep.2 : API)). Après un bref passage sur quelques nouvelles, nous nous concentrerons sur des améliorations qui semblent converger vers une fonctionnalité très attendue : le pattern matching.
Centrée autour des Records et du pattern matching, la présentation laissait clairement espérer voir Java rejoindre un jour le niveau de Scala sur cette fonctionnalité. De plus, les changements apportés à la JVM (Java Virtual Machine) pour le supporter permettent d’espérer que des améliorations seront également visibles dans tous les langages de cet éco-système.
Oracle repasse sur un modèle de gratuité du JDK en production, mais à condition d’être sous la dernière version supportée. Vous pouvez donc passer de LTS (Long-Term Support) en LTS tout en utilisant la JDK d’Oracle gratuitement. Et fini le hack pour simuler l’acceptation de la licence pour télécharger automatiquement le JDK ! Si les détails concernant le nouveau cycle de release du JDK vous intéressent, ils sont dans la première partie de la vidéo [DevFest Nantes 2021] Java au Futur : des Record au Pattern Matching. Nous nous intéresserons ici plutôt aux nouveautés de Java 17 et de ses successeurs.
Les Nestmates : les classes imbriquées et leur classe hôte sont désormais explicitement hôtes ou compagnons, avec persistance de l’information au runtime. L’accès aux membres des nestmates est donc explicitement géré, la JVM n’a plus besoin de créer des accesseurs synthétiques pour permettre aux compagnons d’accéder aux membres privés de l’hôte.
Les Constant Dynamics : ajout de l’instruction constantdynamic dans le byte code Java, qui permet en premier lieu de réintégrer dans le constant pool des constantes qui sont générées par une expression non statique, et ainsi de leur faire bénéficier des mêmes optimisations que les autres constantes. En second lieu, cela ouvre la perspective de variables lazy en Java ! Cela permettrait également la simplification des Delegate de Kotlin et probablement l’implémentation interne de lazy en Scala.
Des explications détaillées se trouvent dans l’article Hands on Java 11’s constant dynamic.
Le switch devient une expression, simplifiant certaines écritures. Et mine de rien, ça participe tout autant que les fameuses lambdas de Java 8 à écrire un Java moins impératif et plus fonctionnel.
Ancien switch statement
public enum Day { SUNDAY, MONDAY, TUESDAY,
WEDNESDAY, THURSDAY, FRIDAY, SATURDAY; }
// ...
int numLetters = 0;
Day day = Day.WEDNESDAY;
switch (day) {
case MONDAY:
case FRIDAY:
case SUNDAY:
numLetters = 6;
break;
case TUESDAY:
numLetters = 7;
break;
case THURSDAY:
case SATURDAY:
numLetters = 8;
break;
case WEDNESDAY:
numLetters = 9;
break;
default:
throw new IllegalStateException("Invalid day: " + day);
}
System.out.println(numLetters);
Nouveau switch expression
Day day = Day.WEDNESDAY;
System.out.println(
switch (day) {
case MONDAY, FRIDAY, SUNDAY -> 6;
case TUESDAY -> 7;
case THURSDAY, SATURDAY -> 8;
case WEDNESDAY -> 9;
default -> throw new IllegalStateException("Invalid day: " + day);
}
);
Cette version entérine les Records et le instanceof pattern qui étaient en preview depuis Java 14.
Comme nous l’avons montré dans Feature match : Java 15 vs les autres langages de la JVM, les Records sont pour Java le précurseur des data class/case class déjà présentes respectivement en Kotlin/Scala, mais n’en avaient alors pas la puissance.
Le instanceof n’est plus juste un mot-clé qui retourne un booléen, mais une syntaxe de pattern dans lequel on peut trouver des variables. Pour le pattern instanceof, c’est la variable typée qu’on peut référencer si l’expression est vraie (si le pattern correspond). On économise ainsi une variable locale et un horrible cast down.
Sans pattern
if (person instanceof Customer) {
Customer customer = (Customer) person;
customer.pay();
}
Avec pattern
if (person instanceof Customer customer) {
customer.pay();
}
On parle pattern matching parce que le instanceof rentre désormais dans catégorie des matchers : est-ce que <expression sujette> ressemble à <expression servant de modèle> ?
Au runtime, le modèle de droite (pattern) est rempli avec les éléments de l’instance de gauche si c’est possible.
Les classes scellées sont des interfaces ou classes abstraites dont on doit connaître à l’avance toutes les implémentations possibles (comme en Scala ou Kotlin). Cette fonctionnalité a un aspect extrêmement pratique dans les sur les patterns : ils permettent de créer des expressions switch avec — en plus des traditionnelles valeurs -— des expressions modèle (pattern). On retrouvera donc ce qui était à droite du instanceof dans un case de switch.
En quoi les classes scellées sont pratiques ? En ce qu'elles permettent de garantir à la compilation que les cas d’un switch sont exhaustifs avec la possibilité de l’omission du default. Et ce qui est garanti à la compilation n’est plus à tester.
Exemple de switch dont la couverture est vérifiable à la compilation
sealed interface S permits A, B, C { }
final class A implements S { }
final class B implements S { }
record C(int i) implements S { } // Implicitly final
...
static int testSealedCoverage(S s) {
return switch (s) {
case A a -> 1;
case B b -> 2;
case C c -> 3;
};
}
Cet exemple compile sans le default, car comme on le voit les case couvrent toutes les possibilités pour une variable de type S.
Mais ceux qui connaissent Scala diront qu’il manque encore quelque chose, que nous allons étudier de plus près.
Et si pour un type d’instance A de mon exemple précédent, j’avais deux cas possibles ? Ou si je ne veux prendre en compte que des A/B/C particuliers et avoir default pour le reste ?
C’est là qu’arrivent les guards, qui donnent au pattern le nom de guarded pattern. Ce sont des conditions qui empêchent l’entrée dans le case si elle ne sont pas vérifiées.
Exemple de case avec guard
case A a && a.age > 18 -> 1;
Les guarded patterns sont en preview dans Java 17.
Le pattern matching de structures arborescentes est le second élément qui rend le pattern matching de Scala supérieur à ses concurrents. Autrement dit, je peux décomposer une instance en type + attributs, lesquels sont remplacés par des valeurs littérales ou eux-même un pattern type + attributs, etc.
Exemple de pattern matching imbriqué en Scala
case Personne(nom, Adresse(num, TypeVoie.Rue, nomVoie)), email: Email) => doSomething()
Ceci est permis par les “déconstructeurs” (méthode unapply en Scala) qui ont pour but de transformer une instance en la suite des éléments qui ont été utilisés pour la construction.
Cette fonctionnalité très puissante débarquera très bientôt dans Java.
Comme en Scala, cela permettra de faire des case de switch pour des cas complexes, ce qui réduira la complexité cyclomatique du code.
Un caractère joker (comme le _
dans l’exemple ci-dessus) pour raccourcir la partie qui ne nous intéresse pas pourrait aussi arriver, mais ce n’est pas encore fixé.
Autre fonctionnalité reprise à Scala visible dans l’exemple ci-dessus, pouvoir utiliser un nom spécifique pour la déconstruction, ce qui identifiera de manière plus claire le pattern.
Le cas est non plus constitué d’une simple valeur comme un nombre ou une énumération, mais une expression avec des imbrications comme Circle(Point(int x, int y), _)
.
Au début, on utilisera le nom de la méthode usine qui a servi à construire l’objet (cas des Records). Cependant, il est projeté d’avoir des déconstructeurs de classes spécifiques comme en Scala.
L’assignation par déconstruction qu’on trouve dans beaucoup de langages est prévue également, et serait possible avec le mot clé match
. Pour Java, cela évite à la fois d’appeler les getters, et à la fois de faire de la vérification de type.
On peut imaginer des définitions de valeurs par défaut à la place du throw ci-dessus.
En combinant les patterns avec un opérateur encore non défini, on peut imaginer des choses complexes comme faire matcher une Map ou un objet avec du JSON. Quand ce niveau sera atteint, Java aura un pattern matching au-dessus du Scala actuel, qui doit faire ça avec des bibliothèques spécifiques.
José Paumard nous parle aussi des Map literals (comme cela est permis dans certains langages), qui seraient ici permis par ce pattern matching combiné.
Une interrogation demeure quant à la possibilité de faire un matching bien pratique et porteur de sens d’un point de vue métier : le pattern matching d’objets n’ayant rien à voir entre eux, comme une String et un type structuré, ou deux objets métiers qui on une réelle équivalence ou une quelconque transformation possible.
Exemple : la chaîne "192.168.0.1"
pourrait alors matcher avec l’objet IPv4(192, 168, 0, 1)
.
Avec les différents ajouts que nous venons de voir, Java vise clairement à égaler le pattern matching de ses concurrents.
C’est un allègement bienvenu de la syntaxe dans ce langage !
Cependant, les améliorations vont au-delà de la syntaxe : le switch est devenu une expression, ce qui permet d’éviter le mode impératif qui était peu inutile dans ce cas et hérité du C. Par ailleurs, cette expression serait amenée à être très puissante, ce qui remet Java dans le course avec des fonctionnalités qui vont dans le bon sens.