Aller au contenu

Records, enums, sealed

Ce chapitre est celui où Java ressemble le plus à Rust : des types de données immuables en une ligne, des enums riches, et des types algébriques filtrés par un switch exhaustif.

Dans ce chapitre

  • record : un type de données immuable, avec equals, hashCode et toString générés
  • enum : un ensemble fixe d'instances, avec champs et méthodes, et EnumSet/EnumMap
  • sealed + record : l'équivalent d'un enum Rust à données
  • Le pattern matching dans switch : motifs de type, déstructuration, gardes when, exhaustivité
  • Un exemple complet : un évaluateur d'expressions, comparé à sa version Rust

Les records

record Point(int x, int y) {}

Cette ligne déclare une classe immuable avec :

  • deux champs private final int x, y ;
  • un constructeur Point(int x, int y) (le constructeur canonique) ;
  • des accesseurs x() et y() (sans préfixe get) ;
  • equals, hashCode et toString basés sur tous les composants.
record Point(int x, int y) {}

var p = new Point(3, 4);
IO.println(p);                          // affiche : Point[x=3, y=4]
IO.println(p.x());                      // affiche : 3
IO.println(p.equals(new Point(3, 4)));  // affiche : true
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Point { x: i32, y: i32 }

let p = Point { x: 3, y: 4 };
println!("{:?}", p);                    // Point { x: 3, y: 4 }
println!("{}", p.x);
println!("{}", p == Point { x: 3, y: 4 });

Pas besoin d'équivalent à Clone : l'objet étant immuable, partager la référence suffit. Pour « modifier », on crée une copie modifiée.

Sous le capot

javap -p Point.class montre ce que le compilateur a généré :

final class Point extends java.lang.Record {
  private final int x;
  private final int y;
  Point(int, int);
  public final java.lang.String toString();
  public final int hashCode();
  public final boolean equals(java.lang.Object);
  public int x();
  public int y();
}

Ajouter du comportement

Un record peut avoir des méthodes, des membres statiques, implémenter des interfaces, et surtout valider ses données grâce au constructeur compact :

Records.java
public class Records {
    record Point(int x, int y) {
        static final Point ORIGINE = new Point(0, 0);       // membre statique

        static Point de(String texte) {                       // fabrique statique
            String[] p = texte.split(",");
            return new Point(Integer.parseInt(p[0].strip()), Integer.parseInt(p[1].strip()));
        }

        double distance(Point autre) {                        // méthode d'instance
            int dx = x - autre.x, dy = y - autre.y;
            return Math.sqrt(dx * dx + dy * dy);
        }

        Point avecX(int nouveauX) {                           // « wither » : copie modifiée
            return new Point(nouveauX, y);
        }
    }

    record Intervalle(int debut, int fin) {
        Intervalle {                                          // constructeur compact : pas de ()
            if (debut > fin) {
                throw new IllegalArgumentException("debut > fin : " + debut + " > " + fin);
            }
        }                                                     // champs affectés automatiquement ici
    }

    public static void main(String[] args) {
        var p = new Point(3, 4);
        System.out.println(p.distance(Point.ORIGINE));        // affiche : 5.0
        System.out.println(p.avecX(10) + " " + p);            // affiche : Point[x=10, y=4] Point[x=3, y=4]
        System.out.println(Point.de("1, 2"));                 // affiche : Point[x=1, y=2]
        try {
            new Intervalle(5, 1);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());               // affiche : debut > fin : 5 > 1
        }
    }
}

Le constructeur compact reçoit les paramètres, peut les réaffecter (pour normaliser), et les champs sont remplis à la fin. Écrire this.debut = ... dedans est interdit.

Les limites d'un record :

  • il est final et ne peut pas hériter d'une classe (il hérite déjà de java.lang.Record) ;
  • pas de champ d'instance en plus des composants (error: field declaration must be static) ;
  • pas de setter possible : les champs sont final.

Piège : immuable en surface seulement

record Equipe(String nom, List<String> membres) garde la référence vers la liste reçue : si l'appelant la modifie, l'équipe change. Fais une copie défensive dans le constructeur compact :

