Al momento stai visualizzando Pattern matching per switch

Pattern matching per switch

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 vedremo una interessante novità introdotta nella versione 17 come caratteristica in anteprima (feature preview), e che con tutta probabilità verrà ufficializzata nella versione 20. Si tratta della seconda parte di una articolata caratteristica nota come pattern matching. Se la prima parte ha modificato in meglio l’utilizzo dell’operatore instanceof (vedi articolo dedicato), la seconda migliora il costrutto switch, in realtà già migliorato nella versione 14 con l’introduzione di una nuova sintassi e la possibilità di utilizzarlo come espressione (vedi articolo dedicato).

Questo post è abbastanza tecnico e richiede la conoscenza di alcune caratteristiche recentemente aggiunte al linguaggio. Se necessario quindi, consigliamo di leggere prima gli articoli sul pattern matching per instanceof, il nuovo switch, le feature preview, ed i tipi sealed, che risultano essere propedeutici alla piena comprensione di quanto riportato di seguito.

 

Pattern matching

Con l’introduzione del pattern matching per instanceof in Java 16, abbiamo definito un pattern come composto da:

  • un predicato: un test che cerca la corrispondenza (matching) di un input con un suo operando. Come vedremo tale operando è un tipo (infatti si parla di type pattern)
  • una o più variabili di binding (dette anche variabili di vincolo o variabili di pattern): queste vengono estratte dall’operando a seconda del risultato del test. Con il pattern matching è stato infatti introdotto un nuovo scope per le variabili, lo scope di binding, che garantisce la visibilità della variabile solo dove il predicato è verificato.

Un pattern è quindi un modo sintetico di esprimere una soluzione complessa.

Il concetto è lo stesso che troviamo alla base delle regular expression. In questo caso però il pattern è basato sul riconoscimento di un certo tipo mediante l’operatore instanceof, e non su una determinata sequenza di caratteri da trovare in una stringa.

 

Il nuovo switch

Il costrutto switch è stato rivisto nella versione 12 come feature preview ed ufficializzato nella versione 14. La rivisitazione del costrutto ha introdotto una sintassi meno verbosa e più robusta basata sull’operatore freccia ->, ed inoltre è possibile utilizzare switch come espressione (in particolare si parla di poli-espressione). Potete approfondire tutti dettagli nell’articolo dedicato. Il costrutto quindi è diventato più potente, utile ed elegante. È rimasto però il vincolo di poter passare ad uno switch in input solo determinati tipi:

  • I tipi primitivi byte, short, int e char.
  • I corrispondenti tipi wrapper Byte, Short, Integer e Character.
  • Il tipo String.
  • Un qualsiasi tipo enumerazione.

In futuro si prevede di rendere il costrutto ancora più utile aggiungendo nuovi tipi alla lista di cui sopra, come i tipi primitivi float, double e boolean. In generale si punta ad arrivare ad uno switch che ci permetta di realizzare la decostruzione degli oggetti. Attualmente è ancora presto per parlarne, ma intanto Java avanza velocemente passo dopo passo e dalla versione 17 è possibile provare in anteprima una nuova versione del costrutto switch, che permette di passare come input un oggetto di qualsiasi tipo. Per entrare in una certa clausola case verrà utilizzato il pattern matching per instanceof.

 

Il nuov(issim)o switch

Passiamo subito ad un esempio, consideriamo il seguente metodo:

public static String getInfo(Object object) {
  if (object instanceof Integer integer) {
    return (integer < 0 ? "Negative" : "Positive") + " integer";
  }
  if (object instanceof String string) {
    return "String of length " + string.length();
  }
  return "Unknown";
}

Esso prende in input un parametro di tipo Object quindi accetta qualsiasi tipo, e sfruttando l’operatore instanceof ritorna una particolare stringa descrittiva. Nonostante il pattern matching per instanceof ci abbia permesso di risparmiare il tipico passaggio che comprendeva la dichiarazione di una reference ed il relativo cast, il codice è comunque ancora poco leggibile, inelegante e soggetto ad errori. Riscriviamo quindi il metodo precedente utilizzando il pattern matching applicato ad una switch expression:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

Il codice è ora più conciso, leggibile, applicabile, funzionale ed elegante, ma analizziamolo con calma.

Notiamo che, diversamente dal costrutto switch che abbiamo sempre utilizzato, nell’esempio precedente la validazione di un certo case non sarà basata sull’operatore di uguaglianza il cui secondo operando è una costante, ma sull’operatore instanceof il cui secondo operando è un tipo.

