Aller au contenu principal

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 :

ModificateurSignification
finalPersonne ne peut hériter de cette sous-classe
sealedSeules certaines classes peuvent hériter (avec permits)
non-sealedN'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
// ...
}
info

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);
attention

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

AspectClasse abstraite classiqueClasse scellée
Sous-classesOuvertes à tousLimitées à permits
Switch exhaustifNécessite defaultPas de default nécessaire
Ajout de sous-classeSilencieux, peut casser le codeErreur de compilation dans tous les switchs
Cas d'usageHiérarchie extensibleHié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);
}
}
}
}
info

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


Depuis quelle version de Java les classes scellées sont-elles disponibles en version stable ?


Quel mot-clé permet de déclarer les sous-classes autorisées dans une classe scellée ?


Quel modificateur doit avoir une sous-classe d'une sealed class qui ne doit pas pouvoir être étendue davantage ?


Quel est l'avantage d'un switch sur une sealed class par rapport à une classe abstraite classique ?


Que se passe-t-il si on tente de créer une classe 'Dragon extends Personnage' alors que Personnage est sealed avec permits Guerrier, Mage, Archer ?


Que signifie le modificateur 'non-sealed' sur une sous-classe d'une sealed class ?


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.


Bonne pratique - sealed pour les hiérarchies fermées

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.


Bonne pratique - final par défaut dans une sealed class

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.


Bonne pratique - Supprimer le default des switchs sur sealed

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é.

📌 Une solution