record Equipe(String nom, List<String> membres) {
    Equipe {
        membres = List.copyOf(membres);   // copie immuable
    }
}

Où les utiliser : pour tout ce qui est donnée plutôt qu'entité. Renvoyer plusieurs valeurs, clés de Map, et surtout les objets échangés en JSON par une API Spring (les DTO, Data Transfer Objects) : Jackson sait lire et écrire les records. En revanche, une entité JPA (une ligne de base de données gérée par Hibernate) ne peut pas être un record : Hibernate a besoin d'un objet modifiable (voir Base de données).

Les enums

Un enum Java est une classe dont les instances sont fixées à la compilation. Chaque constante est un objet unique, ce qui permet de les comparer avec ==.

Statut.java (extrait)
enum Statut {
    A_FAIRE("À faire"),
    EN_COURS("En cours"),
    TERMINE("Terminé");                       // ; obligatoire avant les membres

    private final String libelle;             // un champ, présent dans chaque constante

    Statut(String libelle) {                  // constructeur, implicitement privé
        this.libelle = libelle;
    }

    String libelle() { return libelle; }

    boolean estOuvert() { return this != TERMINE; }
}

for (Statut s : Statut.values()) {            // toutes les constantes, dans l'ordre
    IO.println(s + " / " + s.ordinal() + " / " + s.libelle() + " / " + s.estOuvert());
}
// affiche :
// A_FAIRE / 0 / À faire / true
// EN_COURS / 1 / En cours / true
// TERMINE / 2 / Terminé / false

Statut s = Statut.valueOf("EN_COURS");        // depuis une chaîne
IO.println(s == Statut.EN_COURS);             // affiche : true
  • values() renvoie toutes les constantes ; name() leur nom ; ordinal() leur position.
  • valueOf("en_cours") (mauvaise casse) lève IllegalArgumentException: No enum constant ...Statut.en_cours.
  • new Statut(...) est interdit (enum classes may not be instantiated).
  • Un switch sur un enum qui couvre toutes les constantes n'a pas besoin de default.

Piège : ne stocke pas ordinal()

Si tu enregistres la position d'une constante (en base, dans un fichier) et que quelqu'un insère une constante au milieu, toutes les données sont décalées. Stocke le nom. Avec JPA, c'est @Enumerated(EnumType.STRING).

Pour des ensembles et des tables indexées par un enum, utilise EnumSet et EnumMap : ils sont stockés comme des champs de bits ou des tableaux, donc très rapides, et gardent l'ordre de déclaration.

EnumSet<Statut> ouverts = EnumSet.of(Statut.A_FAIRE, Statut.EN_COURS);
IO.println(EnumSet.complementOf(ouverts));    // affiche : [TERMINE]

Map<Statut, Integer> compteurs = new EnumMap<>(Statut.class);
compteurs.put(Statut.TERMINE, 4);
compteurs.put(Statut.A_FAIRE, 2);
IO.println(compteurs);                        // affiche : {A_FAIRE=2, TERMINE=4}

Enum Java contre enum Rust

C'est le faux ami du chapitre. Un enum Java, c'est un ensemble fermé de singletons qui partagent tous les mêmes champs, avec des valeurs fixées une fois pour toutes. Un enum Rust est une union étiquetée : chaque variante porte ses propres données, différentes à chaque valeur créée.

Besoin Rust Java
Liste fixe de cas sans données enum Statut { AFaire, EnCours } enum
Cas avec des constantes associées enum + impl avec match enum avec champs
Variantes avec données propres enum Forme { Cercle(f64), Rect(f64, f64) } sealed interface + record

Les types scellés : sealed

Une interface (ou classe) sealed liste ses seules implémentations autorisées avec permits. Combinée aux records, elle donne exactement l'enum à données de Rust :

sealed interface Forme permits Cercle, Rectangle {}

