Java 8 Parte 2 – Espressioni Lambda

J

Nell’articolo precedente sono state descritte la classe Optional e le interfacce funzionali.

In questo articolo, la trattazione è focalizzata sulle espressioni lambda, che permettono di rendere il codice più semplice e più leggibile.

definizione

Nella programmazione ad oggetti, vengono definite delle classi, che contengono proprietà e metodi. Tra queste, ne esiste una che svolge il ruolo di classe principale, main, che chiama i metodi delle classi subordinate per eseguire delle operazioni. Le subordinate, a loro volta, chiamano metodi di classi più specifiche per l’operazione considerata ottenendo così una catena, dalla classe generale, alla sottoclasse che implementa la specifica richiesta nel modo più opportuno. Il risultato ottenuto viene poi rimandato alla classe main. Un programma è quindi un insieme di classi, che eseguono una serie di comandi.

La programmazione funzionale, invece, è modellata sulla matematica; gli input del programma rappresentano gli argomenti di una funzione e l’output è il valore che questa restituisce. Tutte le funzioni presenti nell’applicazione si comportano come funzioni matematiche, cioè ricevono degli input e restituiscono un output. Un programma è l’applicazione di una singola funzione, che può essere definita in termini di altre funzioni, non si hanno comandi da eseguire.

Una delle tecniche più popolari usata nella programmazione funzionale è rappresentata dalle espressioni lambda, che in Java 8 introducono un nuovo modo di interazione tra le classi. Con il loro uso è possibile definire e chiamare un metodo, senza necessariamente dichiarare una classe che lo contiene.

In realtà, in Java era già presente la capacità di programmazione funzionale ad esempio tramite anonymous inner class o usando librerie Java esterne come Guava.  Ma grazie alle espressioni lambda, lavorare direttamente con le funzioni diventa più semplice.

Il termine lambda indica un insieme di istruzioni che possono essere salvate come variabili, passate a un programma ed eseguite successivamente. Rappresenta un modo più sintetico per esprimere il concetto di anonymous inner class.

Spesso viene usato anche il termine closure per riferirsi alle espressioni lambda, in generale i due concetti non sono del tutto equivalenti.

Lambda deriva dalla branca della matematica, in particolare il  lambda calculus, che si occupa di definire formalmente cosa può o non può essere calcolatoIn questo ambito, l’applicazione di una funzione viene considerata come azione primaria del calcolo e le funzioni sono viste in senso astratto, senza considerazioni su ciò che effettivamente rappresentano, ma concentrandosi sui valori. Portando questi concetti nei linguaggi di programmazione, una funzione viene definita come ciò che può essere trattato come un valore. Di conseguenza un’espressione lambda può essere assegnata ad una variabile, passata come argomento ad un metodo o manipolata come qualsiasi altro valore.

Closure, invece, indica la successiva innovazione di lambda da un punto di vista matematico.

In particolare in Java è rappresentata da anonymous inner class ed è legata al concetto di encapsulation, tecnica che garantisce riusabilità e facile manutenzione del codice. Consiste nel racchiudere all’interno di una classe le sue proprietà, dichiarandole come private e renderle accessibili all’esterno tramite i metodo getter setter. L’interfaccia della classe (ciò che è visibile all’esterno) resta fissa, quindi se si cambia la sua implementazione non si avranno rotture nel codice.

Una closure può racchiudere uno stato e agire su di esso quando viene invocata, nel caso di anonymous inner class si ha una classe che cattura lo stato di alcune variabili in un contesto e lavora su di esse in un altro contesto. Le variabili devono essere dichiarate come final altrimenti si ha errore in fase di compilazione.

In Java 8 i due termini sono equivalanti perchè le espressioni lambda operano come closure: catturano variabili definite nello scope e eseguono operazioni su di esse. Ma a differenza delle closure, con le espressioni lambda non occorre definire le variabili o i parametri usando la keyword final, perchè il compilatore applica implicitamente final a questi oggetti. Si avrà comunque un errore in fase di compilazione se si prova successivamente a modificare il loro valore.

dichiarazione e uso

La definizione di un’espressione lambda è formata da tre parti

  • elenco dei parametri formali, input, tra ( ), separati da virgola. Se c’è un solo input, le parentesi possono essere omesse
  • freccia ->
  • corpo dell’espressione, costituito da una o più istruzioni, racchiuse tra { }.

Nel blocco di codice seguente vengono riportati esempi di dichiarazioni di espressioni lambda

(a,b) -> a+b;

x -> { 
        x += 5; 
       return x; 
      }

Predicate<String> notBlank = s -> s !=null && s.length > 0;

La prima ha due input e un corpo con una sola istruzione. In questo caso, le parentesi { } vengono omesse e il valore calcolato dall’istruzione viene restituito implicitamente senza bisogno di usare return.