In pratica verrà eseguito il codice che segue l’operatore freccia -> del case Integer i, se il parametro object è di tipo Integer. All’interno di questo codice la variabile di binding i di tipo Integer punterà allo stesso oggetto a cui punta il reference object.

Invece entreremo nel codice che segue l’operatore freccia -> del case String s se il parametro object è di tipo String. All’interno di questo codice la variabile di binding s di tipo String punterà allo stesso oggetto a cui punta il reference object.

Infine entreremo nella clausola default, nel caso il parametro object non sia né di tipo String né di tipo Integer.

Per poter padroneggiare il pattern matching per switch però, bisogna anche conoscere una serie di proprietà che verranno presentati nei prossimi paragrafi.

Ricordiamo che nelle versioni 17, 18 e 19, questa caratteristica è ancora in anteprima. Questo significa che per compilare ed eseguire un’applicazione che fa uso del pattern matching per switch, bisogna specificare determinati flag come descritto nell’articolo dedicato alle caratteristiche in anteprima.

 

Exhaustiveness

Notare che la clausola default è necessaria per non ottenere un errore in compilazione. Infatti il costrutto switch con il pattern matching annovera tra le sue proprietà la exhaustiveness (nota anche come completeness), ovvero la completezza della copertura di tutte le possibili opzioni. In questo modo il costrutto è più robusto e meno soggetto ad errori.

Per la retrocompatibilità che da sempre caratterizza Java, non è stato possibile modificare il compilatore in modo tale che pretenda la exhaustiveness anche con il costrutto switch originale. Una tale modifica infatti impedirebbe la compilazione a tantissimi progetti preesistenti. È però prevista per le prossime versioni di Java che il compilatore stampi un warning nel caso di implementazioni del “vecchio” switch che non coprono tutti i casi possibili. Tutto sommato gli IDE più importanti già avvertono i programmatori in queste situazioni.

Notare che in teoria potremmo anche sostituire la clausola:

default -> "Unknown";

con l’equivalente:

case Object o -> "Unknown";

Infatti anche in questo caso avremmo coperto tutti le possibili opzioni. Di conseguenza il compilatore non permetterà di inserire entrambe queste clausole nello stesso costrutto.

 

Dominance

Se provassimo a spostare la clausola case Object o prima delle clausole relative ai tipi Integer e String:

public static String getInfo(Object object) {
  return switch (object) {
    case Object o -> "Unknown";
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
  };
}

otterremo i seguenti errori in compilazione:

error: this case label is dominated by a preceding case label
       case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
            ^
error: this case label is dominated by a preceding case label
       case String s -> "String of length " + s.length();
            ^

Infatti, un’altra proprietà del pattern matching per switch nota come dominance, fa sì che il compilatore consideri irraggiungibili i case Integer i e case String s, perché “dominati” dal case Object o. In pratica quest’ultima clausola comprende le condizioni delle successive due che quindi non verrebbero mai raggiunte.

Questo comportamento è molto simile a quello che già conosciamo sussistere quando specifichiamo più clausole catch per gestire le nostre eccezioni. Una clausola catch più generica, potrebbe dominare le clausole catch successive, causando un errore del compilatore. Anche in quel caso è necessario posizionare la clausola dominante dopo le altre.

 

Dominance e clausola default

A differenza delle clausole case ordinarie, la clausola default invece non deve per forza essere inserita come ultima clausola. Infatti è perfettamente legale inserire la clausola default come prima istruzione di un costrutto switch senza alterarne il funzionamento. Il seguente codice viene compilato senza errori:

public static String getInfo(Object object) {
  return switch (object) {
    default -> "Unknown";
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
  };
}

CURIOSITÀ: ciò in realtà vale anche per il costrutto switch classico, ed è anche la ragione per cui si consiglia di inserire un’istruzione break anche nella clausola default. Infatti aggiungendo inavvertitamente una nuova clausola dopo il default e senza il break potremmo provocare un fall-through indesiderato.

 

Guarded pattern

Possiamo anche specificare dei pattern composti con delle espressioni booleane tramite l’operatore &&, ed in questo caso si parla di guarded pattern (e l’espressione booleana viene detta guard). Per esempio possiamo riscrivere l’esempio precedente in questo modo:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i && i < 0 -> "Negative integer"; // guarded pattern
    case Integer i -> "Positive integer";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

Il codice è più leggibile e intuitivo.

Nella versione 19 (terza preview) in base ai feedback degli sviluppatori, l’operatore && è stato sostituito dalla clausola when (nuova parola chiave contestuale). Quindi il precedente codice dalla versione 19 in poi deve essere riscritto in questo modo:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i when i < 0 -> "Negative integer"; // guarded pattern
    case Integer i -> "Positive integer";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

