Blog

Java 14 : les records

Ce deuxième article propose de détailler les records.

Cet article fait partie d’une série détaillant les principales fonctionnalités proposées dans Java 14 :

 

Java 14 introduit en mode preview un nouveau type nommé Record décrit dans la JEP 359: Records

Les records sont un nouveau type en Java dont le but est de simplifier la création d’une classe qui encapsule simplement de manière immuable des données. Les records sont une forme restreinte de déclaration de classes de manière similaire aux énumérations introduites en Java 5.

Les records ont des similitudes avec les Data classes de Kotlin ou les records de C#.

L’objectif est de proposer d’étendre la syntaxe du langage Java afin de créer simplement un type immuable qui encapsule des données. Cette déclaration minimaliste ne contient que les informations utiles et nécessaire au compilateur pour gérer le byte-code d’une classe.

Les Records sont introduits en mode preview dans Java 14, ce qui implique que :

  • la fonctionnalité peut changer voire même être retirée dans la ou les versions futures de Java,
  • pour pouvoir les utiliser, il faut activer l’option -​-enable-preview des outils javac, java et jshell

 

La problématique

Historiquement, une classe qui encapsule des données en Java requiert beaucoup de code : un constructeur, des accesseurs (getters/setters), les méthode equals(), hashCode() et toString().

package com.oxiane.java14.records;

import java.time.Duration;

public final class Formation { 
  
  private final String titre; 
  private final String code; 
  private final Duration duree;

  public Formation(String titre, String code, Duration duree) {
    this.titre = titre;
    this.code = code;
    this.duree = duree;
  }

  public String getTitre() {
    return titre;
  }

  public String getCode() {
    return code;
  }

  public Duration getDuree() {
    return duree;
  }

  @Override
  public int hashCode() {
    final int prime  = 31;
    int       result = 1;
    result = prime * result + ((code == null) ? 0 : code.hashCode());
    result = prime * result + ((duree == null) ? 0 : duree.hashCode());
    result = prime * result + ((titre == null) ? 0 : titre.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;
    Formation other = (Formation) obj;
    if (code == null) {
      if (other.code != null)
        return false;
    } else if (!code.equals(other.code))
      return false;
    if (duree == null) {
      if (other.duree != null)
        return false;
    } else if (!duree.equals(other.duree))
      return false;
    if (titre == null) {
      if (other.titre != null)
        return false;
    } else if (!titre.equals(other.titre))
      return false;
    return true;
  }

  @Override
  public String toString() {
    return "Formation [titre=" + titre + ", code=" + code + ", duree=" + duree + "]";
  }
}

Même si une large partie de ce code peut être générée par des fonctionnalités proposées par les IDE, il impose au développeur d’avoir une lecture plus ou moins importante pour déterminer que le rôle de cette classe est d’encapsuler des données de manière immuable.

Pour pallier à cela, des bibliothèques permettent d’enrichir le bytecode de certaines de ces fonctionnalités identifiées avec des annotations dédiées. La plus largement utilisée est Lombok.

 

La syntaxe

La syntaxe de déclaration d’un record est très concise et expressive notamment vis-à-vis de la déclaration historique d’une classe équivalente. Elle se compose du mot clé contextuel record suivi du nom du record et d’une paire de parenthèses et d’une paire d’accolades qui définit un body, vide par défaut.

jshell> public record Formation() {}
|  created record Formation

jshell>

Note : record n’est pas ajouté à la liste des mots clé réservés du langage Java.

Sous cette forme, le record n’est pas très utile puisqu’il n’encapsule aucune donnée.

Pour ajouter des composants, qui sont des données encapsulées dans la classe, il suffit d’ajouter la déclaration de chacun (type et nom) séparés par une virgule entre la paire de parenthèses.

jshell> import java.time.Duration

jshell> public record Formation(String titre, String code, Duration duree) {
   ...> }
|  replaced record Formation

jshell>

La ligne de code ci-dessous est équivalente aux 70 lignes de code de la classe présentée au début de cet article.

 

La compilation des records

Un record est compilé par le compilateur comme tout autre type. Comme pour les énumérations, le compilateur va exploiter les informations fournies dans le code pour générer une classe.

Le compilateur va générer des membres à la classe à partir des informations de description du record :