La seconda rappresenta un’espressione con un corpo formato da istruzioni multiple, rispetto al caso precedente va notato l’uso delle parentesi { } e del return per indicare quale valore verrà effettivamente restituito.

La terza rappresenta l’uso dell’espressione lambda per definire un predicato. Predicate è una delle nuove interfacce funzionali definite in Java 8, e come spiegato in seguito le espressioni lambda possono essere usate per definire l’implementazione di un’interfaccia funzionale. Nel caso riportato sopra viene definita la proprietà di stringa non vuota: se l’input s è una stringa diversa da null e con lunghezza maggiore di 0, il predicato restituirà true, in caso contrario false.

Un’espressione lambda può essere assegnata ad una variabile o usata per definire un metodo, wrappandola al suo interno. Non ha un tipo esplicito, il suo target type ( tipo che il compilatore Java si aspetta, per definire valido l’assegnamento) è dedotto dal contesto in cui viene usata. Non esiste nel linguaggio il tipo lambda, il compilatore converte l’espressione lambda nell’interfaccia funzionale equivalente.

In particolare, un’espressione lambda è compatibile con un tipo T se

  • T è un’interfaccia funzionale
  • l’espressione lambda ha lo stesso numero di parametri richiesti dal metodo astratto definito da T e i parametri hanno lo stesso tipo
  • il valore restituito dall’espressione lambda è compatibile con il tipo restituito dal metodo astratto definito da T
  • le eventuali eccezioni lanciate dall’espressione lambda sono compatibili con quelle dichiarate dal metodo astratto definito da T

Quando si usa un’espressione lambda wrappata all’interno di un metodo, tramite la definizione inline, non occorre indicare il tipo degli argomenti e dell’output. In questo caso, il compilatore può usare type inference per ricavare il tipo degli input e dell’output dell’espressione e verificare la correttezza del metodo.

Se, invece, si assegna l’espressione lambda direttamente ad una variabile, non si può sfruttare il type inference ed è necessario dichiarare esplicitamente nell’espressione il tipo degli input e dell’output e garantire che siano uguali a quelli richiesti dalla variabile.

E’ sempre preferibile usare la definizione inline, perchè si ottiene un codice più flessibile alle modifiche, in quanto non si assegnano esplicitamente i tipi e si sfrutta, invece, la type inference del compilatore.

espressioni lambda e metodi

Le espressioni lambda possono essere usate per creare metodi, detti anonymous method, oppure possono eseguire chiamate ad altri metodi già definiti

Un’espressione lambda può anche essere formata da un’unica istruzione che esegue la chiamata ad un metodo esistente, come mostrato nel codice seguente, in cui viene stampato a video il contenuto della stringa in input.

Consumer<String> c = s -> System.out.println(s);

In questo caso è possibile usare la method reference, che permette di referenziare un metodo, senza eseguirlo direttamente.

Applicando questa tecnica all’esempio precedente si ottiene

Consumer<String> c = System.out::println;

Nella chiamata al metodo si sostituisce . con :: e non vengono passati i parametri.

In generale è possibile riferirsi ad un metodo di una classe tramite la notazione referenzaClasse::nomeMetodo, oppure nomeClasse::nomeMetodo, nel caso di metodi statici