record Cercle(double rayon) implements Forme {}
record Rectangle(double largeur, double hauteur) implements Forme {}
enum Forme {
    Cercle { rayon: f64 },
    Rectangle { largeur: f64, hauteur: f64 },
}
  • Toute autre implémentation est refusée : class is not allowed to extend sealed class: Forme (as it is not listed in its 'permits' clause).
  • Les sous-types doivent être final (c'est le cas des records), sealed à leur tour, ou explicitement non-sealed.
  • Si tous les sous-types sont dans le même fichier, permits peut être omis.

Différence avec Rust : chaque variante est un vrai type (Cercle), utilisable seul comme paramètre ou champ, alors qu'une variante Rust n'est pas un type.

Le pattern matching dans switch

Depuis Java 21, switch filtre sur des types et déstructure les records, comme le match de Rust.

Motifs de type et gardes

String decrire(Object o) {
    return switch (o) {
        case null -> "null !";                                 // sinon : NullPointerException
        case Integer i when i > 100 -> "grand entier " + i;    // garde, comme `if` dans un match Rust
        case Integer i -> "entier " + i;
        case String s -> "chaîne de " + s.length() + " caractères";
        case int[] t -> "tableau de " + t.length + " int";
        default -> "autre : " + o.getClass().getSimpleName();
    };
}

decrire(42);        // "entier 42"
decrire(1000);      // "grand entier 1000"
decrire(3.5);       // "autre : Double"
decrire(null);      // "null !"

Les case sont testés dans l'ordre. Un cas plus général placé avant un cas plus précis est refusé : this case label is dominated by a preceding case label. Sur un Object, le default est obligatoire (impossible d'énumérer tous les types).

Déstructuration et exhaustivité

Sur un type scellé, plus besoin de default : le compilateur connaît tous les cas.

double aire(Forme f) {
    return switch (f) {
        case Cercle(double r) -> Math.PI * r * r;              // déstructure le record
        case Rectangle(var l, var h) -> l * h;                 // var : type inféré
    };
}

Ajoute un record Triangle(...) implements Forme à la liste permits, et chaque switch sans default sur Forme cesse de compiler : the switch expression does not cover all possible input values. C'est exactement la garantie du match Rust.

N'ajoute pas de default sur un type scellé

Un default « par sécurité » désactive ce contrôle : le jour où tu ajoutes une variante, elle tombe silencieusement dans le default. Laisse le compilateur te lister les endroits à mettre à jour.

_ (Java 22) remplace une variable inutilisée, comme en Rust : case Rectangle(_, var h) -> ..., case Cercle _ -> .... Il marche aussi ailleurs : catch (NumberFormatException _), for (var _ : liste), (_, v) -> ... dans une lambda.

Exemple complet : un évaluateur d'expressions

Le grand classique des types algébriques : une expression est un nombre, une somme, un produit ou une négation.

Expressions.java
sealed interface Expr permits Num, Add, Mul, Neg {}

record Num(int valeur) implements Expr {}
record Add(Expr gauche, Expr droite) implements Expr {}
record Mul(Expr gauche, Expr droite) implements Expr {}
record Neg(Expr operande) implements Expr {}

int evaluer(Expr e) {
    return switch (e) {
        case Num(int v) -> v;
        case Add(Expr g, Expr d) -> evaluer(g) + evaluer(d);
        case Mul(Expr g, Expr d) -> evaluer(g) * evaluer(d);
        case Neg(Expr x) -> -evaluer(x);
    };
}

String afficher(Expr e) {
    return switch (e) {
        case Num(var v) -> String.valueOf(v);
        case Add(var g, var d) -> "(" + afficher(g) + " + " + afficher(d) + ")";
        case Mul(var g, var d) -> afficher(g) + " * " + afficher(d);
        case Neg(var x) -> "-" + afficher(x);
    };
}

