Classi e Interfacce Sealed in Java

Concetti Chiave
  • Le classi e interfacce sealed in Java, introdotte in JDK 17, consentono di controllare l'ereditarietà specificando quali classi o interfacce possono estenderle o implementarle.
  • Una classe o interfaccia sealed è dichiarata con la parola chiave sealed e una clausola permits che elenca le classi o interfacce permesse.
  • Le sottoclassi di una classe sealed possono essere dichiarate come final, sealed o non-sealed.
  • Le classi e interfacce sealed sono utili per sviluppatori di librerie API, poiché permettono di limitare l'ereditarietà a un insieme specifico di classi e interfacce.
  • Le classi sealed e le loro sottoclassi possono essere archiviate in un singolo file se hanno accesso al pacchetto predefinito, senza necessità di una clausola permits.

Classi e Interfacce sealed

A partire da JDK 17, è possibile dichiarare una classe che può essere ereditata solo da sottoclassi specifiche.

Tale classe è chiamata sealed o classe sigillata. Prima dell'avvento delle classi sealed, l'ereditarietà era una situazione tutto o niente. Una classe poteva essere estesa da qualsiasi sottoclasse oppure marcata come final, il che impediva completamente che una classe potesse derivare da essa.

Le classi sealed si collocano tra questi due estremi perché consentono di specificare precisamente quali sottoclassi una super-classe permetterà. In modo simile, è anche possibile dichiarare un'interfaccia sealed in cui si specificano solo quelle classi che implementano l'interfaccia e/o quelle interfacce che estendono l'interfaccia sealed. Insieme, le classi e le interfacce sealed offrono un controllo significativamente maggiore sull'ereditarietà, il che può essere particolarmente importante quando si progettano librerie di classi.

Classi sealed

Per dichiarare una classe sealed, precediamo la sua dichiarazione con sealed. Poi, dopo il nome della classe, includiamo una clausola permits che specifica le sottoclassi consentite. Sia sealed che permits sono parole chiave sensibili al contesto che hanno significato speciale solo in una dichiarazione di classe o interfaccia. Al di fuori di una dichiarazione di classe o interfaccia, sealed e permits non vengono considerate come parole chiave e non hanno significato speciale. Ecco un semplice esempio di una classe sealed:

public sealed class MiaClasseSigillata
        permits Alfa, Beta {
    // ...
}

Qui, la classe sealed è chiamata MiaClasseSigillata. Consente solo due sottoclassi: Alfa e Beta. Se qualsiasi altra classe tenta di ereditare MiaClasseSigillata, si verificherà un errore in fase di compilazione.

Ecco Alfa e Beta, le due sottoclassi di MiaClasseSigillata:

public final class Alfa extends MiaClasseSigillata {
    // ...
}

public final class Beta extends MiaClasseSigillata {
    // ...
}

Notiamo che ciascuna è specificata come final. In generale, una sottoclasse di una classe sealed deve essere dichiarata come final, sealed, o non-sealed. Vediamo ciascuna opzione. Prima, in questo esempio, ogni sottoclasse è dichiarata final. Questo significa che le uniche sottoclassi di MiaClasseSigillata sono Alfa e Beta, e non possono essere create sottoclassi di nessuna di queste. Pertanto, la catena di ereditarietà termina con Alfa e Beta.

Per indicare che una sottoclasse è essa stessa sealed, deve essere dichiarata sealed e le sue sottoclassi permesse devono essere specificate. Ad esempio, questa versione di Alfa permette Gamma:

public sealed class Alfa extends MiaClasseSigillata permits Gamma {
    // ...
}

Ovviamente, la classe Gamma deve quindi essere anch'essa dichiarata sealed, final, o non-sealed.

All'inizio potrebbe sembrare un po' sorprendente, ma possiamo rimuovere il sigillo da una sottoclasse di una classe sealed dichiarandola non-sealed. Questa parola chiave sensibile al contesto è stata aggiunta da JDK 17. Sblocca la sottoclasse, consentendole di essere ereditata da qualsiasi altra classe. Ad esempio, Beta potrebbe essere codificata così:

public non-sealed class Beta extends MiaClasseSigillata {
    // ...
}

Ora, qualsiasi classe può ereditare Beta. Tuttavia, le uniche sottoclassi dirette di MiaClasseSigillata rimangono Alfa e Beta. Una ragione principale per usare non-sealed è consentire a una super-classe di specificare un insieme limitato di sottoclassi dirette che forniscono una base di funzionalità ben definite ma permettono a quelle sottoclassi di essere liberamente estese.

Se una classe è specificata in una clausola permits per una classe sealed, allora quella classe deve estendere direttamente la classe sealed. Altrimenti, risulterà un errore in fase di compilazione. Così, una classe sealed e le sue sottoclassi definiscono un'unità logica mutuamente dipendente. Inoltre, è illegale dichiarare una classe che non estende una classe sealed come non-sealed.

Un requisito chiave di una classe sealed è che ogni sottoclasse che permette deve essere accessibile. Inoltre, se una classe sealed è contenuta in un modulo con nome, allora ogni sottoclasse deve essere anch'essa nello stesso modulo con nome. In questo caso, una sottoclasse può essere in un package diverso dalla classe sealed. Se la classe sealed è nel modulo senza nome, allora la classe sealed e tutte le sottoclassi permesse devono essere nello stesso package.