La method reference può essere applicata a quattro tipi di metodi

  • statici: rappresentano il caso più semplice, si usa la notazione Classe::metodoStatico,  senza passare al metodo degli argomenti. Il compilatore verifica la firma del metodo invocato e tramite type reference costruisce l’appropriata implementazione dell’interfaccia.
    //espressione lambda
    IntFunction<String> intToString = i -> Integer.toString(i);
    
    //method refrence
    IntFunction<String> intToString = Integer::toString;
  • costruttori: per creare una nuova istanza di una classe, si usa la notazione Classe::new, al posto della forma equivalente new Classe(). Occorre fare una distinzione tra costruttori senza argomenti e costruttori con argomenti. Nel primo caso, verrà creata un’istanza dell’interfaccia funzionale Supplier mentre nel secondo caso si ottiene l’istanza dell’interfaccia funzionale Function. Supponiamo di avere una classe Example, con un costruttore senza argomenti e un costruttore che richiede un intero. Nel codice seguente viene mostrato come creare un’istanza della classe, usando i due costruttori.
    //costruttore senza argomenti
    //espressione lambda
    () -> new Example();
    //method reference
    Supplier<Example> example = Example::new;
    
    //costruttore con argomento Integer
    //espressione lambda
    (i) -> new Example(i);
    //method refrence 
    Function<Integer,Example> example = Example::new;

    In entrambi i casi, nella method reference viene chiamato il costruttore Example::new, ma il compilatore applica la type inference e in base al contesto ricava il costruttore corretto da chiamare e crea l’istanza della classe Example.  Nel primo esempio il tipo della variabile è Supplier, quindi viene usato il costruttore senza argomenti, nel secondo caso il tipo della variabile è Function con Integer come input, quindi viene chiamato il costruttore che ha un argomento di tipo Integer.

  • metodi istanza legati al tipo dell’oggetto fornito in seguito: si riferisce alla chiamata di un metodo di un oggetto, che viene passato in input all’espressione lambda. La sintassi usata è Classe::nomeMetodoIstanzaè legato al tipo dell’oggetto
    //espressione lambda
    (s) -> s.toLowerCase()
    //method inference
    Supplier<String> supplier = String::toLowerCase;

    Con l’espressione lambda, si chiama il metodo toLowerCase dall’input s, mentre con method reference, definito l’input di tipo String, si chiama lo stesso metodo, ma facendo riferimento alla classe, quindi al suo tipo, non all’oggetto.

  • metodi istanza di uno specifico oggetto: rappresentano i classici metodi di istanza, l’oggetto è noto a priori e non viene fornito dall’espressione lambda. La sintassi usata è oggetto::nomeMetodoIstanza. 
    Long l = new Long("3L");
    //espressione lambda 
    () -> l.toString();
    
    //method reference
    Supplier<String> supplier = l::toString;

    Viene creato un oggetto di tipo Long e poi viene chiamato il metodo toString. Rispetto al caso precedente, non è un input dell’espressione lambda, il suo valore non è disponibile nello scope locale dell’espressione, ma è definito in uno scope esterno.

interfacce funzionali e classi astratte

In Java 8, espressioni lambda e interfacce rappresentano due sintassi diverse per la stessa funzionalità.

E’ possibile usare espressioni lambda per implementare direttamente interfacce o estendere classi astratte, solo se queste contengono un unico metodo astratto.

Nel caso di un’interfaccia, l’espressione lambda è usata per implementare il suo unico metodo astratto, mentre nel caso di una classe astratta si delega l’implementazione dei metodi astratti all’espressione lambda e poi si passa questa espressione come parametro al costruttore della classe. Questa differenza deriva dal fatto che un’espressione lambda definisce delle function e non dei metodi. Per convertire l’espressione lambda in un’istanza è necessario definire un metodo factory.

Se si hanno interfacce o classi astratte che dichiarano più di un metodo astratto, per implementarle o estenderle tramite espressione lambda è necessario ridurre ad uno il numero di metodi astratti richiesti.

Ad esempio nel caso di un’interfaccia con più metodi astratti, è possibile definire una nuova interfaccia, che estende quella di partenza, con un unico metodo astratto e poi usare questo metodo per fornire un’implementazione di default di tutti gli altri metodi astratti rimasti. La nuova interfaccia può poi essere implementata tramite espressione lambda. Questo meccanismo può essere applicato anche alle classi astratte, in quel caso i metodi sono legati all’espressione lambda, passata poi come parametro al costruttore della classe.

collection e map

ITERAZIONE

Nel linguaggio Java tradizionale, l’operazione di iterazione viene realizzata tramite il costrutto for. In generale, tutte le classi che implementano l’interfaccia Iterable possono usare questo costrutto che crea una struttura in memoria per eseguire, ad ogni iterazione, determinate operazioni sull’elemento corrente dell’elenco.

In Java 8, l’operazione di iterazione è stata resa più efficiente, grazie all’introduzione di due nuovi metodi, forEach forEachRemaining, applicabili ad esempio alle Collections o alle Maps

forEach(Consumer<? super T> action) è un nuovo metodo dell’interfaccia Iterable, è  la versione funzionale del classico ciclo for : fornisce alla Collection/Map una serie di passaggi, che poi verrà applicata in ordine a ciascun elemento.

Nel caso di un oggetto CollectionforEach riceve un oggetto Consumer , che specifica un’azione da eseguire per ogni elemento della Collection.

Nel caso di un oggetto Map, invece, forEach riceve come argomento un oggetto BiConsumer, che rappresenta la coppia (chiave, valore) per ogni elemento presente nella mappa.

In entrambi i casi, forEach non deve essere utilizzato, se nell’iterazione si vuole modificare la struttura sulla quale si sta iterando, perchè potrebbe essere lanciata l’eccezione ConcurrentModification, come accade per il ciclo for. Non c’è la certezza che venga generata l’eccezione, si può assumere che venga effettivamente lanciata solo nel caso peggiore, ma per garantire la scrittura di un codice robusto, si sconsiglia l’uso di for/forEach per le operazioni che comportano delle modifiche alla struttura, in questo caso si deve usare un iteratore.