Notare che se invertissimo le clausole riguardanti gli interi nel seguente modo:

case Integer i -> "Positive integer"; //questo pattern "domina" il successivo
case Integer i when i < 0 -> "Negative integer"; 

otterremo un errore di dominance:

error: this case label is dominated by a preceding case label
            case Integer i && i < 0 -> "Negative integer";
                 ^

 

Clausole multiple

Abbiamo già visto nell’articolo dedicato al nuovo switch, come la nuova sintassi basata sull’operatore freccia -> ci permetta di utilizzare delle clausole case multiple per gestire case diversi allo stesso modo. In pratica simuliamo l’utilizzo di un operatore OR || evitando di utilizzare una tecnica tanto controversa come quella del fall-through. Per esempio possiamo scrivere:

Month month = getMonth();
String season = switch(month) {
  case DECEMBER, JANUARY, FEBRUARY -> "winter";
  case MARCH, APRIL, MAY -> "spring";
  case JUNE, JULY, AUGUST -> "summer";
  case SEPTEMBER, OCTOBER, NOVEMBER -> "autumn";
};

La sintassi è notevolmente più sintetica, elegante e robusta.

Quando utilizziamo il pattern matching però la situazione cambia. Nelle clausole del costrutto switch non è possibile utilizzare pattern multipli per gestire tipi diversi allo stesso modo. Per esempio, il seguente metodo:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i, Float f -> "This is a number";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

produrrebbe un errore in compilazione:

error: illegal fall-through to a pattern
            case Integer i, Float f -> "This is a number";
                            ^

Infatti, per il concetto di variabili di binding di cui abbiamo parlato nell’articolo dedicato al pattern matching per instanceof, il codice dopo l’operatore freccia potrebbe utilizzare sia la variabile i che la variabile f, ma una di esse sicuramente non sarà inizializzata. È stato scelto quindi di non rendere questo codice compilabile per avere un costrutto più robusto.

Notare che il messaggio di errore ci evidenzia che questo codice non è valido perché definisce un fall-through illegale. Questo perché il codice precedente è equivalente al seguente che non usando la sintassi basata sull’operatore freccia ->, fa uso del fall-through:

public static String getInfo(Object object) {
  return switch (object) {
    case Integer i: // manca il break: fall-through
    case Float f: 
      yield "This is a number";
    break;
    case String s: 
      yield "String of length " + s.length();
    break;
    default: 
      yield "Unknown";
    break;
  };
}

Ovviamente anche questo codice non è compilabile.

Controllo di nullità

Nel momento in cui è stata introdotta la possibilità di passare un qualsiasi tipo ad un costrutto switch, dovremmo prima controllare che il reference in input non sia null. Invece di far precedere il costrutto switch dal classico controllo di nullità:

if (object == null) {
  return "Null!";
}

possiamo utilizzare una nuova elegante clausola da aggiungere nello switch per gestire il caso in cui il parametro object sia null.

public static String getInfo(Object object) {
  return switch (object) {
    case null -> "Null!"; // controllo di nullità
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
    default -> "Unknown";
  };
}

La clausola case null ci permette di evitare il solito noioso controllo a cui siamo abituati. Questa clausola è opzionale, ma siccome è comunque sempre possibile passare un reference null ad uno switch che fa uso del pattern matching, nel caso non ne inserissimo una esplicitamente, il compilatore ne inserirà una per noi il cui codice lancerà una NullPointerException.

 

Dominance e case null

Notare che, come la clausola default non deve per forza essere l’ultima delle clausole di uno switch, non è necessario che la clausola case null si trovi in cima al costrutto. Di conseguenza anche per tale clausola la regola della dominance non è applicabile. È del tutto legale spostare il case null come ultima riga dello switch, come è legale avere come prima clausola il default senza influire sulla funzionalità del costrutto:

public static String getInfo(Object object) {
  return switch (object) {
    default -> "Unknown";
    case Integer i -> (i < 0 ? "Negative" : "Positive")+ " integer";
    case String s -> "String of length " + s.length();
    case null -> "Null!";
  };
}

Tuttavia questa pratica non è consigliata: meglio mantenere la leggibilità del costrutto seguendo il buon senso e lasciare le varie clausole nelle posizioni in cui ci aspettiamo di trovarle.

Concludendo, l’ordine delle clausole è importante, ma fanno eccezione la clausola default e la clausola per gestire la nullità.

 

Clausole multiple con case null

Il case null è l’unico case che può essere utilizzato in un clausola multipla. Per esempio la seguente clausola è legale:

case null, Integer i -> "This is a number or null";

Più probabilmente accoppieremo il case null con la clausola default:

case null, default -> "Unknown or null";

In questo caso, c’è il vincolo che il case null sia specificato prima della clausola default. Il seguente codice infatti produrrà un errore in compilazione:

default, case null -> "Unknown or null";

 

Exhaustiveness con tipi sigillati

Il concetto di exhaustiveness di cui abbiamo già accennato precedentemente, deve essere rivisto nel caso di gerarchie di tipi sigillati (classi ed interfacce sealed, vedi articolo dedicato). Consideriamo le seguenti classi:

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

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

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

Il seguente codice compila senza errori nonostante non sia stata specificata la clausola default:

public class LettoreOttico {
  public void inserisci(DiscoOttico discoOttico) {
    switch(discoOttico) {
      case CD cd -> suonaDisco(cd);
      case DVD dvd -> caricaMenu(dvd);
    }
  }
  // resto del codice omesso
}

Notare che non è necessario utilizzare la clausola default in casi come questi. Infatti l’utilizzo della classe astratta sealed DiscoOttico ci garantisce che come input questo switch può accettare solamente oggetti di tipo CD e DVD, e che quindi non è necessario aggiungere una clausola default perché tutti i casi sono già stati coperti.

In caso di utilizzo di gerarchie sigillate, è quindi sconsigliato utilizzare la clausola default. Infatti la sua assenza permetterebbe di segnalare eventuali modifiche della gerarchia in fase di compilazione.

Per esempio, proviamo ora a modificare la classe DiscoOttico aggiungendo nella clausola permits la seguente classe BluRay:

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

public final BluRay implements DiscoOttico {
  // codice omesso
}

Se ora proviamo a compilare la classe LettoreOttico otterremo un errore:

.\LettoreOttico.java:3: error: the switch statement does not cover all possible input values switch(discoOttico) { ^

che evidenzia che il costrutto non rispetta la regola della exhaustiveness.

Se invece avessimo inserito anche la clausola default il compilatore non avrebbe segnalato nessun errore.

Notare che se la classe DiscoOttico non fosse stata dichiarata abstract, avremmo potuto passare in input allo switch oggetti di tipo DiscoOttico. Di conseguenza, per la regola della exhaustiveness avremmo dovuto aggiungere anche una clausola per gli oggetti di tipo DiscoOttico. Per la regola della dominance inoltre tale clausola avrebbe dovuto essere posizionata come ultima.

L’alternativa sarebbe stata quella di aggiungere una clausola default.

 

Gestione della compilazione migliorata

Java 17 implementa un sistema di compilazione migliorato per prevenire eventuali problemi di compilazione parziale del codice. Se nell’esempio precedente avessimo compilato solo la classe DiscoOttico e la classe BluRay senza ricompilare la classe LettoreOttico, allora il compilatore avrebbe aggiunto implicitamente una clausola default al costrutto switch di LettoreOttico, il cui codice lancerà una IncompatibleClassChangeError.

In pratica il codice sarebbe stato modificato nel seguente modo:

public class LettoreOttico {
  public void inserisci(DiscoOttico discoOttico) {
    switch(discoOttico) {
      case CD cd -> suonaDisco(cd);
      case DVD dvd -> caricaMenu(dvd);
      default -> throw new IncompatibleClassChangeError(); // codice implicito
    }
  }
  // resto del codice omesso
}

Quindi in casi come questo il compilatore renderà automaticamente il nostro codice più robusto.

 

Conclusioni

In questo articolo abbiamo visto come Java 17 abbia introdotto come feature preview il pattern matching per switch. Questa nuova caratteristica accresce la possibilità di utilizzo del costrutto switch, aggiornandolo con nuovi concetti quali la exhaustiveness, la dominance, i guarded pattern, una clausola per il controllo della nullità, e migliorando la gestione della compilazione. Il pattern matching per switch, rappresenta quindi un altro passo in avanti per il linguaggio, che si avvia a diventare sempre più robusto, complesso, potente e meno verboso. In futuro potremo sfruttare il pattern matching per switch per la decostruzione di un oggetto andando ad accedere alle sue variabili d’istanza. In particolare con una sola riga di codice riconosceremo il tipo dell’oggetto ed accederemo alle sue variabili. Insomma, il meglio deve ancora venire!

 

Note dell’autore

Anche ignorando la maggiore sicurezza che offrono le ultime versioni del JDK, 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.

I contenuti di questo articolo saranno presenti anche nel capitolo extra che a breve sarà pubblicato online su https://www.nuovojava.it, allo scopo di aggiornare il mio ultimo libro “Il nuovo Java” dalla versione 15 alla versione 17.

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

Lascia un commento