Java Sealed Types In Depth
Modella gerarchie di tipi chiuse in Java con classi e interfacce sealed, in combinazione con il pattern matching.
Le classi e le interfacce sealed (introdotte definitivamente in Java 17) permettono a un tipo di dichiarare esattamente quali altri tipi possono estenderlo o implementarlo. Invece di una gerarchia aperta che chiunque può estendere, si definisce un insieme chiuso su cui il compilatore può ragionare. Questa singola garanzia — questi e solo questi — è ciò che rende possibile il pattern matching esaustivo e rende sicuro modellare gerarchie di classi orientate ai dati.
Un tipo sealed è il complemento naturale dei record. I record forniscono i dati; il sealing fornisce l'insieme chiuso di casi. Insieme portano i tipi di dati algebrici (il "tipo somma" che potresti conoscere da Kotlin, Rust o Scala) nel normale Java, e cambiano il comportamento di uno switch su una gerarchia.
Questo capitolo spiega come chiudere un tipo con permits, la scelta obbligatoria tra final, sealed e non-sealed che ogni sottotipo deve effettuare, perché una gerarchia chiusa abilita uno switch esaustivo senza default, e come il sealing si combina con la decostruzione dei record e i pattern con guardie. Si basa su interfacce e ereditarietà.
Chiudere un tipo con permits
Un tipo diventa sealed con il modificatore sealed e una clausola permits che elenca ogni sottotipo diretto. Nessun'altra classe può unirsi alla gerarchia, nemmeno nello stesso package. I sottotipi permessi devono essere accessibili al tipo sealed e, nel modulo senza nome, risiedere nello stesso package (o nello stesso modulo).
public sealed interface Payment
permits Cash, Card, BankTransfer {}
public record Cash(int amount) implements Payment {}
public record Card(String number, int amount) implements Payment {}
public record BankTransfer(String iban, int amount) implements Payment {}Se un sottotipo si trova nello stesso file sorgente, la clausola permits è opzionale — il compilatore la inferisce dal file. È necessario specificare permits esplicitamente solo quando i sottotipi si trovano in file separati.
// Same file: permits is inferred, so it can be omitted.
sealed interface Expr {
record Num(int value) implements Expr {}
record Add(Expr left, Expr right) implements Expr {}
}La regola final, sealed e non-sealed
Ogni sottotipo permesso deve dichiarare come viene chiusa la propria parte della gerarchia. Il compilatore impone una scelta: ogni sottotipo diretto deve essere dichiarato final, sealed o non-sealed. Non esiste un'opzione "non fare nulla" — omettere il modificatore è un errore di compilazione.
| Modificatore | Significato per il sottotipo |
|---|---|
final | Il sottotipo non può essere ulteriormente esteso. I record sono implicitamente final. |
sealed | Il sottotipo è anch'esso chiuso e fornisce la propria lista permits. |
non-sealed | Il sottotipo riapre la gerarchia — chiunque può di nuovo estenderlo. |
public sealed class Shape permits Circle, Polygon, Freeform {}
public final class Circle extends Shape {} // closed here
public sealed class Polygon extends Shape // closed, but to a set
permits Triangle, Rectangle {}
public non-sealed class Freeform extends Shape {} // reopened: any subclass allowed
public final class Triangle extends Polygon {}
public final class Rectangle extends Polygon {}non-sealed è la valvola di sfogo: permette a un ramo di una gerarchia altrimenti chiusa di restare aperto all'estensione. Usarlo con parsimonia, perché rinuncia alla garanzia di esaustività per quel ramo.
Perché il sealing abilita lo switch esaustivo
Il vantaggio di chiudere una gerarchia è che il compilatore conosce la lista completa dei casi. Uno switch su un tipo sealed che copre ogni sottotipo permesso è esaustivo, quindi non si scrive un ramo default. Ancora meglio: se in seguito viene aggiunto un nuovo sottotipo permesso, ogni switch non esaustivo smette di compilare — il compilatore indica il codice che ha dimenticato il nuovo caso.
sealed interface Payment permits Cash, Card, BankTransfer {}
record Cash(int amount) implements Payment {}
record Card(String number, int amount) implements Payment {}
record BankTransfer(String iban, int amount) implements Payment {}
static String fee(Payment p) {
return switch (p) { // no default needed
case Cash c -> "no fee";
case Card c -> "2% card fee";
case BankTransfer b -> "flat fee";
};
}Rimuovere il caso BankTransfer farà sì che il codice non compili: "the switch expression does not cover all possible input values." Questa segnalazione a tempo di compilazione è la ragione principale per chiudere una gerarchia.
Record, decostruzione e guardie
Poiché i sottotipi permessi sono solitamente record, è possibile combinare il sealing con i pattern di decostruzione dei record e i pattern con guardie (when) — vedere pattern matching per la funzionalità completa. La decostruzione associa i componenti del record direttamente nell'etichetta case; una guardia aggiunge una condizione booleana. L'ordine è importante: i casi con guardie più specifici devono precedere il fallback senza guardia per lo stesso tipo.
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
static String describe(Shape s) {
return switch (s) {
case Circle(double r) when r > 10 -> "big circle";
case Circle(double r) -> "circle r=" + r;
case Rectangle(double w, double h) when w == h -> "square";
case Rectangle(double w, double h) -> "rectangle";
};
}Il compilatore considera ancora questo switch esaustivo: ogni sottotipo permesso è abbinato da almeno un'etichetta senza guardia, quindi lo switch è totale anche se alcune etichette hanno guardie.
Un esempio completo
L'esempio eseguibile qui sotto mette insieme tutto: un'interfaccia sealed Shape con tre sottotipi record, uno switch esaustivo per l'area, uno switch con decostruzione e guardie per la descrizione, e uno sguardo ai metadati di sealing tramite reflection. Usa solo il JDK, quindi può essere eseguito così com'è.
Cosa osservare dall'esecuzione:
- Lo
switchdell'area non ha un ramodefault— poichéShapeè sealed, coprire tutti e tre i record è già esaustivo. describestampabig circle r=12.0solo per il cerchio di raggio 12, dimostrando che la guardiawhen r > 10viene valutata prima dell'etichettaCirclesenza guardia.- Il rettangolo con lato 5 stampa
square side=5.0, mostrando che la guardiaw == hprevale sul casoRectanglesemplice che la segue. - L'area totale (525.96) viene accumulata su ogni sottotipo record, confermando che un singolo ciclo polimorfico gestisce l'intera gerarchia chiusa.
Shape.class.isSealed()restituiscetrueegetPermittedSubclasses()elenca Circle, Rectangle e Triangle — l'insiemepermitssopravvive nei metadati a runtime.