Block Image

Sebbene Java 17 sia stato rilasciato da più di un anno (14 settembre 2021), ci sono ancora molti progetti relativamente nuovi che utilizzano la versione 11 o addirittura la 8.
Con la recente uscita della versione 3 di Spring Boot, gli sviluppatori sono incentivati al passaggio a Java 17, in quanto la nuova versione di Spring supporta come versione minima di Java proprio la 17.
Spesso il passaggio da Java 11 a Java 17 è abbastanza indolore. Tuttavia, sarebbe più utile utilizzare anche le feature più recenti di questa versione. In questo articolo elenco alcune funzionalità che io trovo essere le più utili.
Molte di queste feature non sono state introdotte in Java 17, ma in versioni successive a Java 11.

Expression Switch

Con l'Expression Switch, lo switch non è più uno statement, ma una espressione.
Possiamo associare un valore di ritorno allo switch!

Esempio:

boolean workDay = switch (dayOfWeek) {
    case SATURDAY, SUNDAY -> false;
    default -> true;
};

Dal codice sopra possiamo vedere che l'espressione dello switch ritorna un valore memorizzato nella variabile workDay.
Il valore di ritorno è preceduto dall'operatore freccia.
Una cosa molto importante dell'Expression Switch sugli Enum, è che devono essere valutati tutti i possibili casi, altrimenti avremo un errore di compilazione. Possiamo bypassare questo vincolo aggiungendo un caso di default.

Block Image
Qui il compilatore dà errore perché non vengono valutati tutti i casi.

Naturalmente possiamo anche eseguire delle istruzioni prima di ritornare un valore, in questo modo:

boolean workDay = switch (dayOfWeek) {
    case SATURDAY, SUNDAY -> {
        System.out.println("do something");
        yield false;
    }
    default -> true;
};

Da notare che non viene utilizzata la keyword return ma la contextual keywords yield.

NullPointerException più chiara

Se in Java 11 avevamo un NullPointerException del genere:

Block Image

non potevamo sapere quale valore della riga 41 fosse null (user oppure getAddress?).
Con Java 17, la stessa eccezione viene loggata specificando esattamente quale campo è null:

Block Image

Exception in thread "main" java.lang.NullPointerException: Cannot invoke 
"com.vincenzoracca.springbootrest.Java17$Address.getCity()" 
because the return value of 
"com.vincenzoracca.springbootrest.Java17$User.getAddress()" is null
at com.vincenzoracca.springbootrest.Java17.simulateNullPointer(Java17.java:76)
at com.vincenzoracca.springbootrest.Java17.main(Java17.java:23)

Grazie alla chiarezza del log di Java 17, sappiamo che a essere null è getAddress.
Se invece user fosse stato null, avremmo avuto questo log:

Exception in thread "main" java.lang.NullPointerException: Cannot invoke 
"com.vincenzoracca.springbootrest.Java17$User.getAddress()" because 
"user" is null
at com.vincenzoracca.springbootrest.Java17.simulateNullPointer(Java17.java:76)
at com.vincenzoracca.springbootrest.Java17.main(Java17.java:23)

Text Block

I blocchi di testo sono utili quando vogliamo valorizzare una stringa in un formato particolare (ad esempio JSON o XML).
Ad esempio in Java 11 se volessimo valorizzare una stringa con un JSON formattato, avremmo scritto questo:

String json = "{\n  \"key\": \"value\" \n}";

L'output è il seguente:

Block Image

Con Java 17, per avere lo stesso output possiamo scrivere il codice in questo modo:

 String json = """
    {
      "key": "value"
    }""";

