Les Records Java en profondeur
Plongée approfondie dans les records Java : constructeurs canoniques et compacts, validation et cas d'usage.
Un record est la façon qu'a Java de déclarer une classe dont le seul rôle est de transporter des données. Introduit en aperçu dans Java 14 et finalisé dans Java 16, un record réduit à néant le code répétitif habituel — champs private final, constructeur, accesseurs, equals, hashCode et toString — en une seule ligne d'en-tête. Le chapitre précédent sur les records présentait la syntaxe de base ; celui-ci explore plus en profondeur le comportement réel des records : leurs constructeurs canoniques et compacts, la façon dont ils imposent des invariants, les garanties d'immutabilité offertes, et les contextes où ils conviennent (et ceux où ils ne conviennent pas).
Ce que le compilateur génère pour vous
Lorsque vous écrivez record Point(int x, int y) {}, le compilateur émet une classe final avec deux champs private final, un constructeur public prenant les deux, des méthodes d'accès publiques portant exactement le nom des composants (x(), y() — sans préfixe get), ainsi que des méthodes equals, hashCode et toString basées sur les valeurs.
record Point(int x, int y) {}
// Equivalent to (roughly) hand-writing:
// final class Point {
// private final int x;
// private final int y;
// Point(int x, int y) { this.x = x; this.y = y; }
// int x() { return x; }
// int y() { return y; }
// public boolean equals(Object o) { ... compares x and y ... }
// public int hashCode() { ... derived from x and y ... }
// public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
// }Les x et y dans l'en-tête sont les composants du record. Les membres générés par le compilateur en sont entièrement dérivés, dans l'ordre de déclaration.
Constructeurs canoniques et compacts
Chaque record possède un constructeur canonique dont les paramètres correspondent aux composants. Il est rarement écrit en entier — on utilise plutôt le constructeur compact, qui omet la liste de paramètres et les affectations finales this.field = field. Le compilateur exécute votre code en premier, puis affecte les paramètres (éventuellement modifiés) aux champs. C'est l'endroit naturel pour la validation et la normalisation.
record Range(int low, int high) {
Range { // compact constructor — no (int low, int high)
if (low > high) {
throw new IllegalArgumentException("low must be <= high");
}
low = Math.max(low, 0); // reassigning the parameter normalizes the field
}
}Si vous avez besoin de la forme canonique explicite (par exemple, pour copier défensivement un composant mutable), écrivez la signature complète et effectuez vous-même les affectations :
record Tags(String name, List<String> values) {
Tags(String name, List<String> values) { // explicit canonical constructor
this.name = name;
this.values = List.copyOf(values); // defensive, unmodifiable copy
}
}Immutabilité et ce que les records ne sont pas
Les champs d'un record sont final, de sorte que la référence que chaque composant contient ne change jamais après la construction. Cela rend les records superficiellement immuables. Mais l'immutabilité s'arrête à la référence : si un composant pointe vers un objet mutable (comme un ArrayList), les appelants qui partagent cet objet peuvent toujours en modifier le contenu. Les copies défensives dans le constructeur canonique comblent cette lacune.
| Propriété | Records | Classes ordinaires |
|---|---|---|
| Champs | toujours private final | au choix |
| Classe | implicitement final | extensible sauf si final |
| Superclasse | toujours java.lang.Record | n'importe laquelle (par défaut Object) |
| Accesseurs | générés automatiquement, sans préfixe get | écrits manuellement |
equals/hashCode | basés sur les valeurs, générés | par identité par défaut |
| Mutateurs | aucun — immuable | autorisés |
Comme un record étend toujours java.lang.Record, il ne peut pas étendre une autre classe. Il peut néanmoins implémenter des interfaces, déclarer des membres statiques et ajouter des méthodes d'instance.
Ajouter du comportement, des membres statiques et des fabriques
Un record est toujours une classe. Vous pouvez lui ajouter des méthodes supplémentaires, des méthodes de fabrique statiques, des champs statiques et même des types imbriqués. Les composants définissent l'état ; tout le reste est du Java ordinaire.
record Money(String currency, long cents) {
static Money of(String currency, long cents) { // static factory
return new Money(currency, cents);
}
Money plus(Money other) { // derived behavior
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(currency, cents + other.cents); // returns a new value
}
}Les records s'associent également naturellement aux types scellés et au pattern matching, modélisant des ensembles fermés de formes de données — l'ossature de la conception de données de style algébrique en Java moderne. Une interface scellée fixe l'ensemble des implémentations de records autorisées, et un switch sur ces records peut déconstruire chacun d'eux par ses composants en une seule expression.
Un exemple complet : les records de bout en bout
Ce programme exploite les membres générés d'un record, prouve les propriétés d'immutabilité et de classe via la réflexion, impose un invariant dans un constructeur compact, liste les composants du record dans l'ordre de déclaration, et montre les records fonctionnant avec des collections et un comportement ajouté.
Ce qu'il faut retenir de l'exécution :
- Le
Pointpour lequel vous n'avez jamais écrit de corps a quand même affichéPoint[x=3, y=4], répondu àa.x(), et indiquéequals by value: trueavec des codes de hachage identiques — le compilateur a générétoString, les accesseurs,equalsethashCodebasés sur les valeurs à partir des deux seuls composants. - La réflexion a confirmé le contrat garanti par le langage :
is final class : true(les records ne peuvent pas être sous-classés) etis a record : true(chaque record étendjava.lang.Record), ce qui explique l'absence de mutateurs et l'immutabilité des champs. - L'appel
Range(9, 2)a été rejeté aveclow must be <= high. Le constructeur compact s'est exécuté avant l'affectation des champs, de sorte qu'un record n'est jamais construit dans un état invalide — la validation y a sa place, et non dans une vérification de fabrique séparée. getRecordComponents()a retourné les composants dans l'ordre de déclaration sous la formelow:int high:int, montrant que la structure d'un record est introspectable par réflexion — base sur laquelle les bibliothèques de sérialisation et les frameworks mappent automatiquement les records.Money.of("USD", 500).plus(Money.of("USD", 250))a produitUSD 750, etdistinct()a fusionné deux valeursPoint(0,0)identiques pour n'en laisser que2— les records se comportent comme de véritables valeurs partout, y compris dans les flux et les ensembles, précisément parce que leurequals/hashCodecompare les contenus.
Quand utiliser un record (et quand ne pas le faire)
Optez pour un record lorsque le type est défini par ses données et que ces données ne changent pas après la construction :
- DTOs et charges utiles de requêtes/réponses API.
- Clés de Map et éléments de Set (
equals/hashCodebasés sur les valeurs fournis gratuitement). - Types de retour regroupant plusieurs valeurs, remplaçant les tuples jetables ou les paramètres de sortie.
- Les « feuilles » d'une hiérarchie scellée que vous déconstruisez avec le pattern matching.
Préférez une classe ordinaire lorsque :
- L'objet a un état mutable ou un cycle de vie (entités, constructeurs, services).
- Vous devez étendre une autre classe — les records ne peuvent qu'implémenter des interfaces.
- L'identité de l'objet importe plus que son contenu (vous souhaitez l'égalité référentielle).
Un piège courant : l'accesseur d'un record retourne la référence stockée telle quelle. Si un composant est d'un type mutable (un List, un tableau, une Date), copiez-le défensivement dans le constructeur canonique — comme le fait l'exemple Tags ci-dessus avec List.copyOf — sinon les appelants peuvent muter l'état « immuable » du record via la référence qu'ils ont passée.