CodeGym /Corsi /JAVA 25 SELF /Sealed classes: sintassi e applicazioni

Sealed classes: sintassi e applicazioni

JAVA 25 SELF
Livello 65 , Lezione 0
Disponibile

1. Sintassi delle classi sealed: come appare

Partiamo da un problema classico dell’OOP in Java: gerarchie di ereditarietà aperte. Nella Java tradizionale chiunque può estendere la vostra classe, a meno che non sia dichiarata final. È comodo, ma a volte porta a sorprese — per esempio, non potete sapere in anticipo quali classi saranno effettivamente sottoclassi della vostra, e quindi non potete garantire di aver gestito tutti i casi in switch o if-else.

Di conseguenza, quando scrivete una logica basata sul tipo degli oggetti (ad esempio tramite pattern matching in switch), siete costretti ad aggiungere il ramo default «per ogni evenienza»: e se qualcuno da qualche parte crea una nuova sottoclasse?

Le classi sealed risolvono questo problema: consentono di limitare esplicitamente l’elenco delle sottoclassi. Questo rende la gerarchia chiusa e controllata, e il vostro codice — più prevedibile e sicuro.

Sintassi di base

Le classi sealed sono apparse in Java 17. Si dichiarano con il modificatore sealed, e l’elenco dei sottotipi consentiti si specifica con la parola chiave permits:

public sealed class Shape permits Circle, Rectangle, Square {
    // Comportamento comune per tutte le figure
}

Qui dichiariamo la classe Shape, e solo le classi Circle, Rectangle e Square possono esserne sottoclassi dirette. Nessun altro potrà estendere Shape — il compilatore non lo permetterà.

Importante: tutte le classi indicate in permits devono essere dichiarate nello stesso file o essere visibili al compilatore (di solito nello stesso package). Inoltre, se tutti gli eredi sono dichiarati nello stesso file della classe sealed, si può anche omettere permits — il compilatore capirà da solo.

Esempio:

// Tutto in un unico file - permits non è obbligatorio
public sealed class Shape {
}
final class Circle extends Shape {}
final class Rectangle extends Shape {}

Requisiti per le sottoclassi

Ognuna delle sottoclassi deve dichiarare esplicitamente il proprio stato:

  • essere final (proibisce ulteriori estensioni),
  • oppure essere sealed (e continuare a limitare l’ereditarietà),
  • oppure essere non-sealed (consente l’ereditarietà, rimuovendo i vincoli).

Esempio:

public sealed class Shape permits Circle, Rectangle, Square {}

public final class Circle extends Shape {}
public sealed class Rectangle extends Shape permits FilledRectangle, EmptyRectangle {}
public non-sealed class Square extends Shape {}
  • Circle — finale; non è possibile ereditarne ulteriormente.
  • Rectangle — a sua volta sealed, consente solo due sottoclassi.
  • Squarenon-sealed, chiunque può estenderla.

Esempio minimo

public sealed class Animal permits Dog, Cat {}

public final class Dog extends Animal {}
public final class Cat extends Animal {}

Provate a dichiarare una nuova classe public class Wolf extends Animal {} — otterrete un errore di compilazione:

class Wolf is not allowed to extend sealed class Animal

2. Uso delle classi sealed: dove e perché usarle

Pattern matching e switch

Le classi sealed si combinano particolarmente bene con il pattern matching in switch. Se il compilatore conosce tutte le possibili sottoclassi, può verificare che abbiate gestito ogni variante e non richiederà nemmeno un ramo default.

public sealed interface Result permits Success, Error {}

public final class Success implements Result {
    public final String data;
    public Success(String data) { this.data = data; }
}

public final class Error implements Result {
    public final String message;
    public Error(String message) { this.message = message; }
}

public class Main {
    public static void main(String[] args) {
        Result result = new Success("Evviva!");
        switch (result) {
            case Success s -> System.out.println("Successo: " + s.data);
            case Error e -> System.out.println("Errore: " + e.message);
        }
    }
}

Il compilatore sa che non esistono altre varianti per Result e non richiede default. Se dimenticate di gestire una delle varianti, il compilatore ve lo segnalerà subito.

Attenzione: a partire da Java 21, se in switch non sono gestite tutte le varianti, otterrete un errore di compilazione. Nelle versioni precedenti (17–20) può essere richiesto default, ma l’IDE avviserà comunque della copertura incompleta.

Sicurezza e controllo

Le classi sealed permettono allo sviluppatore di controllare completamente la gerarchia. Questo è particolarmente importante per i modelli di dominio, dove l’insieme delle varianti deve essere fisso (ad esempio, lo stato di un ordine: New, Paid, Cancelled).

Semplificazione della manutenzione e dell’evoluzione del codice

Quando conoscete tutte le varianti delle sottoclassi, è più semplice aggiungere nuove funzionalità, mantenere il codice e fare refactoring. Anche l’IDE «conoscerà» tutte le varianti e potrà aiutare con l’autocompletamento e l’analisi.

3. Esempi pratici di utilizzo delle classi sealed

Esempio 1: Figure geometriche

public sealed interface Shape permits Circle, Rectangle, Square {}

public final class Circle implements Shape {
    public final double radius;
    public Circle(double radius) { this.radius = radius; }
}

public final class Rectangle implements Shape {
    public final double width, height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
}