void main() {
    // (2 + 3) * -4
    Expr e = new Mul(new Add(new Num(2), new Num(3)), new Neg(new Num(4)));
    IO.println(afficher(e) + " = " + evaluer(e));   // affiche : (2 + 3) * -4 = -20
}
main.rs
enum Expr {
    Num(i32),
    Add(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Neg(Box<Expr>),
}

use Expr::*;

fn evaluer(e: &Expr) -> i32 {
    match e {
        Num(v) => *v,
        Add(g, d) => evaluer(g) + evaluer(d),
        Mul(g, d) => evaluer(g) * evaluer(d),
        Neg(x) => -evaluer(x),
    }
}

fn afficher(e: &Expr) -> String {
    match e {
        Num(v) => v.to_string(),
        Add(g, d) => format!("({} + {})", afficher(g), afficher(d)),
        Mul(g, d) => format!("{} * {}", afficher(g), afficher(d)),
        Neg(x) => format!("-{}", afficher(x)),
    }
}

fn main() {
    let e = Mul(
        Box::new(Add(Box::new(Num(2)), Box::new(Num(3)))),
        Box::new(Neg(Box::new(Num(4)))),
    );
    println!("{} = {}", afficher(&e), evaluer(&e)); // (2 + 3) * -4 = -20
}

Pas de Box en Java : les champs d'un record de type Expr sont déjà des références. Le toString généré donne aussi gratuitement une représentation de l'arbre :

Mul[gauche=Add[gauche=Num[valeur=2], droite=Num[valeur=3]], droite=Neg[operande=Num[valeur=4]]]

Les motifs s'imbriquent, ce qui rend les réécritures d'arbre très lisibles. Ajoute ceci au fichier :

Expr simplifier(Expr e) {
    return switch (e) {
        case Mul(Num(var v), _) when v == 0 -> new Num(0);            // 0 * x  → 0
        case Mul(Num(var v), var d) when v == 1 -> simplifier(d);     // 1 * x  → x
        case Add(Num(var v), var d) when v == 0 -> simplifier(d);     // 0 + x  → x
        case Neg(Neg(var x)) -> simplifier(x);                        // --x    → x
        case Add(var g, var d) -> new Add(simplifier(g), simplifier(d));
        case Mul(var g, var d) -> new Mul(simplifier(g), simplifier(d));
        case Neg(var x) -> new Neg(simplifier(x));
        case Num n -> n;
    };
}

// dans main :
Expr f = new Add(new Num(0), new Mul(new Num(1), new Neg(new Neg(new Num(7)))));
IO.println(afficher(f) + "  ->  " + afficher(simplifier(f)));
// affiche : (0 + 1 * --7)  ->  7

En Rust, filtrer « à travers » un Box (Mul(Num(0), _)) demande une garde ou des motifs box encore instables ; en Java, l'imbrication est directe.

Exercices

1. Écris un record Fraction(long num, long den) toujours normalisé : le constructeur compact refuse un dénominateur nul, met le signe au numérateur et simplifie par le PGCD. Ajoute plus(Fraction) et un toString qui affiche 1/2 (ou 3 si le dénominateur vaut 1). Vérifie que new Fraction(1, 2).equals(new Fraction(2, 4)).

Solution
Fraction.java
record Fraction(long num, long den) {
    Fraction {
        if (den == 0) throw new ArithmeticException("dénominateur nul");
        if (den < 0) {                       // le signe porte sur le numérateur
            num = -num;
            den = -den;
        }
        long g = pgcd(Math.abs(num), den);
        num /= g;                            // on réaffecte les PARAMÈTRES,
        den /= g;                            // les champs sont affectés après
    }

    static long pgcd(long a, long b) {
        return b == 0 ? a : pgcd(b, a % b);
    }

    Fraction plus(Fraction o) {
        return new Fraction(num * o.den + o.num * den, den * o.den);
    }

