Les classes scellées (Java 17)
Notions théoriques
Le problème de l'héritage ouvert
Avec une classe abstraite classique, n'importe quelle classe peut en hériter :
public abstract class Personnage { ... }
// N'importe qui peut créer :
public class Dragon extends Personnage { ... } // OK ?
public class Vehicule extends Personnage { ... } // Absurde !
public class InconnuteClasse extends Personnage { ... }
Dans un jeu RPG avec un ensemble fermé de types de personnages, on veut contrôler exactement quelles sous-classes existent.
Les classes scellées avec sealed
Le mot-clé sealed (Java 17) permet de limiter l'héritage à une liste explicite de sous-classes autorisées avec permits :
public abstract sealed class Personnage permits Guerrier, Mage, Archer {
// ...
}
Maintenant, seules Guerrier, Mage et Archer peuvent hériter de Personnage. Toute autre tentative provoque une erreur de compilation.
Les modificateurs des sous-classes
Chaque sous-classe permise doit déclarer comment elle gère l'héritage :
| Modificateur | Signification |
|---|---|
final | Personne ne peut hériter de cette sous-classe |
sealed | Seules certaines classes peuvent hériter (avec permits) |
non-sealed | N'importe qui peut hériter (réouvre l'héritage) |
public final class Guerrier extends Personnage { // final : pas de sous-classe
// ...
}
public final class Mage extends Personnage { // final : pas de sous-classe
// ...
}
public non-sealed class Archer extends Personnage { // non-sealed : peut être étendue
// ...
}
final est le choix le plus courant pour les sous-classes d'une hi érarchie scellée. Il garantit que l'ensemble des types est entièrement connu.
Switch expression exhaustif sans default
Le grand avantage des classes scellées : le compilateur connaît toutes les sous-classes possibles. Un switch expression sur une variable Personnage peut donc être exhaustif sans default :
Personnage p = new Guerrier("Thor", 120, 18, "épée");
String description = switch (p) {
case Guerrier g -> g.getNom() + " le guerrier armé de " + g.getTypeArme();
case Mage m -> m.getNom() + " le mage avec " + m.getMana() + " mana";
case Archer a -> a.getNom() + " l'archer avec " + a.getNbFleches() + " flèches";
};
System.out.println(description);
Si vous ajoutez une nouvelle sous-classe à permits sans mettre à jour les switchs, le compilateur génère une erreur à chaque endroit concerné. C'est la grande force de sealed : le compilateur vous force à traiter tous les cas.
Pattern matching + sealed = code sûr
La combinaison de sealed et du pattern matching (Java 16) produit un code très lisible et garanti complet :
void decrire(Personnage p) {
switch (p) {
case Guerrier g -> System.out.println(g.getNom() + " est un guerrier.");
case Mage m -> System.out.println(m.getNom() + " est un mage.");
case Archer a -> System.out.println(a.getNom() + " est un archer.");
// Pas de default nécessaire : le compilateur sait qu'il n'y a que ces 3 cas
}
}
Comparaison avec la hiérarchie ouverte
| Aspect | Classe abstraite classique | Classe scellée |
|---|---|---|
| Sous-classes | Ouvertes à tous | Limitées à permits |
| Switch exhaustif | Nécessite default | Pas de default nécessaire |
| Ajout de sous-classe | Silencieux, peut casser le code | Erreur de compilation dans tous les switchs |
| Cas d'usage | Hiérarchie extensible | Hiérarchie fermée et connue |
Exemple pratique
// Fichier : Personnage.java
public abstract sealed class Personnage permits Guerrier, Mage, Archer {
private String nom;
private int pointsDeVie;
private int force;
public Personnage(String nom, int pointsDeVie, int force) {
this.nom = nom;
this.pointsDeVie = pointsDeVie;
this.force = force;
}
public String getNom() { return nom; }
public int getPointsDeVie() { return pointsDeVie; }
public int getForce() { return force; }
public void subirDegats(int degats) {
this.pointsDeVie = Math.max(0, this.pointsDeVie - degats);
}
public boolean estVivant() { return pointsDeVie > 0; }
@Override
public String toString() {
return getNom() + " [PV: " + getPointsDeVie() + " | Force: " + getForce() + "]";
}
public abstract void attaquer(Personnage cible);
}
// Fichier : Guerrier.java
public final class Guerrier extends Personnage {
private String typeArme;
public Guerrier(String nom, int pointsDeVie, int force, String typeArme) {
super(nom, pointsDeVie, force);
this.typeArme = typeArme;
}
public String getTypeArme() { return typeArme; }
@Override
public void attaquer(Personnage cible) {
int degats = getForce() * 2;
System.out.println(getNom() + " frappe " + cible.getNom() + " (" + typeArme + "). Dégâts : " + degats);
cible.subirDegats(degats);
}
}
// Fichier : Archer.java
public final class Archer extends Personnage {
private int nbFleches;
public Archer(String nom, int pointsDeVie, int force, int nbFleches) {
super(nom, pointsDeVie, force);
this.nbFleches = nbFleches;
}
public int getNbFleches() { return nbFleches; }
@Override
public void attaquer(Personnage cible) {
if (nbFleches > 0) {
int degats = getForce() * 2 + 5;
System.out.println(getNom() + " tire une flèche sur " + cible.getNom() + ". Dégâts : " + degats);
cible.subirDegats(degats);
nbFleches--;
} else {
System.out.println(getNom() + " n'a plus de flèches !");
}
}
}
// Fichier : Main.java
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Personnage> equipe = List.of(
new Guerrier("Thor", 120, 18, "épée runique"),
new Mage("Elara", 80, 12, 60),
new Archer("Lyra", 90, 14, 20)
);
// Switch exhaustif sans default : le compilateur vérifie tous les cas
System.out.println("=== Présentation de l'équipe ===");
for (Personnage p : equipe) {
String description = switch (p) {
case Guerrier g -> g.getNom() + " — Guerrier armé d'un " + g.getTypeArme();
case Mage m -> m.getNom() + " — Mage avec " + m.getMana() + " mana";
case Archer a -> a.getNom() + " — Archer avec " + a.getNbFleches() + " flèches";
};
System.out.println(description);
}
// Combat
Personnage boss = new Guerrier("Dragon Boss", 500, 30, "griffes");
System.out.println("\n=== Combat contre " + boss.getNom() + " ===");
for (Personnage hero : equipe) {
if (hero.estVivant()) {
hero.attaquer(boss);
}
}
}
}
Le switch sur p (type Personnage) sans default compile car Personnage est sealed et le compilateur sait que seuls Guerrier, Mage et Archer sont possibles.
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Vous allez convertir la hiérarchie Personnage en classe scellée et utiliser des switchs exhaustifs pour décrire et faire agir chaque type de personnage.
Étape 1 — Sceller la classe Personnage
Modifiez la déclaration de Personnage pour la rendre sealed avec permits Guerrier, Mage, Archer.
Utilisez sealed chaque fois que vous connaissez et contrôlez toutes les sous-classes possibles. Dans un domaine métier défini (types de personnages, types de paiement, états d'une commande), sealed rend la hiérarchie explicite et sécurisée.
Étape 2 — Rendre Guerrier, Mage et Archer final
Ajoutez le modificateur final à chaque sous-classe.
Dans une hiérarchie scellée, préférez final pour les sous-classes sauf si vous avez une raison explicite de permettre l'héritage. final rend l'intention claire : cette classe est une feuille de la hiérarchie, pas un nœud intermédiaire.
Étape 3 — Switch exhaustif sans default
Dans Main, écrivez un switch expression sur chaque personnage de l'équipe sans default.
L'absence de default n'est pas un oubli : c'est une garantie. Si demain vous ajoutez Paladin à permits, le compilateur identifiera immédiatement tous les switchs à mettre à jour. Avec default, cet ajout passerait silencieusement, le cas Paladin tombant dans le default sans traitement dédié.