Points Clés
- Java 16, et la version imminente de Java 17, sont livrés avec une pléthore de fonctionnalités et d'améliorations du langage qui aideront à augmenter la productivité des développeurs et les performances des applications
- L'API Stream en Java 16 fournit de nouvelles méthodes pour les opérations terminales couramment utilisées et aide à réduire la quantité du code standard
- Les Records sont une nouvelle fonctionnalité du langage en Java 16 permettant de définir de manière concise des classes de données uniquement. Le compilateur fournit des implémentations pour les constructeurs, les accesseurs et certaines des méthodes d'Object courantes
- La pattern matching est une autre nouvelle fonctionnalité de Java 16, qui, entre autres avantages, simplifie le casting par ailleurs explicite et détaillé effectué avec des blocs de code instanceof
Java 16 est sorti en mars 2021 en tant que version GA destinée à être utilisée en production, et j'ai couvert les nouvelles fonctionnalités dans ma présentation vidéo détaillée. Et Java 17, la prochaine version LTS, devrait sortir en septembre. Java 17 contiendra de nombreux perfectionnements et améliorations du langage, dont la plupart sont l'aboutissement de toutes les nouvelles fonctionnalités et modifications apportées depuis Java 11.
En ce qui concerne les nouveautés de Java 16, je vais partager une mise à jour sympa dans l'API Stream puis me concentrer principalement sur les changements dans le langage.
Un Stream vers une List
List<String> features =
Stream.of("Records", "Pattern Matching", "Sealed Classes")
.map(String::toLowerCase)
.filter(s -> s.contains(" "))
.collect(Collectors.toList());
L'extrait de code que vous voyez ci-dessus devrait vous être assez familier si vous avez l'habitude de travailler avec l'API Stream de Java .
Ce que nous avons dans le code est un Stream de quelques chaînes. Nous appliquons une fonction dessus, puis nous filtrons le Stream.
Enfin, nous matérialisons le Stream dans une List.
Comme vous pouvez le voir, nous invoquons généralement l'opération terminale collect
et lui passons un Collector.
Cette pratique assez courante d'utiliser la méthode collect
et de lui passer le Collectors.toList()
ressemble à du code boilerplate.
La bonne nouvelle est que dans Java 16, une nouvelle méthode a été ajoutée à l'API Stream qui nous permet d'appeler immédiatement toList()
en tant qu'opération terminale d'un Stream.
List<String> features =
Stream.of("Records", "Pattern Matching", "Sealed Classes")
.map(String::toLowerCase)
.filter(s -> s.contains(" "))
.toList();
L'utilisation de cette nouvelle méthode dans le code ci-dessus génère une List de chaînes du Stream contenant un espace. A noter que cette List que nous récupérons est une List non modifiable. Ce qui signifie que vous ne pouvez plus ajouter ou supprimer d'éléments de la List renvoyée par cette opération terminale. Si vous souhaitez collecter votre Stream dans une List mutable, vous devrez continuer à utiliser un Collector passé en paramètre de collect()
. Donc, cette nouvelle méthode toList()
qui est disponible dans Java 16 n'est vraiment qu'un petit délice. Et cette nouvelle mise à jour rendra, espérons-le, les pipeline de blocs de code des Stream un peu plus faciles à lire.
Une autre mise à jour de l'API Stream est la méthode mapMulti()
. Son objectif est un peu similaire à la méthode flatMap()
. Si vous travaillez généralement avec flatMap()
et que vous mappez vers des flux internes dans la lambda que vous lui transmettez, mapMulti()
vous offre une autre façon de le faire, où vous poussez des éléments vers un Consommer. Je n'entrerai pas dans les détails de cette méthode dans cet article car j'aimerais discuter des nouvelles fonctionnalités du langage dans Java 16. Si vous souhaitez en savoir plus sur mapMulti()
, je recommande de consulter la Documentation Java pour cette méthode.
Les Records
La première grande fonctionnalité du langage fournie dans Java 16 s'appelle les Records. Les Records permettent de représenter des données sous forme de données dans du code Java plutôt que sous forme de classes arbitraires. Avant Java 16, lorsque nous avions simplement besoin de représenter certaines données, nous nous retrouvions avec une classe arbitraire comme celle indiquée dans l'extrait de code ci-dessous.
public class Product {
private String name;
private String vendor;
private int price;
private boolean inStock;
}
Ici, nous avons une classe Product
qui a quatre membres. Cela devrait être toutes les informations dont nous avons besoin pour définir cette classe. Bien sûr, nous avons besoin de beaucoup plus de code pour que cela fonctionne. Par exemple, nous avons besoin d'un constructeur. Nous avons besoin de méthodes getter correspondantes pour obtenir les valeurs des membres. Pour le compléter, nous devons également avoir des implémentations d'equals()
, hashCode()
et toString()
qui sont congruentes avec les membres que nous avons défini. Une partie de ce code boilerplate peut être généré par un IDE, mais cela présente certains inconvénients. Vous pouvez également utiliser des frameworks comme Lombok, mais ils présentent également certains inconvénients.
Ce dont nous avons vraiment besoin, c'est d'un mécanisme dans le langage Java pour décrire plus précisément ce concept d'avoir des classes de données uniquement. Et donc en Java 16, nous avons le concept de Records. Dans l'extrait de code suivant, nous avons redéfini la classe Product
en tant que record.
public record Product(
String name,
String vendor,
int price,
boolean inStock) {
}
Notez l'introduction du nouveau mot clé record
. Nous devons spécifier le nom du type record juste après le mot-clé record
. Dans notre exemple, le nom est Product
. Et puis nous n'avons qu'à fournir les composants qui composent ces Records. Ici, nous avons fourni les quatre composants en donnant leurs types et les noms. Et puis nous avons terminé. Un Record en Java est une forme spéciale d'une classe qui ne contient que ces données.
Que nous offre un record ? Une fois que nous avons une déclaration d'un record, nous obtenons une classe qui a un constructeur implicite acceptant toutes les valeurs des composants du Record. Nous obtenons automatiquement des implémentations pour les méthodes equals()
, hashCode()
et toString()
basées sur tous les composants du record. De plus, nous obtenons également des méthodes d'accès pour chaque composant que nous avons dans les Records. Dans notre exemple ci-dessus, nous obtenons une méthode name
, une méthode vendor
, une méthode price
et une méthode inStock
qui renvoient respectivement les valeurs réelles des composants des records.
Les records sont toujours immuables. Il n'y a pas de méthodes de type setter. Une fois qu'un record est instancié avec certaines valeurs, vous ne pouvez plus le modifier. De plus, les classes records sont finales. Vous pouvez implémenter une interface avec un record, mais vous ne pouvez étendre d'aucune autre classe lors de la définition d'un record. Dans l'ensemble, il y a quelques restrictions ici. Mais les records nous offrent un moyen très puissant de définir de manière concise des classes de données uniquement dans nos applications.
Comment penser aux records
Comment devez-vous penser et aborder ces nouveaux éléments du langage ? Un record est une forme nouvelle et restreinte d'une classe utilisée pour modéliser des données en tant que données. Il n'est pas possible d'ajouter un état supplémentaire à un record, vous ne pouvez pas définir de champs (non statiques) en plus des composants d'un record. Les recordss concernent vraiment la modélisation de données immuables. Vous pouvez également considérer les records comme des tuples, mais pas seulement des tuples dans un sens générique que certains autres langages ont où vous avez des composants arbitraires qui peuvent être référencés par index. En Java, les éléments de tuple ont des noms réels, et le type de tuple lui-même, le record, a également un nom, car les noms ont de l'importance en Java.
Comment ne pas penser aux records
Il y a aussi certaines façons dont nous pouvons être tentés de penser aux records qui ne sont pas tout à fait appropriées. Tout d'abord, ils ne sont pas conçus comme un mécanisme de réduction de code boilerplate pour vos codes existants. Bien que nous ayons maintenant une manière très concise de définir ces records, cela ne signifie pas que des données telles que la classe de votre application peuvent être facilement remplacées par des records, principalement en raison des limitations imposées par les records. Ce n'est pas non plus vraiment le but de la conception.
L'objectif de conception des records est d'avoir un bon moyen de modéliser les données en tant que données. Ce n'est pas non plus un remplacement direct des JavaBeans, car comme je l'ai mentionné plus tôt, les méthodes d'accès, par exemple, ne respectent pas les normes des getter des JavaBeans. Et les JavaBeans sont généralement mutables, alors que les records sont immuables. Même s'ils servent un objectif quelque peu similaire, les records ne remplacent pas les JavaBeans de manière significative. Vous ne devez pas non plus considérer les records comme des types de valeur (Value types).
Les types de valeur peuvent être fournis en tant qu'amélioration du langage dans une future version de Java où les types de valeur concernent essentiellement la disposition de la mémoire et la représentation efficace des données dans les classes. Bien sûr, ces deux mondes peuvent se rencontrer à un moment donné, mais pour l'instant, les records ne sont qu'un moyen plus concis d'exprimer des classes de données uniquement.
En savoir plus sur les records
Considérons le code suivant où nous créons des records p1
et p2
de type Product
avec exactement les mêmes valeurs.
Product p1 = new Product("peanut butter", "my-vendor", 20, true);
Product p2 = new Product("peanut butter", "my-vendor", 20, true);
On peut comparer ces records par égalité de références et on peut aussi les comparer à l'aide de la méthode equals()
, celle qui a été fournie automatiquement par l'implémentation du record.
System.out.println(p1 == p2); // Prints false
System.out.println(p1.equals(p2)); // Prints true
Ce que vous verrez ici, c'est que ces deux records sont deux instances différentes, donc la comparaison de référence sera évaluée à false. Mais lorsque nous utilisons equals()
, il ne regarde que les valeurs de ces deux records et il sera évalué à true. Parce qu'il ne s'agit que des données qui se trouvent à l'intérieur du record. Pour réitérer, les implémentations d'égalité et de hashcode sont entièrement basées sur les valeurs que nous fournissons au constructeur pour un record.
Une chose à noter est que vous pouvez toujours remplacer l'une des méthodes d'accès, ou les implémentations d'égalité et de hashcode, à l'intérieur d'une définition d'un record. Cependant, il vous appartiendra de préserver la sémantique de ces méthodes dans le cadre d'un record. Et vous pouvez ajouter des méthodes supplémentaires à une définition d'un record. Vous pouvez également accéder aux valeurs d'un record dans ces nouvelles méthodes.
Une autre fonction importante que vous voudrez peut-être utiliser dans un record est la validation. Par exemple, vous souhaitez uniquement créer un record si l'entrée fournie au constructeur du record est valide. La méthode traditionnelle de validation consisterait à définir un constructeur avec des arguments d'entrée qui sont validés avant d'affecter les arguments aux variables membres. Mais avec les records, nous pouvons utiliser un nouveau format, le constructeur dit compact. Dans ce format, nous pouvons laisser de côté les arguments du constructeur formel. Le constructeur aura implicitement accès aux valeurs des composants. Dans notre exemple Product
, nous pouvons dire que si le prix est inférieur à zéro, nous lançons une nouvelle IllegalArgumentException
.
public record Product(
String name,
String vendor,
int price,
boolean inStock) {
public Product {
if (price < 0) {
throw new IllegalArgumentException();
}
}
}
Comme vous pouvez le voir dans l'extrait de code ci-dessus, si le prix est supérieur à zéro, nous n'avons pas à effectuer de tâches manuellement. Les affectations des paramètres (implicites) du constructeur aux champs du record sont ajoutées automatiquement par le compilateur lors de la compilation de ce record.
Nous pouvons même faire la normalisation si nous le voulons. Par exemple, au lieu de lever une exception si le prix est inférieur à zéro, nous pouvons définir le paramètre de prix, qui est implicitement disponible, sur une valeur par défaut.
public Product {
if (price < 0) {
price = 100;
}
}
Encore une fois, les affectations aux membres réels du records, les champs finaux qui font partie de la définition du record, sont insérées automatiquement par le compilateur à la fin de ce constructeur compact. Dans l'ensemble, un moyen très polyvalent et très agréable de définir en Java des classes de données uniquement.
Vous pouvez également déclarer et définir des records localement dans les méthodes. Cela peut être très pratique si vous avez un état intermédiaire que vous voulez utiliser à l'intérieur de votre méthode. Par exemple, disons que nous voulons définir un produit à prix réduit. Nous pouvons définir un record qui prend un Product
et un boolean
qui indique si le produit est à prix réduit ou non.
public static void main(String... args) {
Product p1 = new Product("peanut butter", "my-vendor", 100, true);
record DiscountedProduct(Product product, boolean discounted) {}
System.out.println(new DiscountedProduct(p1, true));
}
Comme vous pouvez le voir dans l'extrait de code ci-dessus, nous n'aurons pas à fournir un corps pour la nouvelle définition du record. Et nous pouvons instancier le DiscountedProduct
avec p1
et true
comme arguments. Si vous exécutez le code, vous verrez que cela se comporte exactement de la même manière que les records de premier niveau dans un fichier source. Les records en tant que construction locale peuvent être très utiles dans les situations où vous voulez regrouper certaines données dans une étape intermédiaire du pipeline d'un Stream.
Où utiliseriez-vous des records
Il y a des endroits évidents où les records peuvent être utilisés. L'un de ces endroits est lorsque nous voulons utiliser des objets de transfert de données (DTO). Les DTO sont par définition des objets qui n'ont pas besoin d'identité ou de comportement. Ils servent uniquement à transférer des données. Par exemple, à partir de la version 2.12, la bibliothèque Jackson prend en charge la sérialisation et la désérialisation des records en JSON et dans d'autres formats supportés.
Les records seront également très utiles lorsque vous souhaitez que les clés d'une map soient composées de plusieurs valeurs qui agissent comme une clé composite. L'utilisation des recordss dans ce scénario sera très utile puisque vous obtenez automatiquement le comportement correct pour les implémentations d'equals et de hashcode. Et puisque les records peuvent également être considérés comme des tuples nominaux, un tuple où chaque composant a un nom, vous pouvez facilement voir qu'il sera très pratique d'utiliser records pour retourner plusieurs valeurs d'une méthode à l'appelant.
D'un autre côté, je pense que les records ne seront pas beaucoup utilisés lorsqu'il s'agira de l'API Java Persistence. Si vous voulez utiliser des records pour représenter des entités, ce n'est pas vraiment possible parce que les entités sont fortement basées sur la convention JavaBeans. Et les entités ont généralement tendance à être mutables plutôt qu'immuables. Bien sûr, il pourrait y avoir quelques opportunités lorsque vous instanciez des objets en vue de lecture seule dans des requêtes où vous pourriez utiliser des records au lieu de classes ordinaires.
Tout compte fait, je pense que c'est un développement très excitant que nous ayons maintenant des records en Java. Je pense qu'ils verront une utilisation répandue.
Pattern matching avec instanceof
Cela nous amène au deuxième changement du langage dans Java 16, et c'est le pattern matching avec instanceof
. C'est une première étape dans un long voyage pour apporter le pattern matching à Java. Pour l'instant, je pense que c'est déjà très bien que nous ayons le support initial dans Java 16. Jetez un coup d'œil à l'extrait de code suivant.
if (o instanceOf String) {
String s = (String) o;
return s.length();
}
Vous reconnaîtrez probablement ce schéma où un bout de code vérifie si un objet est une instance d'un type, dans ce cas la classe String
. Si la vérification est réussie, nous devons déclarer une nouvelle variable locale, effectuer un cast et affecter la valeur, et seulement alors nous pouvons commencer à utiliser la variable typée. Dans notre exemple, nous devons déclarer la variable s
, cast o
en un String
et ensuite appeler la méthode length()
. Bien que cela fonctionne, c'est verbeux, et ce n'est pas vraiment un code révélateur de l'intention. Nous pouvons faire mieux.
A partir de Java 16, nous pouvons utiliser la nouvelle fonctionnalité de pattern matching. Avec le pattern matching, au lieu de dire o
est une instance d'un type spécifique, nous pouvons faire correspondre o
à un pattern de type. Un motif de type se compose d'un type et d'une variable de liaison. Voyons un exemple.
if (o instanceOf String s) {
return s.length();
}
Ce qui se passe dans le bout de code ci-dessus, c'est que si o
est bien une instance de String
, alors String s
sera immédiatement liée à la valeur de o
. Cela signifie que nous pouvons immédiatement commencer à utiliser s
comme une chaîne de caractères sans un cast explicite à l'intérieur du corps du if
. L'autre chose intéressante ici est que la portée de s
est limitée au corps de if
. Une chose à noter ici est que le type de o
dans le code source ne doit pas être un sous-type de String
, car si c'est le cas, la condition sera toujours vraie. Et donc, en général, si le compilateur détecte que le type d'un objet qui est testé est un sous-type du type du motif, il lancera une erreur de compilation.
Une autre chose intéressante à souligner est que le compilateur est assez intelligent pour déduire la portée de s
selon que la condition est évaluée à true ou false comme vous le verrez dans l'extrait de code suivant.
if (!(o instanceOf String s)) {
return 0;
} else {
return s.length();
}
Le compilateur voit que si la correspondance du pattern ne réussit pas, alors dans la branche else
, nous aurions s
dans le scope avec le type String
. Et dans la branche if
s
ne sera pas dans la portée, nous aurons seulement o
dans la portée. Ce mécanisme est appelé flow scoping où la variable du motif de type n'est dans la portée que si le pattern correspond effectivement. C'est vraiment pratique. Il aide vraiment à resserrer ce code. C'est quelque chose dont vous devez être conscient et qui pourrait prendre un peu de temps pour s'y habituer.
Un autre exemple où vous pouvez très joliment voir ce typage de traitement en action est lorsque vous réécrivez l'implémentation du code suivant de la méthode equals()
. L'implémentation courante consiste à vérifier d'abord si o
est une instance de MyClass
. Si c'est le cas, nous castons o
en MyClass
et faisons ensuite correspondre le nom de champ de o
avec l'instance actuelle de MyClass
.
@Override
public boolean equals(Object o) {
return (o instanceOf MyClass) &&
((MyClass) o).name.equals(name);
}
Nous pouvons simplifier la mise en œuvre en utilisant le nouveau mécanisme de pattern matching, comme le démontre l'extrait de code suivant.
@Override
public boolean equals(Object o) {
return (o instanceOf MyClass m) &&
m.name.equals(name);
}
A nouveau, une belle simplification du casting explicite et verbeux dans le code. Le pattern matching abstrait beaucoup de code boilerplate lorsqu'elle est utilisée dans les cas d'utilisation appropriés.
Le futur du pattern matching
L'équipe Java a esquissé certaines des orientations futures du pattern matching. Bien sûr, il n'y a aucune promesse sur quand ou comment ces futures directions se retrouveront effectivement dans le langage officiel. Dans l'extrait de code suivant, nous allons voir que dans la nouvelle expression switch, nous pouvons utiliser des patterns de type avec instanceOf
comme nous l'avons discuté précédemment.
static String format(Object o) {
return switch(o) {
case Integer i -> String.format("int %d", i);
case Double d -> String.format("int %f", d);
default -> o.toString();
};
}
Dans le cas où o
est un entier, le flow scoping entre en jeu et nous avons la variable i
immédiatement disponible pour être utilisée comme un entier. Il en va de même pour les autres cas et la branche par défaut.
Une autre direction nouvelle et passionnante est les patterns de record où nous pourrions être en mesure de faire correspondre nos records à des patterns et de lier immédiatement les valeurs des composants à des variables fraîches. Jetez un coup d'œil à l'extrait de code suivant.
if (o instanceOf Point(int x, int y)) {
System.out.println(x + y);
}
Nous avons un record Point
avec x
et y
. Si l'objet o
est bien un Point, nous allons immédiatement lier les composants x
et y
aux variables x
et y
et commencer immédiatement à les utiliser.
Les patterns de tableau sont un autre type de pattern matching que nous pourrions obtenir dans une future version de Java. Jetez un coup d'œil à l'extrait de code suivant.
if (o instanceOf String[] {String s1, String s2, ...}) {
System.out.println(s1 + s2);
}
Si o
est un tableau de chaînes de caractères, vous pouvez immédiatement extraire la première et la deuxième partie du tableau de chaînes de caractères en s1
et s2
. Bien sûr, cela ne fonctionne que s'il y a réellement deux éléments ou plus dans le tableau de chaînes. Et nous pouvons simplement ignorer le reste des éléments du tableau en utilisant la notation à trois points.
Pour résumer, le pattern matching avec instanceOf
n'est qu'une petite fonctionnalité sympathique, mais c'est un petit pas vers ce nouvel avenir où nous pourrions avoir des types de patterns supplémentaires qui peuvent être utilisés pour écrire un code propre, simple et lisible.
Fonctionnalité en preview : classe scellée
Parlons de la fonctionnalité des classes scellées. Notez que c'est une fonctionnalité en preview dans Java 16, bien qu'elle sera finale dans Java 17. Vous devez passer l'option --enable-preview
lors de l'invocation du compilateur et à l'invocation de la JVM afin d'utiliser cette fonctionnalité avec Java 16. Cette fonctionnalité vous permet de contrôler votre hiérarchie d'héritage.
Disons que vous voulez modéliser un super type Option
où vous voulez seulement avoir Some
et Empty
comme sous-types. Et vous voulez empêcher les extensions arbitraires de votre type Option
. Par exemple, vous ne voulez pas autoriser un type Maybe
dans la hiérarchie.
Vous avez donc essentiellement un aperçu exhaustif de tous les sous-types de votre type Option
. Comme vous le savez, le seul outil pour contrôler l'héritage en Java à l'heure actuelle est via le mot-clé final
. Cela signifie qu'il ne peut pas y avoir de sous-classes du tout. Mais ce n'est pas ce que nous voulons. Il y a quelques solutions de contournement pour pouvoir modéliser cette fonctionnalité sans classes scellées, mais en utilisant les classes scellées, cela devient beaucoup plus facile.
La fonctionnalité des classes scellées s'accompagne de nouveaux mots-clés sealed
et permits
. Jetez un coup d'œil à l'extrait de code suivant.
public sealed class Option<T>
permits Some, Empty {
...
}
public final class Some
extends Option<String> {
...
}
public final class Empty
extends Option<Void> {
...
}
Nous pouvons définir la classe Option
comme étant scellée
. Puis, après la déclaration de la classe, nous utilisons le mot clé permits
pour indiquer que seules les classes Some
et Empty
sont autorisées à étendre la classe Option
. Ensuite, nous pouvons définir Some
et Empty
comme des classes comme d'habitude. Nous voulons rendre ces sous-classes finales
car nous voulons empêcher tout héritage ultérieur. Aucune autre classe ne peut maintenant être compilée pour étendre la classe Option
. Ceci est appliqué par le compilateur à travers le mécanisme des classes scellées.
Il y a beaucoup plus à dire sur cette fonctionnalité qui ne peut être couvert dans cet article. Si vous souhaitez en savoir plus, je vous recommande de vous rendre sur la page sealed classes Java Enhancement Proposal, JEP 360, pour en savoir plus.
Et plus encore
Il y a beaucoup d'autres choses dans Java 16 que nous n'avons pas pu couvrir dans cet article. Par exemple, les API incubator comme l'API Vector, l'API Foreign Linker et l'API Foreign-Memory Access sont toutes très prometteuses. Et de nombreuses améliorations qui ont été apportées au niveau de la JVM. Par exemple, ZGC a bénéficié de quelques améliorations de performances. Des améliorations comme Elastic Metaspace ont été apportées dans la JVM. Et puis il y a un nouvel outil de packaging pour les applications Java qui vous permet de créer des installateurs natifs pour Windows, Mac et Linux. Enfin, et je pense que cela aura un impact très important, les types encapsulés dans le JDK seront fortement gardés lorsque vous exécutez votre application à partir du classpath
.
Je vous encourage vivement à vous pencher sur toutes ces nouvelles fonctionnalités et améliorations du langage, car certaines d'entre elles peuvent avoir un impact important sur vos applications.
A propos de l'auteur
Sander Mak est un Java Champion qui est actif dans la communauté Java depuis plus de dix ans. Actuellement, il est directeur technique chez Picnic. Parallèlement, Mak est également très actif en termes de partage des connaissances, par le biais de conférences mais aussi sur des plateformes d'e-learning.