I blocchi di testo iniziano e terminano con """ e permettono di formattare la stringa così come viene scritta (non abbiamo bisogno ad esempio di inserire caratteri speciali come \n per andare a capo).

Pattern Matching for instanceof

In Java 11, quando è verificata una espressione if contenente la keyword instanceof, siamo costretti a fare un downcast se vogliamo utilizzare la variabile sfruttando il suo sottotipo effettivo, come in questo caso:

if(userObject instanceof User) {
    User user = (User) userObject;
    System.out.println(user.getName());
}

Con Java 17, non siamo costretti a scrivere codice boilerplate:

if(userObject instanceof User user) {
    System.out.println(user.getName());
}

Come mostrato dall'esempio sopra, scriviamo il nome della variabile direttamente nell'espressione.
È possibile utilizzare questa variabile in tutto lo scope in cui è verificato l'instanceof, come ad esempio:

if(userObject instanceof User user && user.getName().equals("Enzo")) {
    System.out.println(user.getName());
}

Naturalmente se invece dell'AND ci fosse stato l'operatore OR, non sarebbe stato possibile utilizzare la variabile user nella seconda condizione, in quanto non è certo che la prima condizione sia verificata.
Possiamo anche utilizzare la variabile user in questo modo:

if(! (userObject instanceof User user) ) {
    //
}
else {
    System.out.println(user.getName());
}

In questo caso l'instanceof è verificato nell'else, quindi possiamo utilizzare la variabile in quello scope.

Records

In Java 17 possiamo utilizzare i record, che non sono altro che delle classi final immutabili. Inoltre il compilatore genera per noi i metodi equals, hashcode, getter (non nella maniera "getVariableName()" ma "variableName()") e toString.
Il classico caso d'uso dei record è nell'utilizzo di DTO, classi che mappano risultati di query sul database oppure classi che mappano eventi.

Ecco un esempio:

public record User(String name, Address address) {}

La stessa classe in Java 11 (al netto del modificatore final) sarebbe scritta in questo modo:

public class User {
    private final String name;
    private final Address address;

    public User(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    public String getName() {
        return name;
    }

    public Address getAddress() {
        return address;
    }

     @Override
     public boolean equals(Object o) {
         if (this == o) return true;
         if (o == null || getClass() != o.getClass()) return false;
         User user = (User) o;
         return Objects.equals(name, user.name) && Objects.equals(address, user.address);
     }

     @Override
     public int hashCode() {
         return Objects.hash(name, address);
     }

     @Override
     public String toString() {
         return "User{" +
                 "name='" + name + '\'' +
                 ", address=" + address +
                 '}';
     }
 }

Con Java 17 abbiamo risparmiato un bel po' di codice, vero?

Per richiamare i valori dei campi name e address, utilizziamo questa sintassi:

System.out.println(user.name());
System.out.println(user.address());

Possiamo personalizzare il costruttore in questo modo:

record User(String name, Address address) {

    public User {
        if(name == null || address == null) {
            throw new IllegalArgumentException();
        }
    }
}

Da notare che il costruttore non ha le parentesi tonde con i due parametri. Possiamo anche scriverlo in quel modo, ma dovremmo poi inizializzare noi i campi, come nella maniera classica.

Possiamo aggiungere altri metodi (di istanza o statici) e sovrascrivere quelli di default (equals, hashcode e toString), aggiungere campi statici e costruttori custom (purché richiamino il costruttore con tutti gli argomenti).

Sealed interfaces/classes

Con le interfacce Sealed possiamo limitare le classi che possono implementare un'interfaccia (lo stesso vale per le Sealed classes). Limitare i sottotipi può essere utile ad esempio quando scriviamo una libreria e non vogliamo che i nostri client possano poter implementare le nostre interfacce con delle loro classi (questo può aumentare la sicurezza della nostra libreria).
In Java 17 possiamo farlo facilmente in questo modo:

public sealed interface Person permits Employee {

}

Nell'esempio sopra, stiamo dicendo che solo la classe Employee può implementare l'interfaccia Person.
La classe Employee sarà scritta in questo modo:

public final class Employee implements Person {

}

In particolare:

  • Employee può essere sia una classe che una interfaccia
  • se Employee non è una classe final, deve necessariamente avere il modificatore sealed (quindi deve avere a sua volta dei sottotipi che la estendono) oppure deve avere il modifica non-sealed (in quel caso può essere esteso da qualsiasi sottoclasse).

Ad esempio Employee potrebbe essere scritta in questo modo:

public non-sealed class Employee implements Person {
// any class can extend it
}
//or
public sealed class Employee implements Person permits ChiefEmployee {
// only the ChiefEmployee class can extend it
}
public final class ChiefEmployee extends Employee {

}

Stream.toList()

In Java 11, quando vogliamo mappare uno stream in una List, dobbiamo richiamare il metodo collect e utilizzare il metodo statico Collectors.toList() (importando la classe Collectors):

import java.util.stream.Collectors;
...
var integers = List.of(1, 2, 3, 4);
var evenNumbers = integers.stream()
        .filter(number -> number % 2 == 0)
        .collect(Collectors.toList());

In Java 17, possiamo utilizzare il metodo toList() di Stream:

var integers = List.of(1, 2, 3, 4);
var evenNumbers = integers.stream()
        .filter(number -> number % 2 == 0)
        .toList();

Considerazioni finali

In questo breve articolo abbiamo visto quali nuove features di Java 17 sfruttare per chi proviene da Java 11.
Ci sono ovviamente anche altre features e altri vantaggi impliciti di Java 17, come ad esempio le migliori performance del Garbage Collector.
Se ritieni che ci siano altre feature utili da sfruttare, scrivilo nei commenti, può essere d'aiuto a tutti!

Articoli su Spring: Spring.
Articoli su Kubernetes: Kubernetes
Articoli su Docker: Docker

Libri consigliati: