Al momento stai visualizzando Tipi sealed e limitazione dell’ereditarietà

Tipi sealed e limitazione dell’ereditarietà

Secondo alcuni sondaggi come quello di JetBrains, la versione 8 di Java è al momento quella più utilizzata in assoluto dagli sviluppatori di tutto il mondo, nonostante si tratti di una release del 2014. Quello che state leggendo è un articolo che fa parte di una serie intitolata “Superare Java 8”, ispirata ai contenuti del mio ultimo libro “Il nuovo Java”. Questi articoli accompagneranno passo dopo passo il lettore all’esplorazione delle più importanti caratteristiche introdotte a partire dalla versione 9. L’obiettivo è quello di far acquisire la consapevolezza di quanto è importante aggiornare le proprie conoscenze relative a Java, spiegando gli enormi vantaggi che offrono le ultime versioni del linguaggio.

In questo articolo parleremo di una importante novità introdotta nella versione 15 come caratteristica in anteprima (feature preview, vedi relativo articolo), ed ufficializzata definitivamente come caratteristica standard con Java 17. Tale caratteristica è nota con il nome di definita come Sealed Classes (che in italiano possiamo tradurre come classi sigillate), ma che in realtà riguarda anche le interfacce, e che quindi preferiamo chiamare Sealed Types (ovvero tipi sigillati). Ora possiamo dichiarare classi ed interfacce imponendo alcuni limiti sulla loro estensione/implementazione. Prima dell’avvento di questa caratteristica, potevamo solamente impedire ad una classe di essere estesa dichiarandola final (oppure dichiarando tutti i suoi costruttori privati), ora invece possiamo decidere da quali classi può essere estesa. Questo permette maggiore controllo sull’ereditarietà, aprendo la via ad altre importanti caratteristiche come il pattern matching per il costrutto switch di cui accenneremo alla fine di questo articolo e che approfondiremo presto in un articolo dedicato.

 

Classi sealed

Una sealed class (in italiano classe sigillata) è caratterizzata dal fatto che nella sua dichiarazione specifica da quali sottoclassi deve essere estesa direttamente. Questo lo fa tramite il nuovo modificatore sealed, e la clausola definita dalla nuova parola contestuale permits. Per esempio, se volessimo scrivere un programma che si occupa di gestire il funzionamento di un lettore ottico che può leggere solo CD e DVD, potremmo definire la classe DiscoOttico in modo tale che possa essere estesa solo dalle classi CD e DVD, nel seguente modo:

public sealed class DiscoOttico permits CD, DVD {
  // codice omesso
}

Si noti che per specificare la lista delle classi che possono estendere la classe sealed DiscoOttico, abbiamo usato proprio la clausola definita dalla parola chiave permits. Con tale clausola possiamo specificare i nomi delle sottoclassi separate da virgole. Esistono però dei vincoli che queste devono rispettare.

 

Primo vincolo

Il primo vincolo impone alle classi che estendono una classe sealed, di definire uno tra i modificatori final, sealed e non-sealed. Sappiamo già che l’utilizzo del modificatore final implicherà che la sottoclasse non potrà essere più estesa. Se invece anche la sottoclasse fosse dichiarata sealed, allora anche tale sottoclasse dovrebbe dichiarare la clausola permits, specificando le uniche classi che devono estenderla. Infine, se non vogliamo utilizzare i vincoli che implicano l’utilizzo di sealed o final, sarà obbligatorio utilizzare il modificatore non-sealed. Tale modificatore serve quindi solo per marcare le sottoclassi ordinarie (ovvero che possono quindi essere estese senza particolari vincoli), quando esse estendono classi sealed. Per il nostro esempio abbiamo scelto di dichiarare la classe CD come non-sealed:

public non-sealed class CD extends DiscoOttico {
  // codice omesso
}

e la classe DVD come final:

public final class DVD extends DiscoOttico {
  // codice omesso
}

Quindi possiamo ancora estendere la classe CD a nostro piacimento, mentre non è possibile estendere la classe DVD.

Secondo vincolo

Un altro vincolo che abbiamo quando dichiariamo una classe sealed, è quello che le sue sottoclassi devono risiedere nello stesso package della superclasse. Nel caso stessimo scrivendo un’applicazione modulare, le sottoclassi potrebbero anche risiedere in package diversi, che però devono essere definiti all’interno dello stesso modulo.

Terzo vincolo

Un altro vincolo per le classi dichiarate sealed, è quello che bisogna sempre dichiarare la clausola permits, a meno che le sottoclassi vengano dichiarate nello stesso file della superclasse. In tal caso il compilatore automaticamente eleggerà le sottoclassi contenute nello stesso file, come uniche sottoclassi della classe sealed. Per esempio, potremmo creare un file chiamato DiscoOtticoSealed.java dove possiamo scrivere:

sealed class DiscoOttico /* permits CD, DVD */ {
  // codice omesso
}

final class DVD extends DiscoOttico {
  // codice omesso
}

non-sealed class CD extends DiscoOttico {
  // codice omesso
}

Notare come la clausola permits sia stata commentata e che le classi DVD e CD siano state elette implicitamente ad uniche sottoclassi dirette della classe DiscoOttico.

 

Interfacce sealed

Il modificatore sealed può essere utilizzato anche per le interfacce, i vincoli e la sintassi sono gli stessi che abbiamo presentato per le classi. Rispetto ad una classe sealed però, un’interfaccia sealed, può specificare sia da quali sottoclassi può essere implementata, sia da quali interfacce può essere estesa. In particolare le interfacce sealed possono essere estese anche da tipi record. Infatti questi ultimi non possono estendere classi ma possono implementare interfacce. I tipi record hanno il vantaggio di essere implicitamente dichiarati final, quindi non dovremo preoccuparci di utilizzare un altro modificatore quando li dichiariamo (come visto nella sezione Primo vincolo). Come esempio, consideriamo il seguente record Articolo:

public record Articolo(String descrizione, double peso) implements Pesabile {
    public double getPeso() {
        return peso;
    }
}

Creiamo anche il record Imballaggio che conterrà un articolo. Il peso totale dell’imballaggio sarà calcolato dalla somma del peso dell’imballaggio più la somma del peso dell’articolo:

public record Imballaggio(Articolo articolo, double peso) implements Pesabile {
    public double getPeso() {
        return peso + articolo.getPeso();
    }
}

Ora consideriamo la seguente interfaccia Pesabile dichiarata sealed:

public sealed interface Pesabile permits Imballaggio, Articolo {
    String UNITA_DI_MISURA = "kg";
    double getPeso();
}

L’interfaccia Pesabile ha ristretto a sue sottoclassi solo i record Articolo e Imballaggio.

Abbiamo quindi creato una gerarchia completa (sigillata) con poche righe di codice.

Come per le enumerazioni, anche i record sono dichiarati implicitamente final, e non è possibile usare il modificatore abstract. Quindi, nel momento in cui implementiamo un’interfaccia in un record, dovremo implementare obbligatoriamente tutti i metodi ereditati.

 

Facciamo evolvere l’esempio

Ora proviamo a modificare l’esempio rendendolo più interessante. Modifichiamo l’interfaccia Pesabile permettendo ad essa di aggiungere come possibile estensione un’enumerazione di nome Misura:

public sealed interface Pesabile permits Imballaggio, Articolo, Misura {
    String UNITA_DI_MISURA = "kg";
    double getPeso();
}

Segue l’enumerazione Misura, che implementa l’interfaccia sealed Pesabile:

public enum Misura implements Pesabile {
    SMALL(0.05), MEDIUM(0.1), LARGE(0.5), EXTRA_LARGE(0.07);
    private double peso;
    Misura(double peso) {
        this.peso = peso;
    }
    public double getPeso() {
        return peso;
    }
}

Modifichiamo il record Imballaggio cambiando l’header, definendo come variabili d’istanza un’enumerazione Misura e un varargs di oggetti Pesabile:

public record Imballaggio(Misura misura, Pesabile... pesabili) implements 
                                                                      Pesabile {
    public double getPeso() {
        double pesoComplessivo = misura.getPeso();
        for (Pesabile pesabile : pesabili) {
            pesoComplessivo += pesabile.getPeso();
        }
        return pesoComplessivo;
    }

    @Override
    public String toString() {
        String descrizione = "Imballaggio Misura: " + misura + "\n" ;
        descrizione += "Contenuto:\n" ;
        for (Pesabile pesabile : pesabili) {
            descrizione += "\t"+pesabile.toString()+ "\n" ;
        }
        return descrizione;
    } 
}

Si noti che abbiamo riscritto il metodo getPeso in modo che venga calcolato sommando il peso dell’imballaggio in base alla misura e il peso di tutti gli oggetti pesabili inclusi in esso. Un discorso simile lo sfruttiamo nell’override del metodo toString, che stamperà in maniera personalizzata un oggetto di tipo Imballaggio.

Aggiungiamo anche al record Articolo un metodo toString:

public record Articolo(String descrizione, double peso) implements Pesabile {
    public double getPeso() {
        return peso;
    }

    @Override
    public String toString() {
        return descrizione;
    }
}

È interessante notare quanto sia stato semplice in termini di numero di righe di codice (nonostante le personalizzazioni dei record), creare questa gerarchia che sfrutta bene il polimorfismo. In particolare, possiamo notare che abbiamo utilizzato un’enumerazione, un’interfaccia sealed e due record. Solo in uno di questi record (Imballaggio) abbiamo dovuto scrivere un minimo di logica sfruttando due cicli foreach, per ciclare sul varargs di oggetti di tipo Pesabile. Con i giusti strumenti il codice Java è stato astratto correttamente e velocemente.

 

Classe del main

Basta guardare il seguente esempio che sfrutta il polimorfismo con i tipi che abbiamo definito:

public class Bilancia {
    public static void main(String args[]) {
        var scarpe = new Articolo("Scarpe", 0.8);
        var cavoUSB = new Articolo("Cavo USB", 0.1);
        var imballaggioCavoUSB = new Imballaggio(Misura.SMALL, cavoUSB);
        var occhiali = new Articolo("Occhiali", 0.2);
        var imballaggioLarge = new Imballaggio(Misura.LARGE, scarpe, occhiali, imballaggioCavoUSB);
        pesa(imballaggioLarge);
    }

    public static void pesa(Pesabile pesabile) {
        System.out.println(pesabile + "Peso:");
        System.out.println(pesabile.getPeso());
    }
}

Nel metodo main  abbiamo istanziato vari oggetti.

Si noti, che abbiamo usato la parola var per tutti i reference definiti. Avremmo potuto anche usare come tipo dei reference Pesabile per ogni istanza, ma tutto sommato var in questo caso ci è sembrata la scelta più adatta.

Abbiamo istanziato tre articoli (scarpe, cavoUSB, e occhiali), due imballaggi (imballaggioCavoUSB e imballaggioLarge). Nell’oggetto imballaggioCavoUSB, abbiamo passato come varargs di oggetti Pesabile, solo l’oggetto cavoUSB. Con questa composizione abbiamo astratto il concetto che il cavo USB è stato inserito in un imballaggio apposito di piccole dimensioni (SMALL). Nell’oggetto imballaggioLarge invece, abbiamo inserito come varargs di oggetti Pesabile, gli oggetti scarpe, occhiali, e imballaggioCavoUSB, che a sua volta contiene l’oggetto cavoUsb. È possibile inserire un imballaggio all’interno di un altro imballaggio, perché anche Imballaggio implementa Pesabile. Infine, viene pesato l’oggetto imballaggioLarge che contiene tutti gli altri, e viene stampato il suo peso. L’output sarà il seguente:

Imballaggio Misura: LARGE
Contenuto:
    Scarpe
    Occhiali
    Imballaggio Misura: SMALL
Contenuto:
    Cavo USB

Peso:
1.65

 

Quando usare i tipi sealed

Il modificatore sealed serve a limitare l’ereditarietà e di conseguenza il polimorfismo. Progettare le nostre classi limitando l’ereditarietà, è un’ottima idea visto che l’estensione di una classe implica una forte relazione di dipendenza tra la superclasse e la sottoclasse. Tale relazione a sua volta implica che l’evoluzione di una gerarchia di classi deve essere sempre gestita globalmente. Infatti, modificare un metodo di una superclasse potrebbe modificare anche il comportamento delle sottoclassi. Inoltre le nostre classi potrebbero essere estese in maniera inappropriata (per esempio senza considerare il test “is a” che convalida l’ereditarietà). Possiamo quindi utilizzare i tipi sealed per progettare gerarchie semplici e in contesti ben noti. Per tale ragione è consigliato estendere i tipi sealed con classi dichiarate final o magari con record. Da un certo punto di vista, l’introduzione del modificatore sealed dovrebbe anche aiutarci a progettare gerarchie più semplici e robuste. La sintassi inoltre garantisce maggiori informazioni sul tipo, aggiungendo maggiore leggibilità al nostro codice e quindi migliorandone la qualità totale.

Abbiamo visto che possiamo usare il modificatore sealed solo per classi ed interfacce. È evidente invece che non è possibile dichiarare sealed gli altri tipi Java come le enumerazioni e i record, in quanto tali tipi sono dichiarati implicitamente final. Ovviamente non è possibile dichiarare sealed neanche le annotazioni.

Invece il modificatore sealed può essere tranquillamente utilizzato per le classi astratte. Per esempio, sarebbe auspicabile dichiarare astratta la classe DiscoOttico:

public abstract sealed class DiscoOttico permits CD, DVD {
  // codice omesso
}

Questa non è stata la nostra scelta iniziale per non distrarre il lettore dall’argomento principale di questo articolo.

 

Conseguenze dell’introduzione dei tipi sealed

La Reflection API si è arricchita con due nuovi metodi aggiunti nella classe java.lang.Class:

  • boolean isSealed(): che restituisce true se la classe (o l’interfaccia) è dichiarata con il modificatore sealed.
  • Class[] getPermittedSubclasses: che ritorna un array delle oggetti di tipi Class che rappresentano le classi (o le interfacce) specificate nella clausola permits.

Da Java 16 in poi, il termine parola chiave contestuale (contextual keyword) va a sostituire le precedenti definizioni di identificatore ristretto (restricted identifier) e parola chiave ristretta (restricted keyword) introdotti a partire dalla versione 9 di Java. Una parola chiave contestuale rispetto ad una classica parola chiave di Java (come tutte quelle definite sino alla versione 8), non ha lo stesso vincolo per quanto riguarda gli identificatori. Infatti è possibile usare la parola sealed come identificatore di variabili, metodi, package e moduli. L’unico vincolo rimane quello di non poter utilizzare questa parola come identificatore per una classe o un altro tipo Java. Tuttavia, vista la convenzione utilizzata per gli identificatori dei tipi che ci “obbliga” ad usare utilizzare la prima lettera maiuscola, questo vincolo non dovrebbe mai impattare sul codice scritto in precedenza.

La conseguenza più interessante è però relativa all’introduzione di una uova feature preview chiamata “Pattern matching for switch” nella versione 17, a cui dedicheremo un articolo a breve.

 

Esempio di pattern matching per il costrutto switch

Potremmo scrivere un metodo che contiene questo codice usando il pattern matching per instanceof (vedi relativo articolo):

static void test(Pesabile p) {
    if (p instanceof Articolo a) {
        System.out.println("Articolo");
    } else if (p instanceof Imballaggio i) {
        System.out.println("Imballaggio");
    } else if ((p instanceof Misura m)) {
        System.out.println("Misura");
    }         
}

Considerando la definizione del nuovo costrutto switch (vedi relativo articolo) grazie ai tipi sealed possiamo riscrivere il metodo test precedente nel seguente modo:

static void test(Pesabile p) {
    switch (p) {
      case Articolo a -> System.out.println("Articolo ");
      case Imballaggio i -> System.out.println("Imballaggio");
      case Misura m -> System.out.println("Misura");    
    }
}

Notare come il costrutto switch si sia evoluto per testare anche il tipo dell’oggetto in input e modificando la sintassi della clausola case in modo tale venga specificato un tipo e un reference (per esempio Articolo a). In questo caso non c’è neanche bisogno di aggiungere la clausola default visto che il compilatore conosce a priori tutte le sottoclassi dirette di Pesabile, e può controllare che esse non siano estese.

Siccome il pattern matching per switch è una feature preview in Java 17, per compilare ed eseguire questo codice bisogna abilitare le feature preview come spiegato nell’articolo relativo.

 

Conclusioni

In questo articolo abbiamo mostrato cosa sono i tipi sealed e come si utilizzano le nuove parole chiave contestuali sealed, non-sealed e permits. Abbiamo visto anche alcuni esempi di nuovo codice Java e l’impatto di questa nuova caratteristica su quanto esisteva prima (nuovi metodi per la classe Class, nuovo modo per progettare le nostre gerarchie, pattern matching per switch). In particolare, classi ed interfacce sealed rappresentano quindi un nuovo (ennesimo) passo in avanti per il linguaggio. Al netto della possibilità di estendere le funzionalità del costrutto switch con il pattern matching (per ora solo in preview), la più grande utilità dei tipi sealed dal nostro punto di vista è quella di rappresentare un importante strumento per progettare delle gerarchie di classi ed interfacce coerenti con i principi della programmazione object oriented e con il dominio in cui si sta sviluppando.

 

Note dell’autore

Anche ignorando la maggiore sicurezza e le migliori prestazioni che offrono le ultime versioni di Java, esistono tantissimi motivi per aggiornare le proprie conoscenze di Java, o quantomeno le proprie installazioni del runtime di Java. Il mio ultimo libro “Il nuovo Java”, a cui si ispira la serie “Superare Java 8”, contiene tutte le informazioni per poter imparare Java da zero, ed utilizza un metodo didattico ben collaudato e perfezionato in 20 anni di esperienza, che rende l’apprendimento semplice e appassionante. Inoltre è strutturato per approfondire gli argomenti ed avere una conoscenza superiore che può fare la differenza per la vostra carriera lavorativa.

Questo articolo è ispirato principalmente ai paragrafi 6.2.4.4. e 6.4.4 del capitolo 6, ed al paragrafo 9.2.4.2 del capitolo 9 del libro “Il nuovo Java”.

Per maggiori informazioni visitate https://www.nuovojava.it.

 

Lascia un commento