  • une classe final qui hérite de la classe java.lang.Record,
  • chaque composant est défini sous la forme d’un champ private final,
  • un constructeur public qui attend en paramètre les composants définis dans le record pour initialiser les valeurs de chaque composant,
  • un accesseur en lecture pour chaque composant,
  • une redéfinition des méthodes equals(), hashCode() et toString() héritées de la classe Object
C:\java> javac --enable-preview --source 14 Formation.java
Note: Formation.java uses preview language features.
Note: Recompile with -Xlint:preview for details.

C:\java> javap Formation.class
Compiled from "Formation.java"
public final class Formation extends java.lang.Record {
  public Formation(java.lang.String, java.lang.String, java.time.Duration);
  public java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public java.lang.String titre();
  public java.lang.String code();
  public java.time.Duration duree();
}

Evidemment comme un record est par nature immuable, aucun setter n’est généré.

Le nom des accesseurs en lecture ne respecte pas la convention JavaBeans qui implique que le nom d’un getter soit composé du préfixe get ou is suivi du nom de la propriété avec la première lettre en majuscule. Dans un record, le nom d’un accesseur est uniquement composé du nom du composant.

 

La personnalisation des records

Il est possible de redéfinir les méthodes et le constructeur générés par le compilateur.

Dans un record, il est possible d’utiliser un constructeur compact (compact constructor) qui permet facilement de personnaliser le constructeur sans avoir à le redéfinir intégralement. Il ne faut pas refournir la liste des paramètres puisque le compilateur va utiliser ceux définis dans le record.

Le but du code contenu dans le constructeur compact peut être de valider ou de normaliser les données. Le code contenu dans le constructeur compact sera ajouté par le compilateur dans le code du constructeur qu’il génère notamment pour initialiser les données.

jshell> record Formation(String titre, String code, Duration duree) {
   ...>
   ...>   public Formation {
   ...>     if (titre.isBlank()) {
   ...>       throw new IllegalArgumentException("Le titre doit être renseigné");
   ...>     }
   ...>   }
   ...> }
|  modified record Formation

jshell> var formationJava14 = new Formation("", "JA-J14", Duration.ofDays(1));
|  Exception java.lang.IllegalArgumentException: Le titre doit être renseigné
|        at Formation.<init> (#13:5)
|        at do_it$Aux (#14:1)
|        at (#14:1)

jshell> var formationJava14 = new Formation("Java 14", "JA-J14", Duration.ofDays(1));
formationJava14 ==> Formation[titre=Java 14, code=JA-J14, duree=PT24H]

jshell>

Il est possible d’ajouter d’autres constructeurs tant que ceux-ci garantissent que tous les champs sont initialisés.

jshell> public record Formation(String titre, String code, Duration duree) {
   ...>
   ...>   public Formation() {
   ...>     this("", "XX-XXX", Duration.ofDays(0L));
   ...>   }
   ...> }
|  created record Formation
|    update replaced variable formationJava14, reset to null
|    update replaced variable formationJava, reset to null

jshell> var formationJava14 = new Formation("Java 14", "JA-J14", Duration.ofDays(1));
formationJava14 ==> Formation[titre=Java 14, code=JA-J14, duree=PT24H]

jshell> var formation = new Formation();
formation ==> Formation[titre=, code=XX-XXX, duree=PT0S]

jshell>

Il est possible de redéfinir les accesseurs.

jshell> public record Formation(String titre, String code, Duration duree) {
   ...>
   ...>   public String code() {
   ...>     return code.toUpperCase();
   ...>   }
   ...> }
|  replaced record Formation

jshell> var formationJava14 = new Formation("Java 14", "ja-j14", Duration.ofDays(1));
formationJava14 ==> Formation[titre=Java 14, code=ja-j14, duree=PT24H]

jshell> System.out.println(formationJava14.code())
JA-J14

jshell>

Comme pour tout type, il est possible d’ajouter des commentaires Javadoc et d’annoter un record avec des annotations dont la méta-annotation @Target contient ElementType.TYPE.

Il est possible d’annoter un composant d’un record avec des annotations dont la méta-annotation @Target contient ElementType.RECORD_COMPONENT.

Dans un record, il est possible :

  • de le typer avec des génériques,
  • d’implémenter une ou plusieurs interfaces,
  • d’ajouter des champs, des blocs d’initialisation et des méthodes statiques, par exemple pour proposer une ou plusieurs fabriques,
  • d’ajouter des méthodes d’instance
jshell> public record Formation(String titre, String code, Duration duree) {
   ...>
   ...>   public String formatterLibelle() {
   ...>     return titre + " ("+code+") pour une duree de "+duree.toDays()+ " j";
   ...>   }
   ...> }
|  modified record Formation

jshell> var formationJava14 = new Formation("Java 14", "JA-J14", Duration.ofDays(1));
formationJava14 ==> Formation[titre=Java 14, code=JA-J14, duree=PT24H]

jshell> System.out.println(formationJava14.formatterLibelle());
Java 14 (JA-J14) pour une duree de 1 j

jshell>

Plusieurs limitations seront vérifiées par le compilateur :

  • il n’est pas possible d’ajouter un nouveau champ directement dans le corps d’un record,
  • un record ne peut hériter explicitement d’une classe puisqu’il hérite déjà de la classe java.lang.Record,
  • les records sont final et ne peuvent donc pas être abstract ou être utilisé comme classe fille

 

L’utilisation des records

Un record s’utilise comme une classe : création d’une instance via l’opérateur new et le constructeur et invocation des méthodes générés selon les besoins.

jshell> System.out.println(formationJava14.titre());
Java 14

jshell> var formationJava = new Formation("Java 14", "JA-J14", Duration.ofDays(1));
formationJava ==> Formation[titre=Java 14, code=JA-J14, duree=PT24H]

jshell> formationJava.equals(formationJava14)
$9 ==> true

jshell> System.out.println(formationJava14)
Formation[titre=Java 14, code=JA-J14, duree=PT24H]

jshell> System.out.println(formationJava14.hashCode())
-38835574

jshell> System.out.println(formationJava.hashCode())
-38835574

jshell>

 

Conclusion

Les records adressent la problématique de la réduction dramatique de la quantité de code requise pour créer une classe qui encapsule de manière immuable des données.

Gardez à l’esprit que les records sont une fonctionnalité proposée en mode preview en Java 14, ce qui signifie qu’elle pourra évoluer. D’ailleurs une seconde preview est prévue dans Java 15 via la JEP 384.

Des travaux sont aussi en cours pour prendre en compte les records notamment avec les sealed types et le pattern matching.

Le prochain article de cette série détaille les évolutions dans l’instruction switch.

 

Jean-Michel Doudoux

Written by

CTO OXiane