Nous avons gardé le meilleur nouveau type Java pour la fin ! Si vous avez entendu parler des nouvelles fonctionnalités de Java, vous avez probablement entendu parler des records. Les records sont passionnants car ils éliminent une tonne de code répétitif. Avant d’aborder les records, il est utile d’avoir un peu de contexte sur les raisons pour lesquelles ils ont été ajoutés au langage, alors commençons par l’encapsulation.
Comprendre l’Encapsulation
Un POJO, qui signifie Plain Old Java Object (Simple Vieil Objet Java), est une classe utilisée pour modéliser et transmettre des données, souvent avec peu ou pas de méthodes complexes (d’où la partie “simple” de la définition). Vous avez peut-être aussi entendu parler d’un JavaBean, qui est un POJO avec quelques règles supplémentaires.
Créons un POJO simple avec deux champs :
public class Grue {
int nombreOeufs;
String nom;
public Grue(int nombreOeufs, String nom) {
this.nombreOeufs = nombreOeufs;
this.nom = nom;
}
}
Oups, les champs ont un accès package. Pourquoi nous en soucions-nous ? Cela signifie que quelqu’un en dehors de la classe mais dans le même package pourrait modifier ces valeurs et créer des données invalides comme ceci :
public class Braconnier {
public void mauvaisActeur() {
var mere = new Grue(5, "Cathy");
mere.nombreOeufs = -100;
}
}
Ce n’est clairement pas bon. Nous ne voulons pas que la Grue mère ait un nombre négatif d’œufs ! L’encapsulation à la rescousse. L’encapsulation est un moyen de protéger les membres d’une classe en restreignant leur accès. En Java, elle est généralement mise en œuvre en déclarant toutes les variables d’instance comme private. Les appelants doivent utiliser des méthodes pour récupérer ou modifier les variables d’instance.
L’encapsulation consiste à protéger une classe contre une utilisation inattendue. Elle nous permet également de modifier les méthodes et le comportement de la classe ultérieurement sans que quelqu’un ait déjà un accès direct à une variable d’instance au sein de la classe. Par exemple, nous pouvons changer le type de données d’une variable d’instance mais maintenir les mêmes signatures de méthode. De cette manière, nous conservons un contrôle total sur le fonctionnement interne d’une classe.
Jetons un coup d’œil à la classe Grue nouvellement encapsulée (et immuable) :
public final class Grue {
private final int nombreOeufs;
private final String nom;
public Grue(int nombreOeufs, String nom) {
if (nombreOeufs >= 0) this.nombreOeufs = nombreOeufs; // condition de garde
else throw new IllegalArgumentException();
this.nom = nom;
}
public int getNombreOeufs() { // accesseur
return nombreOeufs;
}
public String getNom() { // accesseur
return nom;
}
}
Notez que les variables d’instance sont maintenant private. Cela signifie que seul le code à l’intérieur de la classe peut lire ou écrire leurs valeurs. Puisque nous avons écrit la classe, nous savons qu’il ne faut pas définir un nombre négatif d’œufs. Nous avons ajouté une méthode pour lire la valeur, ce qu’on appelle une méthode d’accès ou un accesseur (getter).
Vous avez peut-être remarqué que nous avons marqué la classe et ses variables d’instance final, et que nous n’avons pas de méthodes de mutation, ou setters, pour modifier la valeur des variables d’instance. C’est parce que nous voulons que notre classe soit immuable en plus d’être bien encapsulée. Le modèle d’objets immuables est un modèle de conception orienté objet dans lequel un objet ne peut pas être modifié après sa création. Au lieu de modifier un objet immuable, vous créez un nouvel objet qui contient toutes les propriétés de l’objet original que vous souhaitez copier.
Pour résumer, n’oubliez pas que les données (une variable d’instance) sont private et que les getters/setters sont public pour l’encapsulation. Vous n’avez même pas besoin de fournir des getters et des setters. Tant que les variables d’instance sont private, tout va bien. Par exemple, la classe suivante est bien encapsulée, bien qu’elle ne soit pas très utile puisqu’elle ne déclare aucune méthode non-private :
public class Veterinaire {
private String nom = "Dr Dupont";
private int anneesExperience = 25;
}
Vous devez omettre les setters pour qu’une classe soit immuable.
Application des Records
Notre classe Grue faisait 15 lignes de long. Nous pouvons l’écrire beaucoup plus succinctement, comme indiqué ci-dessous. Mettant de côté la condition de garde sur nombreOeufs dans le constructeur pour un moment, ce record est équivalent et immuable !
public record Grue(int nombreOeufs, String nom) { }
Wow ! Il ne fait qu’une seule ligne ! Un record est un type spécial de classe orientée données dans laquelle le compilateur insère du code répétitif pour vous.
En fait, le compilateur insère beaucoup plus que les 14 lignes que nous avons écrites précédemment. En bonus, le compilateur insère des implémentations utiles des méthodes Object equals(), hashCode() et toString(). Nous avons couvert beaucoup de choses en une seule ligne de code !
Maintenant, imaginez que nous avions 10 champs de données au lieu de 2. C’est beaucoup de méthodes que nous n’avons pas à écrire. Et nous n’avons même pas parlé des constructeurs ! Pire encore, chaque fois que quelqu’un modifie un champ, des dizaines de lignes de code associées peuvent devoir être mises à jour. Par exemple, nom peut être utilisé dans le constructeur, toString(), la méthode equals(), etc. Si nous avons une application avec des centaines de POJOs, un record peut nous faire gagner un temps précieux.
Créer une instance de Grue et imprimer quelques champs est facile :
var maman = new Grue(4, "Camille");
System.out.println(maman.nombreOeufs()); // 4
System.out.println(maman.nom()); // Camille
Quelques points devraient vous interpeller. Premièrement, nous n’avons jamais défini de constructeurs ou de méthodes dans notre déclaration de Grue. Comment le compilateur sait-il quoi faire ? En coulisses, il crée un constructeur pour vous avec les paramètres dans le même ordre que celui dans lequel ils apparaissent dans la déclaration du record. Omettre ou changer l’ordre des types entraînera des erreurs de compilation :
var maman1 = new Grue("Camille", 4); // NE COMPILE PAS
var maman2 = new Grue("Camille"); // NE COMPILE PAS
Pour chaque champ, il crée également un accesseur sous le nom du champ, plus un ensemble de parenthèses. Contrairement aux POJO traditionnels ou aux JavaBeans, les méthodes n’ont pas le préfixe get ou is. Encore quelques caractères que les records vous évitent d’avoir à taper ! Enfin, les records redéfinissent un certain nombre de méthodes de Object pour vous.
Membres Automatiquement Ajoutés aux Records
- Constructeur : Un constructeur avec les paramètres dans le même ordre que la déclaration du record
- Méthode d’accès : Un accesseur pour chaque champ
- equals() : Une méthode pour comparer deux éléments qui renvoie true si chaque champ est égal en termes de equals()
- hashCode() : Une méthode hashCode() cohérente utilisant tous les champs
- toString() : Une implémentation de toString() qui affiche chaque champ du record dans un format pratique et facile à lire
Voici quelques exemples des nouvelles méthodes. N’oubliez pas que la méthode println() appellera automatiquement la méthode toString() sur tout objet qui lui est transmis.
var pere = new Grue(0, "Claude");
System.out.println(pere); // Grue[nombreOeufs=0, nom=Claude]
var copie = new Grue(0, "Claude");
System.out.println(copie); // Grue[nombreOeufs=0, nom=Claude]
System.out.println(pere.equals(copie)); // true
System.out.println(pere.hashCode() + ", " + copie.hashCode()); // 1007, 1007
Ce sont les bases des records. Nous disons “bases” car il y a beaucoup plus de choses que vous pouvez faire avec eux, comme vous le verrez dans les sections suivantes.
Fait amusant : il est légal d’avoir un record sans aucun champ. Il est simplement déclaré avec le mot-clé record et des parenthèses :
public record Grue() {}
Comprendre l’Immuabilité des Records
Comme vous l’avez vu, les records n’ont pas de setters. Chaque champ est intrinsèquement final et ne peut pas être modifié après avoir été écrit dans le constructeur. Pour “modifier” un record, vous devez créer un nouvel objet et copier toutes les données que vous souhaitez conserver.
var cousin = new Grue(3, "Julie");
var ami = new Grue(cousin.nombreOeufs(), "Justine");
Tout comme les interfaces sont implicitement abstract, les records sont également implicitement final. Le modificateur final est optionnel mais supposé.
public final record Grue(int nombreOeufs, String nom) {}
Comme les enums, cela signifie que vous ne pouvez pas étendre ou hériter d’un record.
public record GrueBleue() extends Grue {} // NE COMPILE PAS
Aussi comme les enums, un record peut implémenter une interface régulière ou scellée, à condition qu’il implémente toutes les méthodes abstraites.
public interface Oiseau {}
public record Grue(int nombreOeufs, String nom) implements Oiseau {}
Il y a de bonnes raisons de rendre les classes orientées données immuables. Cela peut conduire à un code moins sujet aux erreurs, car un nouvel objet est établi chaque fois que les données sont modifiées. Cela les rend également intrinsèquement thread-safe et utilisables dans des frameworks concurrents.
Déclaration de Constructeurs
Que faire si vous devez déclarer un record avec quelques gardes comme nous l’avons fait précédemment ? Dans cette section, nous couvrons deux façons d’y parvenir avec les records.
Le Constructeur Long
Premièrement, nous pouvons simplement déclarer le constructeur que le compilateur insère normalement automatiquement, ce que nous appelons le constructeur long.
public record Grue(int nombreOeufs, String nom) {
public Grue(int nombreOeufs, String nom) {
if (nombreOeufs < 0) throw new IllegalArgumentException();
this.nombreOeufs = nombreOeufs;
this.nom = nom;
}
}
Le compilateur n’insérera pas de constructeur si vous en définissez un avec la même liste de paramètres dans le même ordre. Puisque chaque champ est final, le constructeur doit définir chaque champ. Par exemple, ce record ne compile pas :
public record Grue(int nombreOeufs, String nom) {
public Grue(int nombreOeufs, String nom) {} // NE COMPILE PAS
}
Bien que pouvoir déclarer un constructeur soit une fonctionnalité intéressante des records, c’est aussi problématique. Si nous avons 20 champs, nous devrons déclarer des affectations pour chacun d’entre eux, introduisant le code répétitif que nous cherchions à éliminer. Aïe !
Constructeurs Compacts
Heureusement, les auteurs de Java ont ajouté la possibilité de définir un constructeur compact pour les records. Un constructeur compact est un type spécial de constructeur utilisé pour les records afin de traiter la validation et les transformations de manière concise. Il ne prend aucun paramètre et définit implicitement tous les champs. Voici un exemple de constructeur compact :
public record Grue(int nombreOeufs, String nom) {
public Grue {
if (nombreOeufs < 0) throw new IllegalArgumentException();
nom = nom.toUpperCase();
}
}
Super ! Maintenant, nous pouvons vérifier les valeurs que nous voulons, et nous n’avons pas à lister tous les paramètres du constructeur et les affectations triviales. Java exécutera le constructeur complet après le constructeur compact. Vous devez également vous rappeler qu’un constructeur compact est déclaré sans parenthèses. Comme montré ci-dessus, nous pouvons même transformer les paramètres du constructeur, comme nous en discutons plus en détail dans la section suivante.
Vous pourriez penser que vous avez besoin de méthodes personnalisées pour chaque champ dans le record, comme la vérification négative que nous avons faite avec getNombreOeufs(). En pratique, de nombreux POJOs sont créés pour un usage général avec peu de validation.
Transformation des Paramètres
Les constructeurs compacts vous donnent la possibilité d’appliquer des transformations à n’importe laquelle des valeurs d’entrée. Voyez si vous pouvez comprendre ce que fait le constructeur compact suivant :
public record Grue(int nombreOeufs, String nom) {
public Grue {
if (nom == null || nom.length() < 1)
throw new IllegalArgumentException();
nom = nom.substring(0,1).toUpperCase()
+ nom.substring(1).toLowerCase();
}
}
Vous abandonnez ? Il valide la chaîne, puis la formate de telle sorte que seule la première lettre est en majuscule. Comme précédemment, Java appelle le constructeur complet après le constructeur compact mais avec les paramètres du constructeur modifiés.
Bien que les constructeurs compacts puissent modifier les paramètres du constructeur, ils ne peuvent pas modifier les champs du record. Par exemple, ceci ne compile pas :
public record Grue(int nombreOeufs, String nom) {
public Grue {
this.nombreOeufs = 10; // NE COMPILE PAS
}
}
Supprimer la référence this permet au code de compiler, car le paramètre du constructeur est modifié à la place.
Bien que nous ayons couvert à la fois les formes longue et compacte des constructeurs de records dans cette section, il est fortement recommandé de s’en tenir à la forme compacte, sauf si vous avez une bonne raison de ne pas le faire.
Constructeurs Surchargés
Vous pouvez également créer des constructeurs surchargés qui prennent une liste de paramètres complètement différente. Ils sont plus étroitement liés au constructeur de forme longue et n’utilisent aucune des fonctionnalités syntaxiques des constructeurs compacts.
public record Grue(int nombreOeufs, String nom) {
public Grue(String prenom, String nom) {
this(0, prenom + " " + nom);
}
}
La première ligne d’un constructeur surchargé doit être un appel explicite à un autre constructeur via this(). S’il n’y a pas d’autres constructeurs, le constructeur long doit être appelé. Contrairement aux constructeurs compacts, vous ne pouvez transformer les données que sur la première ligne. Après la première ligne, tous les champs seront déjà assignés, et l’objet est immuable.
public record Grue(int nombreOeufs, String nom) {
public Grue(int nombreOeufs, String prenom, String nom) {
this(nombreOeufs + 1, prenom + " " + nom);
nombreOeufs = 10; // SANS EFFET (s'applique au paramètre, pas au champ d'instance)
this.nombreOeufs = 20; // NE COMPILE PAS
}
}
Vous ne pouvez pas non plus déclarer deux constructeurs de record qui s’appellent l’un l’autre à l’infini ou en cycle.
public record Grue(int nombreOeufs, String nom) {
public Grue(String nom) {
this(1); // NE COMPILE PAS
}
public Grue(int nombreOeufs) {
this(""); // NE COMPILE PAS
}
}
Personnalisation des Records
Comme les records sont orientés données, nous nous sommes concentrés sur les fonctionnalités des records que vous êtes susceptible d’utiliser. Les records prennent en charge en fait beaucoup des mêmes fonctionnalités qu’une classe. Voici quelques-uns des membres que les records peuvent inclure :
- Constructeurs surchargés et compacts
- Méthodes d’instance, y compris la redéfinition de toutes les méthodes fournies (accesseurs, equals(), hashCode(), toString())
- Classes, interfaces, annotations, enum et records imbriqués
À titre d’exemple illustratif, ce qui suit redéfinit deux méthodes d’instance en utilisant l’annotation @Override optionnelle :
public record Grue(int nombreOeufs, String nom) {
@Override public int nombreOeufs() { return 10; }
@Override public String toString() { return nom; }
}
Bien que vous puissiez ajouter des méthodes, des champs statiques et d’autres types de données, vous ne pouvez pas ajouter de champs d’instance en dehors de la déclaration du record, même s’ils sont private. Cela va à l’encontre du but d’utiliser un record et pourrait briser l’immuabilité !
public record Grue(int nombreOeufs, String nom) {
private static int type = 10;
public int taille; // NE COMPILE PAS
private boolean amical; // NE COMPILE PAS
}
Les records ne prennent pas non plus en charge les initialiseurs d’instance. Toute initialisation pour les champs d’un record doit se produire dans un constructeur.
Bien que ce soit une fonctionnalité utile que les records prennent en charge beaucoup des mêmes membres qu’une classe, essayez de les garder simples. Comme les POJOs et les JavaBeans dont ils sont issus, plus ils deviennent compliqués, moins ils deviennent utilisables.