public final class Square implements Shape {
    public final double side;
    public Square(double side) { this.side = side; }
}

Ora è possibile usare in sicurezza switch con pattern matching:

Shape shape = new Circle(5);
switch (shape) {
    case Circle c -> System.out.println("Cerchio con raggio " + c.radius);
    case Rectangle r -> System.out.println("Rettangolo " + r.width + "x" + r.height);
    case Square s -> System.out.println("Quadrato con lato " + s.side);
}

Esempio 2: Transazioni finanziarie

public sealed interface Transaction permits Deposit, Withdraw, Transfer {}

public final class Deposit implements Transaction {
    public final double amount;
    public Deposit(double amount) { this.amount = amount; }
}

public final class Withdraw implements Transaction {
    public final double amount;
    public Withdraw(double amount) { this.amount = amount; }
}

public final class Transfer implements Transaction {
    public final double amount;
    public final String toAccount;
    public Transfer(double amount, String toAccount) {
        this.amount = amount;
        this.toAccount = toAccount;
    }
}

Ora, nell’handler, potete essere certi di non dimenticare alcun tipo di transazione:

Transaction tx = new Transfer(100, "ACC123");
switch (tx) {
    case Deposit d -> System.out.println("Deposito: " + d.amount);
    case Withdraw w -> System.out.println("Prelievo: " + w.amount);
    case Transfer t -> System.out.println("Trasferimento: " + t.amount + " verso " + t.toAccount);
}

Esempio 3: Gerarchia limitata con non-sealed

public sealed class Notification permits EmailNotification, SmsNotification, PushNotification {}

public final class EmailNotification extends Notification {}
public non-sealed class SmsNotification extends Notification {}
public final class PushNotification extends Notification {}

// Ora chiunque può estendere SmsNotification
public class ViberNotification extends SmsNotification {}

4. Caratteristiche, limitazioni e particolarità delle classi sealed

Requisiti dei modificatori

  • Una classe sealed deve indicare esplicitamente tutte le sottoclassi tramite permits.
  • Tutte le sottoclassi devono essere final, sealed oppure non-sealed.
  • Le sottoclassi devono essere dichiarate nello stesso file o essere visibili al compilatore.

Classi sealed astratte

Una classe sealed può essere astratta, un interface o una classe normale. Per esempio:

public sealed abstract class Expr permits Const, Add, Mul {}

Compatibilità con altri modificatori

  • Non si può dichiarare una classe sealed come final o non-sealed.
  • Anche un interface può essere sealed (ed è molto comodo!).

Uso con i record

Le classi record possono essere sottotipi di una classe sealed, se sono dichiarate final (per impostazione predefinita un record è sempre final):

public sealed interface Expr permits Const, Add, Mul {}

public record Const(int value) implements Expr {}
public record Add(Expr left, Expr right) implements Expr {}
public record Mul(Expr left, Expr right) implements Expr {}

5. Aspetti utili

Classi sealed e pattern matching: che relazione c’è

La caratteristica principale delle classi sealed è l’esaustività del pattern matching. Suona impegnativo, ma funziona in modo semplice. Il compilatore conosce tutte le varianti possibili e potete scrivere tranquillamente switch senza il ramo default:

Expr expr = ...;
switch (expr) {
    case Const c -> ...
    case Add a -> ...
    case Mul m -> ...
}

Se non gestite una delle varianti, il compilatore non lascerà compilare il progetto — è molto comodo e sicuro.

Applicazione in casi reali

Dove sono davvero utili le classi sealed? Ovunque ci sia un insieme fisso di varianti:

  • Risultato di un’operazione: Success, Error (come nell’esempio sopra).
  • Stato di un ordine: New, Paid, Cancelled.
  • Alberi di sintassi astratta (AST) per i parser.
  • Risposte API: Ok, NotFound, Error.
  • Eventi nel sistema: UserLoggedIn, UserLoggedOut, UserRegistered.

6. Errori tipici nel lavoro con le classi sealed

Errore n. 1: avete dimenticato di elencare tutte le sottoclassi in permits.
Se non elencate tutte le sottoclassi richieste tramite permits, il compilatore si lamenterà subito. Per esempio, se avete scritto permits Circle, Rectangle, ma avete dimenticato Square e tale classe esiste — otterrete un errore.

Errore n. 2: la sottoclasse non è final, sealed o non-sealed.
Se la sottoclasse non è dichiarata con il modificatore richiesto, il compilatore genererà un errore: «Class must be either final, sealed or non-sealed».

Errore n. 3: la sottoclasse è dichiarata in un altro file e non è visibile al compilatore.
Tutte le classi elencate in permits devono essere accessibili al compilatore — o nello stesso file, o nello stesso package.

Errore n. 4: non avete gestito tutte le varianti in switch.
Se usate una classe sealed in switch con pattern matching e dimenticate di gestire una variante, il compilatore non lascerà compilare il progetto. È un bene — non vi sfuggirà alcun caso.

Errore n. 5: tentativo di estendere una classe sealed non presente in permits.
Se provate a creare una sottoclasse non indicata in permits, otterrete l’errore: «is not allowed to extend sealed class».

Errore n. 6: tentare di usare le classi sealed su vecchie versioni di JDK.
Le classi sealed sono disponibili solo da Java 17. Se provate a usarle in una versione precedente, otterrete un errore di compilazione o non riuscirete affatto a costruire il progetto.

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION