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, avecequals,hashCodeettoStringgénérésenum: un ensemble fixe d'instances, avec champs et méthodes, etEnumSet/EnumMapsealed+record: l'équivalent d'unenumRust à données- Le pattern matching dans
switch: motifs de type, déstructuration, gardeswhen, exhaustivité - Un exemple complet : un évaluateur d'expressions, comparé à sa version Rust
Les records¶
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()ety()(sans préfixeget) ; equals,hashCodeettoStringbasés sur tous les composants.
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é :
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 :
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
finalet ne peut pas hériter d'une classe (il hérite déjà dejava.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 :
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 ==.
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èveIllegalArgumentException: No enum constant ...Statut.en_cours.new Statut(...)est interdit (enum classes may not be instantiated).- Un
switchsur un enum qui couvre toutes les constantes n'a pas besoin dedefault.
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 :
- 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 explicitementnon-sealed. - Si tous les sous-types sont dans le même fichier,
permitspeut ê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.
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
}
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 :
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
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
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
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,hashCodeettoStringgé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. switchfiltre par type, déstructure les records (même imbriqués), accepte des gardeswhenetcase null.- Sur un type scellé, pas de
default: le compilateur vérifie l'exhaustivité, commematchen Rust.