Quando l’iterazione viene realizzata fornendo un iteratore della lista, si può usare il metodo forEachRemaining(Consumer<? super E> action), dell’interfaccia Iterator, che si comporta come forEach, ma applica l’oggetto Consumer a tutti gli elementi rimasti nella lista

modifica

Quando si modifica una Collection o una Map tramite l’uso di espressioni lambda, il risultato ottenuto sarà una nuova Collection/Map oppure un nuovo oggetto.

Esistono diversi modi per filtrare gli elementi di una Collection o di una Map

Rimuovere determinati oggetti dall’elenco

Per le Collections si usa il metodo removeIf, che rimuove direttamente degli elementi          dalla lista.

Il metodo ha la seguente firma: removeIf(Predicate<? super E> filter), riceve un predicato e rimuove dalla lista tutti gli elementi che soddisfano la condizione espressa dal predicato. Restituisce true se la Collection è stata modificata, altrimenti false.

Per le Maps, Java non fornisce un metodo equivalente, ma è possibile usare removeIf sulle Collections restituite da Map.entrySet(), Map.values() Map.keySet(), che rappresentano rispettivamente la lista delle coppie (chiave,valore), la lista dei valori e la lista delle chiavi memorizzate nella mappa. Modificando una di queste liste si modifica anche la mappa corrispondente

MODIFICARE UN OGGETTO IN BASE AD UNA CONDIZIONE

Per modificare gli elementi di una Collection in base ad una certa condizione, si usa il metodo applyIf, che riceve un predicato e una funzione e applica la funzione solo quando il predicato è vero. Per sostituire nella lista il vecchio elemento con quello modificato si usa List.replaeAll.

Per le Maps è possibile solo modificare i valori e non le chiavi, altrimenti si potrebbero avere dei problemi sulla struttura. Anche in questo caso si può usare il metodo applyIf.

assegnare valore di default IN una mappa

Per assegnare un valore di default a chiavi della mappa che non hanno valore si può usare il metodo computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction), di Map. Se la chiave specificata nel metodo non ha un valore associato oppure è mappata con null, viene calcolato il valore da assegnare tramite la funzione mappingFunction e il risultato viene memorizzato nella mappa.

Il metodo computeIfPresent(K key, BiFunction<? super K,? super V,? extends V> remappingFunction) è usato per assegnare un nuovo valore alla chiave fornita nel metodo, solo se il valore su cui è mappata non è nullo. Rispettando al caso precedente, il nuovo valore viene calcolato dalla funzione remappingFunction, usando sia la chiave che il suo valore attuale. Permette di lavorare su tutti i valori non nulli assegnati alle chiavi della mappa.

Il metodo putIfAbsent(K key, V value) è usato per assegnare alla chiave specificata il valore dato, solo se la chiave non ha un valore mappato o è mappata con null. Il metodo restituisce null se l’associazione (key,value) è stata eseguita, altrimenti restituisce il valore corrente su cui è mappata la chiave

modificare una mappa

In generale, per aggiungere un nuovo elemento ad una mappa si usa il suo metodo put(K key, V value), che associa alla chiave key, il valore value. Se la chiave è già presente nella mappa, il suo valoreverrà sovrascritto.

In Java 8 è possibile modificare una mappa usando due nuovi metodi, compute merge, che permettono di descrivere, tramite una function, come deve essere modificato il valore di una certa chiave.

compute(K key, BiFunction<? super K,? super V,? extends V> remappingFunction) è usato per modificare il valore della chiave key, usando la function definita nel metodo, remappingFunction. Restituisce il nuovo valore assegnato alla chiave o null se la chiave non è mappata. Si usa questo metodo se il nuovo valore da inserire nella mappa è legato solo alla chiave.

merge(K key, V value, BiFunction<? super V,? super V,? extends V> remappingFunction) riceve sia la chiave da mappare che un valore di default. Se la chiave non è mappata o è mappata con null, viene usato il valore value. Altrimenti viene calcolato il nuovo valore da mappare, tramite la funzione remappingFunction, in questo caso il nuovo valore calcolato dipende sia dalla chiave che dal vecchio valore su cui era mappata.

 

A proposito di me

Dario Frongillo

Uno degli admin di Italiancoders e dell iniziativa devtalks.
Dario Frongillo è un software engineer e architect, specializzato in Web API, Middleware e Backend in ambito cloud native. Attualmente lavora presso NTT DATA, realtà di consulenza internazionale.
E' membro e contributor in diverse community italiane per developers; Nel 2017 fonda italiancoders.it, una community di blogger italiani che divulga articoli, video e contenuti per developers.

Gli articoli più letti

Articoli recenti

Commenti recenti