Développons en Java 2.30 | |
Copyright (C) 1999-2022 Jean-Michel DOUDOUX | (date de publication : 15/06/2022) |
|
Niveau : | Elémentaire |
La définition d'une classe qui encapsule des données immuables est très verbeuse : constructeur, getters, redéfinition des méthodes equals(), hashCode(), toString(), ... Pour contourner cela, les IDE peuvent générer une bonne partie de ce code ou il est possible d'utiliser une solution comme Lombok qui repose sur le traitement d'annotations pour enrichir le bytecode.
Les records sont un nouveau type de classe dans le langage Java (record class), introduit en standard en Java 16, qui proposent une syntaxe compacte pour la déclaration de classes aux fonctionnalités restreintes qui agrègent des valeurs de manière immuable.
D'autres langages de programmation orientée objet proposent une syntaxe pour ce type de fonctionnalité : case classes en Scala, data classes en Kotlin, record classes en C#, ...
Les records ont plusieurs buts :
La syntaxe des records est très concise mais ils ne sont pas aussi souples que les classes. Un record possède un nom, une description des éléments qu'il encapsule et un corps qui peut être vide, exprimé alors avec une paire d'accolades vides.
Exemple ( code Java 14 ) : |
public record Personne(String nom, String prenom) {}
|
Un record est une classe qui possède des caractéristiques particulières :
Comme une enum, un record est une classe générée par le compilateur qui possède des contraintes. Les records possèdent des restrictions, notamment :
Il est possible d'utiliser des types génériques, d'implémenter des interfaces et d'utiliser des annotations.
Le corps du record peut permettre de définir
Ce chapitre contient plusieurs sections :
Un record est basiquement une classe de données dont le but est d'encapsuler des données de manière immuable. Les records permettent de mettre en oeuvre des classes de données, sans avoir à écrire de code verbeux. Les classes de données simples sont réduites de nombreuses lignes de code à potentiellement une seule pour les cas les plus simples.
Les records proposent une nouvelle syntaxe dans le langage Java pour faciliter la déclaration d'une classe qui encapsule des données immuables. Le grand avantage est de réduire la quantité de code boilerplate (constructeur, getters, redéfinition des méthodes héritées d'Object (equals(), hashCode() et toString()).
Les records sont conçus pour être simples : un record a un nom et une description d'état, qui définit les composants du record. Un record permet ainsi d'exprimer clairement l'intention du type de servir de conteneur de données.
Les records sont un nouveau type qui est une forme restreinte de classe de la même manière qu'une énumération.
Le compilateur Java va générer, à partir de la déclaration d'une record, un constructeur, des champs privé finaux, des accesseurs en lecture et les méthodes hashCode(), equals() et toString().
Toutes ces fonctionnalités permettent de garantir que l'état d'un record soit immuable : tous les champs sont final et aucun setter n'est proposé.
Via la JEP 350, Java 14 introduit en mode preview un nouveau type dans le langage : les records. Son but est de proposer une syntaxe compacte pour la déclaration de classes qui encapsulent des données immuables.
La JEP 384 incluse dans Java 15, une seconde preview est proposée. Elle intègre des améliorations au langage issues du feedback de la première preview :
Elle introduit dans le langage la possibilité d'utiliser des interfaces et des énumérations locales ainsi que des records locaux.
Les records deviennent une fonctionnalité standard dans Java 16 via la JEP 395. Une seule modification est apportée aux records par rapport à la seconde preview du JDK 15 : assouplir la restriction historique selon laquelle une classe interne ne peut pas déclarer un membre qui est explicitement ou implicitement statique. Il est donc maintenant possible de permettre à une classe interne de déclarer un membre qui soit un record.
Il est fréquent de déclarer des classes en Java qui ne contiennent que des données. C'est tellement courant que des patterns sont définis pour ce besoin tels que Value Object (VO) ou Data Transfert Object (DTO).
Java est un langage orienté objet : pour encapsuler des données, il faut écrire une classe et le code nécessaire pour permettre l'accès en lecture et/ou en écriture des données.
C'est une grande force mais pour une simple classe qui encapsule des données, la quantité de code à écrire est importante. La verbosité de Java est d'ailleurs fréquemment mise en avant.
Notamment en Java pour créer une classe qui encapsule des données, il est nécessaire d'écrire beaucoup de code à faible valeur ajoutée, répétitif et sujet aux erreurs : constructeurs, accesseurs et la redéfinition de certaines méthodes héritées de la classe Object (equals(), hashCode() et toString()).
Les IDE proposent des fonctionnalités pour générer une bonne partie de ce code mais ce code doit tout de même être lu pour comprendre son rôle.
Par exemple, une classe immuable Employe qui encapsule deux propriétés nom et prénom.
Exemple : |
package fr.jmdoudoux.dej.records;
public final class Employe {
private final String nom;
private final String prenom;
public Employe(String nom, String prenom) {
super();
this.nom = nom;
this.prenom = prenom;
}
public String getNom() {
return nom;
}
public String getPrenom() {
return prenom;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((nom == null) ? 0 : nom.hashCode());
result = prime * result + ((prenom == null) ? 0 : prenom.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Employe other = (Employe) obj;
if (nom == null) {
if (other.nom != null)
return false;
} else if (!nom.equals(other.nom))
return false;
if (prenom == null) {
if (other.prenom != null)
return false;
} else if (!prenom.equals(other.prenom))
return false;
return true;
}
@Override
public String toString() {
return "Employe [nom=" + nom + ", prenom=" + prenom + "]";
}
}
|
La classe requière beaucoup de lignes de code (57) pour cette simple classe qui encapsule deux champs de manière immuables :
Hormis la ligne de déclaration de la classe et de ces champs, toutes les autres lignes sont générées par l'IDE. Mais cela fait beaucoup de code à lire, même partiellement, pour comprendre le rôle de la classe.
La maintenance de ce code peut aussi introduire des problèmes : par exemple l'oubli de tenir compte dans les méthodes equals() et hashCode() lors de l'ajout d'un champs dont elles devraient tenir compte.
Il existe aussi des bibliothèques qui propose d'enrichir le bytecode avec tout ou partie des fonctionnalités requises. La plus connue et utilisée est Lombok.
Un record est un nouveau type du langage Java qui permet au travers d'une syntaxe très simplifiée de définir une classe qui encapsule des données de manière immuable.
A première vue les records pourraient être considérés comme une solution pour réduire la quantité de code nécessaire pour créer des classes qui encapsulent des données. Comme le précise la JEP 359, les records ont aussi un objectif sémantique pour modéliser les données en tant que données. Les records offrent en réalité une solution plus sémantique : encapsuler des données de manière facile et concise en déclarant des classes qui encapsulent des données de manière immuable et fournissent des implémentations des méthodes relatives aux données.
L'utilisation des records facilite aussi la compréhension rapide du rôle du type défini. Même si une large partie du code d'une classe de données en Java peut être généré par un IDE, le développeur doit lire, même rapidement une bonne partie de ce code pour déterminer son rôle à la première lecture.
Les records permettent aussi d'ajouter une sémantique qui indique clairement que la classe encapsule des données de manière immuable. Ce type de classe est connue sous le nom de value object en Domain-Driven Design.
La sémantique des records se retrouvent dans certains autres langages comme les data classes de Kotlin ou les records de C# dont la sémantique est très proche de celle des records de Java.
Plusieurs cas d'usage des records peuvent être trouvés, par exemple :
Un record est une classe qui encapsule des données de manière immuable. Le but des records est de proposer un moyen syntaxiquement simple de créer une telle classe.
Basiquement la définition d'un record tient en une seule ligne.
L'exemple ci-dessous définit un record qui encapsule deux données : un nom et un prénom de manière immuable.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public record Employe(String nom, String prenom) {
}
|
Il faut compiler le code avec le compilateur Java sans oublier de préciser les options nécessaires à l'utilisation de fonctionnalité en mode preview en Java 14 et 15.
Exemple ( code Java 16 ) : |
C:\java\src\fr\jmdoudoux\dej\records>javac Employe.java
|
Le compilateur va générer une classe immuable sur la base des informations fournies dans la définition du record contenant :
Résultat : |
C:\java\src\fr\jmdoudoux\dej\records>javap Employe.class
Compiled from "Employe.java"
public final class fr.jmdoudoux.dej.records.Employe extends java.lang.Record {
public fr.jmdoudoux.dej.records.Employe(java.lang.String, java.lang.String);
public java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public java.lang.String nom();
public java.lang.String prenom();
}
|
Un record hérite de la classe java.lang.Record.
Les accesseurs en lecture seule sont des méthodes dont le nom correspond au nom du membre du record. Le nom des getters ne respecte pas la convention JavaBean qui recommande de préfixer ces méthodes par get ou is (pour les valeurs booléennes). Le nom des getters correspond au nom du champ.
La redéfinition de la méthode equals() considère deux records comme égaux s'ils sont du même type et si tous les champs ont la même valeur.
La redéfinition de la méthode toString() retourne une chaîne de caractères qui contient le nom du record suivi d'une paire de crochet qui contient les paires nom du champs = valeur séparées par une virgule.
Les méthodes sont par défaut générées par le compilateur : il est possible de fournir une redéfinition personnalisée au besoin.
Il est possible d'ajouter des membres (champs, méthodes et constructeurs) sous certaines conditions et restrictions.
L'implémentation des records dans le langage est similaire à celui des enums. Une enum est aussi une classe est une sémantique spécifique et une syntaxe plus concise. Les records sont comme les enums des formes limitées de classes.
Comme les records sont des classes, la plupart des fonctionnalités des classes sont conservées
La possibilité de redéfinir les méthodes générées ou d'ajouter certains membres offre un bon compromis entre simplicité et flexibilité.
Les records proposent une syntaxe concise qui ne permet que de déclarer que les informations importantes : un nom et les éléments qu'il encapsule.
La définition d'un record comporte plusieurs parties :
La déclaration minimale est composée de l'identifiant restreint record suivi du nom du record, d'une paire de parenthèses et d'une paire d'accolades.
Exemple ( code Java 14 ) : |
record Employe() {
}
|
Un record est implicitement final : il est cependant possible d'utiliser explicitement le modificateur final.
Un record hérite de la classe java.lang.Record : il n'est donc pas possible d'utiliser la clause extends dans la définition d'un record.
Il ne peut donc pas être abstrait : il ne peut pas avoir le modificateur abstract.
Ce record minimaliste peut déjà être instancié. Ces méthodes toString(), equals() et hashCode() sont redéfinies par le compilateur.
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp = new Employe();
System.out.println(emp);
System.out.println(emp.hashCode());
}
|
Résultat : |
Employe[]
0
|
Il est possible de tester l'égalité sur deux instances.
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp1 = new Employe();
var emp2 = new Employe();
System.out.println(emp1==emp2);
System.out.println(emp1.equals(emp2));
}
|
Résultat : |
false
true
|
Les deux instances obtenues sont différentes. Par contre, elles sont égales car les données encapsulées sont les mêmes (aucune pour le moment).
« record » est un identifiant restreint (restricted identifier) comme var mais n'est pas un mot clé réservé.
« record » a un rôle particulier lors de la définition d'un type.
Il est cependant tout à fait licite de définir une variable ou une méthode dont le nom est record.
Exemple : |
public int record() {
int record = 0;
return record;
}
|
Les composants d'un record sont précisés dans l'en-tête de la déclaration d'un record définie entre la paire de parenthèses. Chaque composant d'un record est constitué d'un type (éventuellement précédé d'une ou plusieurs annotations) et d'un identifiant qui spécifie le nom du composant. Un composant d'un record générera par le compilateur deux membres de la classe du record : un champ privé déclaré implicitement, et une méthode d'accès publique déclarée explicitement ou implicitement dont le nom est celui du composant.
Exemple ( code Java 14 ) : |
record Employe(String nom) {
} |
Il est bien sûr possible d'ajouter plusieurs composants, chacun séparé par une virgule.
Exemple ( code Java 14 ) : |
record Employe(String nom, String prenom) {
}
|
Il n'est pas possible de déclarer deux composants avec le même nom.
Exemple ( code Java 16 ) : |
public record Employe(String nom, String nom) {
}
|
Résultat : |
C:\java>javac Employe.java
Employe.java:1: error: record component nom is already defined in record Employe
public record Employe(String nom, String nom) {
^
Employe.java:1: error: method nom() is already defined in record Employe
public record Employe(String nom, String nom) {
^
2 errors
|
Il n'est pas possible d'utiliser les noms clone, finalize, getClass, hashCode, notify, notifyAll, toString et wait dans les composants d'un record. Ce sont les noms des méthodes publiques et protégées sans argument dans Object. Le fait de les interdire en tant que noms de composants d'un record évite plusieurs confusions :
Exemple ( code Java 16 ) : |
public record Employe(String clone, String notify) {
}
|
Résultat : |
C:\java>javac Employe.java
Employe.java:1: error: illegal record component name clone
public record Employe(String clone, String notify) {
^
Employe.java:1: error: illegal record component name notify
public record Employe(String clone, String notify) {
^
2 errors
|
Un seul composant d'un record peut être un varargs et dans ce cas doit être le dernier composant.
Si un record ne possède pas de composants, sa définition doit contenir une paire de parenthèses vides.
Les méthodes toString(), equals() et hashCode() sont redéfinies par le compilateur en prenant en compte les composants du record.
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp = new Employe("nom1", "prenom1");
System.out.println(emp);
System.out.println(emp.hashCode());
var emp2 = new Employe("nom1", "prenom1");
System.out.println(emp.equals(emp2));
}
|
Résultat : |
Employe[nom=nom1, prenom=prenom1]
-213416509
true
|
Le compilateur ajoute :
Un record peut implémenter une ou plusieurs interfaces.
Exemple : |
public interface EtatCivil {
String getNomPrenom();
}
|
Exemple ( code Java 14 ) : |
public record Employe(String nom, String prenom) implements EtatCivil {
@Override
public String getNomPrenom() {
return nom + " " + prenom;
}
}
|
Il est possible d'utiliser des annotations sur un record sous réserve que leur target soit correctement défini pour cela :
Exemple ( code Java 14 ) : |
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.RECORD_COMPONENT, ElementType.TYPE})
public @interface MonAnnotation {
}
|
Il est alors possible d'utiliser l'annotation.
Exemple ( code Java 14 ) : |
@MonAnnotation
public record Employe(@MonAnnotation String nom, String prenom) {
}
|
Les annotations sur une déclaration de composant de record sont disponibles par réflexion si leurs interfaces d'annotation sont applicables sur un composant de record. Indépendamment, les annotations sur une déclaration de composant de record sont propagées aux déclarations des membres et des constructeurs de la classe du record si leurs interfaces d'annotation sont applicables sur ces membres.
Les annotations sont utilisables sur les composants d'un record si elles sont applicables aux composants de records, aux paramètres, aux champs ou aux méthodes. Les annotations qui sont applicables à l'une de ces cibles sont propagées aux membres générés (champs, paramètre du constructeur, méthodes).
Les composants d'un record ont plusieurs utilités dans la déclaration d'un record. Chaque composant sera utilisé par le compilateur pour générer un champ du même nom et du même type, à un accesseur du même nom et du même type de retour, et à un paramètre du constructeur du même nom et du même type.
Les annotations sur les composants sont propagées aux éléments générés par le compilateur en fonction des éléments précisés dans la méta-annotation @Target. Lorsqu'une annotation est utilisée sur un composant d'un record, celle est appliquée sur chacun de ses éléments selon la cible d'utilisation de l'annotation définit avec l'annotation @Target (le type sur le composant du record, le type sur le champ correspondant, le type de retour de l'accesseur correspondant, le type du paramètre correspondant du constructeur canonique).
Les règles de propagation de l'annotation utilisée sur un composant d'un record sont :
Cela permet aux classes qui utilisent des annotations sur leurs champs, paramètres de constructeur ou méthodes d'accesseur d'être migrées vers des records sans avoir à déclarer ces membres de manière redondante.
Si un accesseur public ou un constructeur canonique (non compact) est déclaré explicitement, il ne possède que les annotations qui sont directement définies sur lui : rien n'est propagé du composant du record correspondant à ces membres.
Un record peut être typé avec des génériques.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
import java.util.List;
public record Employe<T>(String nom, String prenom, List<T> competences) {
}
|
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
import java.util.List;
public class Main {
public static void main(String[] args) throws Exception {
var emp = new Employe<String>("nom1", "prenom1", List.of("Analyse","Développement"));
System.out.println(emp);
}
}
|
Résultat : |
Employe[nom=nom1, prenom=prenom1, competences=[Analyse, Développement]]
|
Les records sont immuables par défaut, grâce à plusieurs caractéristiques :
Si les champs sont des objets, seuls les références sont immuables.
Exemple ( code Java 14 ) : |
public record Employe(String nom, String prenom, List<String> competences) {
}
|
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
List<String> competences = new ArrayList<>();
competences.add("Java");
var emp = new Employe("nom1", "prenom1", competences);
System.out.println(emp);
System.out.println(emp.hashCode());
competences.add("HTML");
System.out.println(emp);
System.out.println(emp.hashCode());
}
|
Résultat : |
Employe[nom=nom1, prenom=prenom1, competences=[Java]]
1976324350
Employe[nom=nom1, prenom=prenom1, competences=[Java, HTML]]
2047598599
|
Ainsi pour garantir l'immutabilité totale d'un record, il faut que toutes les instances qu'il encapsule soient elles-mêmes immuables.
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp = new Employe("nom1", "prenom1", List.of("Java"));
emp.competences().add("HTML");
}
|
Résultat : |
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:73)
at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(
ImmutableCollections.java:77)
at fr.jmdoudoux.dej.records/fr.jmdoudoux.dej.records.Main.main(Main.java:9)
|
Dans les cas où l'on utilise des types de propriétés qui sont intrinsèquement mutables, par exemple un tableau, il faut redéfinir explicitement la méthode d'accès à la propriété pour qu'il retourne une copie des objets est un moyen d'assurer l'immuabilité.
Par exemple, en créant et en retournant une copie défensive d'un tableau.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
import java.util.Arrays;
public record Employe(String nom, String prenom, String[] competences) {
@Override
public String[] competences() {
return this.competences.clone();
}
@Override
public String toString() {
return "Employe[nom=" + nom + ", prenom=" + prenom + ", competences="
+ Arrays.deepToString(competences) + "]";
}
}
|
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public class TestEmploye {
public static void main(String[] args) {
String[] competences = { "Java" };
var emp = new Employe("nom1", "prenom1", competences);
System.out.println(emp);
emp.competences()[0] = "UML";
System.out.println(emp);
}
}
|
Résultat : |
Employe[nom=nom1, prenom=prenom1, competences=[Java]]
Employe[nom=nom1, prenom=prenom1, competences=[Java]]
|
Par exemple, en enveloppant une List avec le résultat de l'invocation de la méthode Collections.unmodifiableList().
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
import java.util.Collections;
import java.util.List;
public record Employe(String nom, String prenom, List<String> competences) {
@Override
public List<String> competences() {
return Collections.unmodifiableList(this.competences);
}
}
|
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
import java.util.ArrayList;
import java.util.List;
public class TestEmploye {
public static void main(String[] args) {
List<String> competences = new ArrayList<>();
competences.add("Java");
var emp = new Employe("nom1", "prenom1", competences);
System.out.println(emp);
emp.competences().add("UML");
}
}
|
Résultat : |
Employe[nom=nom1, prenom=prenom1, competences=[Java]]
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1062)
at fr.jmdoudoux.dej.records/fr.jmdoudoux.dej.records.TestEmploye.main(TestEmploye.java:13)
|
Comme tout objet immuable, si celui-ci doit être modifié, il faut en créer une nouvelle instance avec les valeurs modifiées.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public record Employe(String nom, String prenom) {
}
|
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public class TestEmploye {
public static void main(String[] args) {
var emp = new Employe("nom1", "prenom1");
System.out.println(emp);
emp = new Employe(emp.nom(), emp.prenom() + " modifie");
System.out.println(emp);
}
}
|
Résultat : |
Employe[nom=nom1, prenom=prenom1]
Employe[nom=nom1, prenom=prenom1 modifie]
|
La définition d'un record offre certaines flexibilités : il est possible de redéfinir les méthodes implémentées par le compilateur : constructeur, accesseurs, equals(), hashCode() et toString().
Contrairement aux classes qui possède un constructeur par défaut si aucun constructeur n'est défini explicitement, les records ne possèdent par défaut qu'un constructeur dit canonique généré par le compilateur qui attend en paramètre les valeurs des différents composants du record.
Cependant, il est parfois nécessaire de personnaliser le constructeur notamment pour permettre de valider une ou plusieurs valeurs fournies pour initialiser l'état du record.
Mais fréquemment, un constructeur doit, en plus d'assigner des valeurs, faire des contrôles sur ces valeurs ou exécuter d'autres opérations d'initialisation.
Il est possible de redéfinir le constructeur en précisant tous les champs en paramètre dénommé le constructeur canonique (canonical constructor).
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public record Employe(String nom, String prenom) {
public Employe(String nom, String prenom) {
if (nom == null || nom.trim().isEmpty()) {
throw new IllegalArgumentException("Le nom est obligatoire.");
}
this.nom = nom;
this.prenom = prenom;
}
}
|
Remarque : en Java 14, le constructeur canonique doit être explicitement public. En Java 15, sa visibilité ne peut pas être inférieure à celle du record.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public class Main {
public static void main(String[] args) throws Exception {
var emp = new Employe("", "prenom1");
}
}
|
Résultat : |
Exception in thread "main" java.lang.IllegalArgumentException: Le nom est obligatoire.
at fr.jmdoudoux.dej.records/fr.jmdoudoux.dej.records.Employe.<init>(Employe.java:7)
at fr.jmdoudoux.dej.records/fr.jmdoudoux.dej.records.Main.main(Main.java:7)
|
L'ordre des paramètres du constructeur canonique doit rester l'ordre de définition des composants dans l'entête du record. Si ce n'est pas le cas, le compilateur émet une erreur.
Exemple ( code Java 14 ) : |
public record Employe(String nom, String prenom) {
public Employe(String prenom, String nom) {
if (nom == null || nom.trim()
.isEmpty()) {
throw new IllegalArgumentException("Le nom est obligatoire.");
}
this.nom = nom;
this.prenom = prenom;
}
}
|
Résultat : |
C:\java>javac Employe.java
Employe.java:3: error: invalid canonical constructor in record Employe
public Employe(String prenom, String nom) {
^
(invalid parameter names in canonical constructor)
1 error
C:\java>
|
Il est possible d'utiliser une syntaxe raccourcie pour définir des traitements du constructeur canonique nommée constructeur compact (compact constructeur). Ces traitements peuvent par exemple permettre la validation des valeurs fournies.
Cette syntaxe évite d'avoir à fournir explicitement les paramètres. Il ne faut même pas utiliser une paire d'accolade vide puisque dans ce cas cela définit le constructeur par défaut.
Un constructeur compact n'est pas un constructeur en tant que tel : il définit du code qui sera exécuté en début du constructeur canonique.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public record Employe(String nom, String prenom) {
public Employe {
if (nom == null || nom.trim().isEmpty()) {
throw new IllegalArgumentException("Le nom est obligatoire.");
}
}
}
|
Remarque : en Java 14, le constructeur compact doit aussi être explicitement public.
En Java 15, la visibilité du constructeur compact ne peut pas être inférieure à celle du record.
Il est possible de redéfinir un accesseur pour modifier son comportement par défaut ou pour ajouter une annotation.
Exemple ( code Java 14 ) : |
public record Employe(String nom, String prenom) {
public String nom() {
return nom.toUpperCase();
}
}
|
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp = new Employe("nom1", "prenom1");
System.out.println(emp);
System.out.println(emp.nom());
}
|
Résultat : |
Employe[nom=nom1, prenom=prenom1]
NOM1
|
Il n'est pas possible d'ajouter des champs d'instance à un record, ce qui est normal puisque de tels champs ne seraient pas correctement gérés pour que le record soit immuable.
Mais il est possible d'ajouter certains membres à un record :
Bien que l'on puisse ajouter des membres supplémentaires dans un record, il n'est pas recommandé d'en abuser. Les records sont conçus pour être de simples classes qui encapsulent des données et l'ajout d'un nombre important de membres est probablement le signe qu'il est préférable d'utiliser une classe standard.
L'objectif des records est de permettre aux développeurs de regrouper des données en un seul élément immuable sans avoir à écrire un code verbeux. Cela signifie que si vous êtes tenté d'ajouter d'autres champs/méthodes à un record alors il est probable qu'une classe standard aurait surement plus de sens pour répondre au besoin.
Par défaut, le compilateur ne génère qu'un seul constructeur dont les paramètres permettent de fournir les valeurs de tous les composants. Il ne génère donc pas de constructeur par défaut puisque cela irait à l'encontre de l'immutabilité d'un record.
Les records permettent de déclarer plusieurs constructeurs avec ou sans paramètres. Il est ainsi possible de définir ses propres constructeurs pour par exemple fournir des valeurs par défaut à certains composants.
Exemple ( code Java 14 ) : |
public record Employe(String nom, String prenom) {
public Employe() {
this("Inconnu","Inconnu");
}
}
|
Le record possède alors deux constructeurs : celui généré par le compilateur qui attend en paramètre le nom et le prénom et celui sans paramètre qui est définit explicitement
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp = new Employe("nom1", "prenom1");
var emp2 = new Employe();
System.out.println(emp);
System.out.println(emp2);
}
|
Résultat : |
Employe[nom=nom1, prenom=prenom1]
Employe[nom=Inconnu, prenom=Inconnu]
|
Un constructeur peut permettre de fournir les valeurs avec un nombre différents de paramètres de celui de la définition des composants du record.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public record Employe(String nom, String prenom) {
public Employe(String nomPrenom) {
this(nomPrenom.split(" ")[0], nomPrenom.split(" ")[1]);
}
}
|
Dans les constructeurs qui ne sont pas le constructeur canonique, il faut obligatoirement invoquer un autre constructeur comme première instruction. Si ce n'est pas le cas, le compilateur émet une erreur.
Exemple ( code Java 16 ) : |
public record Employe(String nom, String prenom) {
public Employe() {
this.nom = "Inconnu";
this.prenom = "Inconnu";
}
} |
Résultat : |
C:\java>javac Employe.java
Employe.java:3: error: constructor is not canonical, so its first statement must invoke
another constructor of class Employe
public Employe() {
^
1 error
|
Le constructeur invoqué n'est pas obligatoirement le constructeur canonique : cela peut être n'importe quel constructeur défini dans le record.
Exemple ( code Java 16 ) : |
public record Employe(String nom, String prenom) {
public Employe(String nom) {
this(nom,"Inconnu");
}
public Employe() {
this("Inconnu");
}
} |
Dans les constructeurs qui ne sont pas le constructeur canonique, il n'est pas possible d'assigner une valeur à un des champs puisque ceux-ci ont déjà été initialisés par le constructeur invoqué. Si c'est le cas, le compilateur émet une erreur :
Exemple ( code Java 16 ) : |
public record Employe(String nom, String prenom) {
public Employe() {
this("Inconnu","Inconnu");
this.nom = "Test";
this.prenom = "Test";
}
} |
Résultat : |
C:\java>javac Employe.java
Employe.java:5: error: variable nom might already have been assigned
this.nom = "Test";
^
Employe.java:6: error: variable prenom might already have been assigned
this.prenom = "Test";
^
2 errors
|
Il est possible d'ajouter des champs statiques.
Un record peut aussi avoir des méthodes statiques. Cela peut par exemple permettre de proposer des fabriques.
Exemple ( code Java 14 ) : |
public static Employe getEmploye(String nomPrenom) {
String[] split = nomPrenom.split(" ");
return new Employe(split[0], split[1]);
}
|
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp = Employe.getEmploye("nom1 prenom1");
System.out.println(emp);
}
|
Exemple ( code Java 14 ) : |
Employe[nom=nom1, prenom=prenom1]
|
Bien que le but premier d'un record soit d'encapsuler des données, il est possible d'y ajouter des méthodes d'instances.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.records;
public record Employe(String nom, String prenom) {
public String getNomPrenom() {
return nom+" "+prenom;
}
}
|
Comme les champs sont final, ces méthodes ne peuvent pas changer l'état d'une instance d'un Record.
Un record peut être défini en tant que :
Une nouvelle instance est créée en utilisant l'instruction new.
Un record peut déclarer des types imbriqués, y compris des records.
Un record imbriqué est implicitement static : cela évite que l'instance englobante ajoute silencieusement de l'état au record.
Exemple ( code Java 14 ) : |
public record MonRecord() {
public record MonRecordImbrique() {
}
}
class TestRecordImbrique {
public static void main(String[] args) {
MonRecord mr = new MonRecord();
MonRecord.MonRecordImbrique mri = new MonRecord.MonRecordImbrique();
}
}
|
Il est permis de spécifier de manière redondante le modificateur static dans la déclaration d'un record qui est un membre d'une classe, mais cela n'est pas permis dans la déclaration d'un record local.
Exemple ( code Java 14 ) : |
public record MonRecord() {
public static record MonRecordImbrique() {
}
}
|
Les records permettre de regrouper un ensemble de valeurs. Il est pratique de déclarer des records pour modéliser ces valeurs. Une option consiste à déclarer des records imbriqués, comme cela se faisait historiquement avec des classes helper.
La JEP 384 ajoutée dans Java 15, introduit dans le langage Java la possibilité de définir des records locaux, donc des records qui sont définis dans une méthode. Cela peut par exemple permettre de définir un record qui va stocker des valeurs intermédiaires au plus près de là elles seront utilisées. Ceci est notamment utile pour simplifier l'utilisation de certaines opérations des Streams. Il est parfois nécessaire qu'un Stream passe plusieurs valeurs pour chaque élément. Un record local peut être défini à la place de la définition d'un type dédié.
Dans l'exemple ci-dessous, un record est défini pour encapsuler un étudiant et sa moyenne calculée via l'invocation d'une méthode.
Exemple ( code Java 16 ) : |
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
public class TestRecordLocal {
public static void main(String[] args) {
TestRecordLocal trl = new TestRecordLocal();
List<Etudiant> meilleurs = trl.getMeilleursEtudiants(
List.of(new Etudiant("Nom1"), new Etudiant("Nom3"), new Etudiant("Nom2"),
new Etudiant("Nom4")));
System.out.println(meilleurs);
}
public List<Etudiant> getMeilleursEtudiants(List<Etudiant> etudiants) {
record EtudiantMoyenne(Etudiant etudiant, double moyenne){};
return etudiants.stream()
.map(e->new EtudiantMoyenne(e,calculerMoyenne(e)))
.peek(System.out::println)
.sorted((e1,e2)->Double.compare(e2.moyenne(),e1.moyenne()))
.map(EtudiantMoyenne::etudiant)
.collect(Collectors.toList());
}
private double calculerMoyenne(Etudiant e) {
return ThreadLocalRandom.current().nextDouble(0, 20);
}
}
|
Les records locaux sont un cas particulier de records imbriqués. Comme tous les records imbriqués, les records locaux sont implicitement static.
Cela implique que les méthodes d'un record local ne peuvent pas accéder aux variables de la méthode englobante, ce qui permet d'éviter de capturer une instance immédiatement englobante qui ajouterait silencieusement de l'état au record.
Le fait que les records locaux soient implicitement static contraste avec les classes locales, qui ne sont pas implicitement static. Les classes locales ne sont jamais static, implicitement ou explicitement, et peuvent toujours accéder aux variables de la méthode englobante.
Il n'est cependant pas possible d'utiliser le modificateur static dans la déclaration d'un record local : le compilateur émet une erreur dans ce cas.
En raison de la finalité des records, plusieurs contraintes doivent être respectées et sont vérifiées par le compilateur :
Il est possible de transformer des classes équivalentes en fonctionnalités en record à partir de Java 16. Comme un record ne peut pas avoir de méthode native, il n'est pas possible de migrer une classe qui possède au moins une méthode native en record.
La classe java.lang.Record est la super-classe de tous les records. Comme cette classe mère est le package java.lang, elle est implicitement importée comme toutes les autres classes du package java.lang.
Il existe donc aussi un cas potentiel d'incompatibilité avec du code existant : celui ou toutes les classes d'un package sont importées en utilisant le joker * avec une classe nommée Record existant dans le package.
Exemple : |
package fr.jmdoudoux.dej.records;
public class Record {
}
|
Cette classe est utilisée dans une classe d'un autre package. Elle est importée en utilisant le joker *.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.java14;
import fr.jmdoudoux.dej.records.*;
public class TestRecord {
public static void main(String[] args) {
Record re;
}
}
|
Dans ce cas, une erreur de compilation est émise par le compilateur car les classes Record du package fr.jmdoudoux.dej.records et du package java.lang sont importées avec des caractères génériques. Par conséquent, aucune des deux classes n'est prioritaire, et le compilateur génère un message d'erreur lorsqu'il rencontre l'utilisation du simple nom de type Record.
Résultat : |
C:\java\TestJava14\src>javac fr/jmdoudoux/dej/java14/TestRecord.java
fr\jmdoudoux\dej\java14\TestRecord.java:9: error: reference to Record is ambiguous
Record re;
^
both class fr.jmdoudoux.dej.records.Record in fr.jmdoudoux.dej.records and class java.lang.
Record in java.lang match
1 error
|
Pour permettre à cet exemple de compiler, l'instruction d'importation peut être modifiée de manière à importer le nom pleinement qualifié de Record : il faut donc préciser explicitement la classe sans utiliser le joker *.
Exemple ( code Java 14 ) : |
package fr.jmdoudoux.dej.java14;
import fr.jmdoudoux.dej.records.Record;
public class TestRecord {
public static void main(String[] args) {
Record re;
}
}
|
Les records peuvent être sérialisés sous réserve, comme pour tous types, d'implémenter l'interface Serializable.
Exemple ( code Java 14 ) : |
import java.io.Serializable;
public record Employe(String nom, String prenom) implements Serializable {
public Employe {
System.out.println("Invocation constructeur canonique");
}
}
|
Il est alors possible de sérialiser un record vers un flux.
Exemple ( code Java 14 ) : |
public static void main(String[] args) throws IOException {
var emp = new Employe("nom1", "prenom1");
try (var oos = new ObjectOutputStream(new FileOutputStream("employe.bin"))) {
oos.writeObject(emp);
}
}
|
Ou d'obtenir une instance d'un record à partir d'un flux.
Exemple ( code Java 14 ) : |
public static void main(String[] args) throws IOException, ClassNotFoundException {
try (var ois = new ObjectInputStream(new FileInputStream("employe.bin"))) {
var emp = (Employe) ois.readObject();
System.out.println(emp);
}
}
|
La valeur de l'attribut serialVersionUID d'un record est toujours égale à zéro quel que soit les composants contenus dans le record.
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp = new Employe("nom1", "prenom1");
long serialID = ObjectStreamClass.lookup(emp.getClass()).getSerialVersionUID();
System.out.println(serialID);
|
Résultat : |
0
|
La valeur de l'attribut serialVersionUID n'est pas vérifiée lors de désérialisation d'un record.
Le mécanisme de sérialisation traite les instances de type record de manière différente des autres classes et est volontairement très simple dans ce cas :
Cela rend la sérialisation des records plus fiable, notamment si des contrôles sont effectuées sur les valeurs fournies en paramètres du constructeur.
Une première conséquence est qu'il n'est pas possible de personnaliser la sérialisation d'un record. Le contenu de la sérialisation ne comprend que l'état des composants du record. De ce fait, il est pas possible de personnaliser le processus de sérialisation/désérialisation d'un record en utilisant les méthodes writeObject(), readObject(), readObjectNoData(), writeExternal(), ou readExternal().
La de-sérialisation d'une instance de classe lit les données, créé une nouvelle instance en utilisant le constructeur par défaut et utilise l'API Reflexion pour affecter les valeurs lues à l'instance. Ce processus n'est pas sécurisé car il n'y a possibilité de valider les données affectées. Il pourrait par exemple être possible d'obtenir une instance qu'il ne serait pas possible de créer en utilisant les membres de la classe.
La de-sérialisation d'un record utilise un mécanisme différent : l'instance obtenue est créée en utilisant le constructeur canonique en lui passant en paramètre les valeurs lues. Il est alors possible de valider ces données dans le constructeur. Les instances obtenues sont donc dans ce cas nécessairement valides.
Exemple ( code Java 14 ) : |
public class SerializeRecord {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Employe emp = new Employe("nom1", "prenom1");
System.out.println("Serialise : " + emp);
try (var oos = new ObjectOutputStream(new FileOutputStream("employe.bin"))) {
oos.writeObject(emp);
}
try (var ois = new ObjectInputStream(new FileInputStream("employe.bin"))) {
emp = (Employe) ois.readObject();
System.out.println("Deserialise : " + emp);
}
}
}
|
Résultat : |
Invocation constructeur canonique
Serialise : Employe[nom=nom1, prenom=prenom1]
Invocation constructeur canonique
Deserialise : Employe[nom=nom1, prenom=prenom1]
|
Le constructeur est invoqué une première fois pour créer l'instance sérialisée et une seconde fois pour créer l'instance désérialisée.
Si un composant est ajouté au record et qu'une instance est obtenue par la sérialisation précédente alors la valeur du composant ajoutée passée en paramètre du constructeur canonique est sa valeur par défaut.
Exemple ( code Java 14 ) : |
public record Employe(String nom, String prenom, String role) implements Serializable {
public Employe {
System.out.println("Invocation constructeur canonique");
}
}
|
Exemple ( code Java 14 ) : |
public class SerializeRecord {
public static void main(String[] args) throws IOException, ClassNotFoundException {
try (var ois = new ObjectInputStream(new FileInputStream("employe.bin"))) {
Employe emp = (Employe) ois.readObject();
System.out.println("Deserialise : " + emp);
}
}
}
|
Résultat : |
Invocation constructeur canonique
Deserialise : Employe[nom=nom1, prenom=prenom1, role=null]
|
Si un composant est retiré au record et qu'une instance est obtenue par la sérialisation précédente alors la valeur du composant retiré est ignorée.
Exemple ( code Java 14 ) : |
public record Employe(String nom) implements Serializable {
public Employe {
System.out.println("Invocation constructeur canonique");
}
}
|
Exemple ( code Java 14 ) : |
public class SerializeRecord {
public static void main(String[] args) throws IOException, ClassNotFoundException {
try (var ois = new ObjectInputStream(new FileInputStream("employe.bin"))) {
Employe emp = (Employe) ois.readObject();
System.out.println("Deserialise : " + emp);
}
}
}
|
Résultat : |
Invocation constructeur canonique
Deserialise : Employe[nom=nom1]
|
Avec ces mécanismes pour gérer l'ajout ou le retrait d'un composant, la valeur du champ serialVersionUID n'est pas utile lors de la désérialisation d'un record.
Deux nouvelles méthodes ont été ajoutées à la classe java.lang.Class. en relation avec les records :
Exemple ( code Java 14 ) : |
public static void main(String[] args) {
var emp = new Employe("nom1", "prenom1");
Class clazz = emp.getClass();
System.out.println(clazz.isRecord());
RecordComponent[] components = clazz.getRecordComponents();
for (RecordComponent rc : components) {
System.out.println(rc.getType().getName()+ " "+rc.getName());
}
}
|
Résultat : |
true
java.lang.String nom
java.lang.String prenom
|
Les champs correspondant aux composants d'un record sont final et ne peuvent pas être modifiés par réflexion : toute tentative lève une exception de type IllegalAccessException.
Exemple ( code Java 15 ) : |
package fr.jmdoudoux.dej.records;
import java.lang.reflect.Field;
public class TestRecord {
public static void main(String[] args) throws Exception {
MonRecord mr = new MonRecord("nom1");
Field nomField = mr.getClass().getDeclaredField("nom");
nomField.setAccessible(true);
nomField.set(mr, "nom2");
}
}
|
Résultat : |
Exception in thread "main" java.lang.IllegalAcces
sException: Can not set
final java.lang.String field fr.jmdoudoux.dej.records.MonRecord.nom to java.lang.String
at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessE
xception(UnsafeFieldAccessorImpl.java:76)
at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessE
xception(UnsafeFieldAccessorImpl.java:80)
at java.base/jdk.internal.reflect.UnsafeQualifiedObjectFieldAccessorImpl.set(UnsafeQual
ifiedObjectFieldAccessorImpl.java:79)
at java.base/java.lang.reflect.Field.set(Field.java:793)
at TestJava15/fr.jmdoudoux.dej.records.TestRecord.main(TestRecord.java:11)
|
|