Nella discussione precedente, la super-classe MiaClasseSigillata e le sue sottoclassi Alfa e Beta sarebbero state archiviate in file separati perché sono tutte classi pubbliche. Tuttavia, è anche possibile per una classe sealed e le sue sottoclassi essere archiviate in un singolo file (formalmente, un'unità di compilazione) purché le sottoclassi abbiano accesso package predefinito. In casi come questo, non è richiesta una clausola permits per una classe sealed. Ad esempio, qui tutte e tre le classi sono nello stesso file:

// Poiché tutto questo è in un file,
// MiaClasseSigillata non richiede
// una clausola permits.
public sealed class MiaClasseSigillata {
    // ...
}

final class Alfa extends MiaClasseSigillata {
    // ...
}

final class Beta extends MiaClasseSigillata {
    // ...
}

Un ultimo punto: Anche una classe astratta può essere sealed. Non c'è restrizione a questo riguardo.

Interfacce sealed

Un'interfaccia sealed è dichiarata nello stesso modo di una classe sealed, mediante l'uso di sealed. Un'interfaccia sealed usa la sua clausola permits per specificare le classi autorizzate ad implementarla e/o le interfacce autorizzate ad estenderla. Quindi, una classe che non fa parte della clausola permits non può implementare un'interfaccia sealed, e un'interfaccia non inclusa nella clausola permits non può estenderla.

Ecco un semplice esempio di un'interfaccia sealed che permette solo alle classi Alpha e Beta di implementarla:

public sealed interface MiaInterfacciaSigillata
        permits Alpha, Beta {
    void mioMetodo();
}

Una classe che implementa un'interfaccia sealed deve essa stessa essere specificata come final, sealed, o non-sealed. Per esempio, qui Alpha è marcata non-sealed e Beta è specificata come final:

public non-sealed class Alpha implements MiaInterfacciaSigillata {
    public void mioMetodo() {
        System.out.println("Nel mioMetodo() di Alpha.");
    }
    // ...
}
public final class Beta implements MiaInterfacciaSigillata {
    public void mioMetodo() {
        System.out.println("All'interno del mioMetodo() di Beta.");
    }
    // ...
}

Ecco un punto chiave: Qualsiasi classe specificata nella clausola permits di un'interfaccia sealed deve implementare l'interfaccia. Quindi, un'interfaccia sealed e le sue classi implementatrici formano un'unità logica.

Un'interfaccia sealed può anche specificare quali altre interfacce possono estendere l'interfaccia sealed. Per esempio, qui, MiaInterfacciaSigillata specifica che MiaIF è autorizzata ad estenderla:

// Si noti che MiaIF è aggiunta alla clausola permits.
public sealed interface MiaInterfacciaSigillata permits Alpha, Beta, MiaIF {
    void mioMetodo();
}

Poiché MiaIF fa parte della clausola permits di MiaInterfacciaSigillata, deve essere marcata come nonsealed o sealed e deve estendere MiaInterfacciaSigillata. Per esempio,

public non-sealed interface MiaIF extends MiaInterfacciaSigillata {
    // ...
}

Come ci si potrebbe aspettare, è possibile per una classe ereditare una classe sealed e implementare un'interfaccia sealed. Per esempio, qui Alpha eredita MiaClasseSigillata e implementa MiaInterfacciaSigillata:

public non-sealed class Alpha
        extends MiaClasseSigillata
        implements MiaInterfacciaSigillata {
    public void mioMetodo() {
        System.out.println("Nel mioMetodo() di Alpha.");
    }
    // ...
}

Negli esempi precedenti, ogni classe e interfaccia sono dichiarate public. Quindi, ognuna è nel suo proprio file. Tuttavia, come nel caso delle classi sigillate, è anche possibile per un'interfaccia sealed e le sue classi implementatrici (e interfacce estendenti) essere memorizzate in un singolo file purché le classi e le interfacce abbiano accesso al pacchetto predefinito. In casi come questo, nessuna clausola permits è richiesta per un'interfaccia sealed. Per esempio, qui MiaInterfacciaSigillata non include una clausola permits perché Alpha e Beta sono dichiarate nello stesso file nel modulo senza nome:

public sealed interface MiaInterfacciaSigillata {
    void mioMetodo();
}
non-sealed class Alpha
        extends MiaClasseSigillata
        implements MiaInterfacciaSigillata {
    public void mioMetodo() {
        System.out.println("Nel mioMetodo() di Alpha.");
    }
    // ...
}

final class Beta extends MiaClasseSigillata implements MiaInterfacciaSigillata {
    public void mioMetodo() {
        System.out.println("Nel mioMetodo() di Beta.");
    }
 // ...
}

Un punto finale: Le classi e interfacce sigillate sono più applicabili agli sviluppatori di librerie API in cui sottoclassi e sotto-interfacce devono essere strettamente controllate. Raramente esse sono usate in applicazioni generali. Tuttavia, quando sono utilizzate, le classi e interfacce sigillate possono essere molto utili per garantire che l'ereditarietà sia limitata a un insieme specifico di classi e interfacce.