    @Override
    public String toString() {
        return den == 1 ? String.valueOf(num) : num + "/" + den;
    }
}

void main() {
    IO.println(new Fraction(2, 4));                                     // affiche : 1/2
    IO.println(new Fraction(3, -6));                                    // affiche : -1/2
    IO.println(new Fraction(1, 2).equals(new Fraction(2, 4)));          // affiche : true
    IO.println(new Fraction(1, 3).plus(new Fraction(1, 6)));            // affiche : 1/2
    IO.println(new Fraction(1, 2).plus(new Fraction(1, 2)));            // affiche : 1
    try {
        new Fraction(1, 0);
    } catch (ArithmeticException e) {
        IO.println(e.getMessage());                                     // affiche : dénominateur nul
    }
}

Comme la normalisation a lieu à la construction, le equals généré (qui compare les champs) suffit : il n'existe jamais deux représentations de la même fraction.

2. Écris un enum Feu (VERT, ORANGE, ROUGE) avec une durée en secondes (30, 5, 35) et une méthode suivant() écrite avec un switch sans default. Affiche quatre états successifs à partir de VERT, puis la durée totale d'un cycle avec values().

Solution
Feu.java
enum Feu {
    VERT(30), ORANGE(5), ROUGE(35);

    private final int secondes;

    Feu(int secondes) {
        this.secondes = secondes;
    }

    int secondes() { return secondes; }

    Feu suivant() {
        return switch (this) {          // exhaustif sans default
            case VERT -> ORANGE;
            case ORANGE -> ROUGE;
            case ROUGE -> VERT;
        };
    }
}

void main() {
    Feu f = Feu.VERT;
    for (int i = 0; i < 4; i++) {
        IO.print(f + " ");
        f = f.suivant();
    }
    IO.println("");                            // affiche : VERT ORANGE ROUGE VERT

    int cycle = 0;
    for (Feu feu : Feu.values()) {
        cycle += feu.secondes();
    }
    IO.println("cycle : " + cycle + " s");     // affiche : cycle : 70 s
}

3. Ajoute à l'évaluateur une variante Var(String nom) et une méthode int evaluer(Expr e, Map<String, Integer> env). Une variable absente de env doit lever une IllegalArgumentException. Utilise une garde when.

Solution
ExprVar.java
sealed interface Expr permits Num, Var, Add, Mul, Neg {}

record Num(int valeur) implements Expr {}
record Var(String nom) implements Expr {}
record Add(Expr gauche, Expr droite) implements Expr {}
record Mul(Expr gauche, Expr droite) implements Expr {}
record Neg(Expr operande) implements Expr {}

int evaluer(Expr e, Map<String, Integer> env) {
    return switch (e) {
        case Num(int v) -> v;
        case Var(String nom) when env.containsKey(nom) -> env.get(nom);
        case Var(String nom) -> throw new IllegalArgumentException("variable inconnue : " + nom);
        case Add(Expr g, Expr d) -> evaluer(g, env) + evaluer(d, env);
        case Mul(Expr g, Expr d) -> evaluer(g, env) * evaluer(d, env);
        case Neg(Expr x) -> -evaluer(x, env);
    };
}

void main() {
    // 2 * x + -y
    Expr e = new Add(new Mul(new Num(2), new Var("x")), new Neg(new Var("y")));
    IO.println(evaluer(e, Map.of("x", 10, "y", 3)));        // affiche : 17
    try {
        evaluer(e, Map.of("x", 10));
    } catch (IllegalArgumentException ex) {
        IO.println(ex.getMessage());                         // affiche : variable inconnue : y
    }
}

Une branche de switch peut lever une exception avec throw au lieu de produire une valeur. Le cas Var sans garde reste nécessaire : une garde ne compte pas pour l'exhaustivité.

À retenir

  • record = classe de données immuable avec constructeur, accesseurs, equals, hashCode et toString générés ; le constructeur compact valide et normalise.
  • Un record n'est immuable qu'en surface : copie défensive des collections reçues. Parfait pour les DTO, impossible pour les entités JPA.
  • enum = ensemble fixe de singletons, avec champs, constructeur et méthodes ; values(), valueOf(), EnumSet, EnumMap. Stocke le nom, jamais l'ordinal().
  • L'enum Rust à données se traduit par sealed interface + record.
  • switch filtre par type, déstructure les records (même imbriqués), accepte des gardes when et case null.
  • Sur un type scellé, pas de default : le compilateur vérifie l'exhaustivité, comme match en Rust.