Contattaci
Lasciaci i tuoi riferimenti, saremo felici di contattarti il prima possibile e organizzare una consulenza gratuita.
Programmazione Funzionale Java
Cos’è la programmazione funzionale
Iniziamo col vedere alcuni esempi di paradigmi di programmazione.
Programmazione imperativa
Il programma viene visto come una sequenza dettagliata di istruzioni da eseguire. Lo sviluppatore deve istruire il programma su cosa deve fare e su come farlo.
Programmazione dichiarativa
Approccio più ad alto livello rispetto alla programmazione imperativa in cui il programma viene visto come una serie di istruzioni meno dettagliate. In questo approccio, lo sviluppatore specifica cosa deve essere fatto ma non pone vincoli su come deve essere fatto.
Programmazione funzionale
Paradigma di programmazione che vede le funzioni come elementi fondamentali, al pari degli oggetti e delle variabili. Nella programmazione funzionale, possono essere definite delle funzioni (anonime o meno) che possono essere passate come argomento ad altre funzioni oppure si possono avere funzioni che restituiscono come risultato altre funzioni.
Programmazione orientata agli oggetti
Object Oriented Programming (OOP), basata sul concetto di oggetto, visto come entità base per la rappresentazione dell’informazione, con un suo stato e con i suoi comportamenti.
Programmazione orientata agli eventi
Paradigma che prevede che il comportamento del software sia determinato dal verificarsi di determinati eventi, più che da una sequenza precisa di istruzioni.
Costrutti di base della programmazione funzionale
Introduciamo ora alcuni costrutti di base della programmazione funzionale, come lambda expressions, interfacce funzionali, Java Stream API e Method reference
Lambda expressions
Cosa sono? Come funzionano? Dove vengono usate?
Le lambda expressions sono costrutti introdotti in Java a partire dalla versione 8 che forniscono un modo per esprimere istanze di interfacce funzionali e scrivere codice più conciso.
Le lambda expressions generalmente prevedono:
- lista argomenti tra parentesi tonde
- operatore freccia/lambda
- corpo di istruzioni tra parentesi graffe
(x, y) -> {
istruzione1
…
return z;
}
La lista degli argomenti può anche essere vuota e, nel caso in cui la funzione debba solo restituire un valore, possono essere omesse l’istruzione return e le graffe.
() -> z
Buone pratiche e consigli
- Usarle solo quando è necessario o conveniente, non introdurle a forza nel codice anche quando non conviene;
- Limitarne il più possibile la lunghezza;
- Evitare il passaggio come argomento di una funzione anonima espressa tramite una lambda expression se quelle stesse operazioni possono essere riutilizzate altrove nel codice;
- Usare dei nomi comprensibili per i parametri.
PRO (se usate correttamente)
- Possono migliorare la leggibilità del codice
- Permettono la scrittura di codice più sintetico e conciso
- Permettono la scrittura di funzioni anonime
CONTRO (se usate impropriamente)
- Espressioni lambda troppo lunghe o complesse possono complicare significativamente la leggibilità e la manutenibilità del codice
- L’utilizzo improprio delle lambda può portare ad una duplicazione del codice evitabile
Interfacce funzionali
Cosa sono, come funzionano, dove vengono usate e quali sono quelle più usate?
Le interfacce funzionali sono state introdotte in Java 8 per supportare la programmazione funzionale. Si definisce interfaccia funzionale un’interfaccia che contiene la dichiarazione di un unico metodo astratto.
Possono essere presenti nell’interfaccia anche altri metodi statici o di default ma è importante che ci sia un unico metodo astratto.
Grazie alle interfacce funzionali, è possibile utilizzare le espressioni lambda e la referenziazione dei metodi (method reference) in modo più efficiente.
Consumer
L’interfaccia Consumer di Java è un’interfaccia funzionale che rappresenta un’operazione che prevede un oggetto in input ma nessun risultato in output.
x -> {
istruzione1
…
istruzioneN
}
I Consumer vengono utilizzati per compiere delle azioni sull’oggetto ricevuto in input o a partire da esso.
L’interfaccia Consumer mette a disposizione un metodo accept() che, quando chiamato, permette di svolgere le azioni contenute nel blocco di istruzioni proprie del Consumer, definite per mezzo di una lambda expression.
Supponiamo di voler definire un Consumer che, dato un numero intero, stampi il valore di questo numero e il suo quadrato.
Definiamo quindi una variabile Consumer<Integer> dove il generic Integer indica il tipo di oggetto trattato dal Consumer.
Il comportamento del Consumer viene descritto da una lambda expression in cui il valore in input è l’oggetto contenuto nel Consumer, un intero x, e il corpo di istruzioni contiene le azioni da far svolgere al Consumer sulla base dell’input ricevuto.
In questo caso, il Consumer stampa prima il valore intero ricevuto in input e poi il suo quadrato.
Quando il Consumer dovrà essere effettivamente utilizzato, con le operazioni in esso definite, occorrerà chiamare il metodo accept() passandogli in input l’oggetto che il Consumer dovrà utilizzare.
Esistono alcune specializzazioni dell’interfaccia Consumer che permettono di gestire implicitamente determinati tipi di oggetti.
Con queste specializzazioni di Consumer, non è più necessario specificare il tipo del generic tra le parentesi angolari.
Esistono anche i BiConsumer, che sono Consumer che accettano in input due oggetti e non restituiscono nulla in output.
BiConsumer<String,Integer> bc = (s, i) -> {…};
Supplier
L’interfaccia Supplier di Java è un’interfaccia funzionale che rappresenta un’operazione che non prevede un oggetto in input ma ne restituisce uno in output.
() -> {
istruzione1
…
istruzioneN
return x;
}
oppure
() -> x
I Supplier vengono utilizzati per fornire un’istanza di un oggetto in output.
L’interfaccia Supplier mette a disposizione un metodo get() che, quando chiamato, permette di ottenere l’oggetto risultato del Supplier.
Come per le altre interfacce funzionali, il comportamento del Supplier è definito da una lambda expression.
Supponiamo di voler definire un Supplier che restituisca in output un numero intero casuale.
Definiamo quindi una variabile Supplier<Integer> dove il generic Integer indica il tipo di oggetto restituito dal Supplier.
Il Supplier non necessita di oggetti in input, quindi si hanno le parentesi vuote, e restituisce in output un intero causale calcolato con il metodo Math.random() di Java.
Quando si rende necessario utilizzare realmente il Supplier, occorre chiamare il metodo get(), che restituisce il risultato della lambda expression.
Predicate
L’interfaccia Predicate di Java è un’interfaccia funzionale che permette di valutare una condizione più o meno complessa, dato un oggetto in input, e che restituisce un valore booleano in output.
x -> {
istruzione1
…
istruzioneN
return true/false;
}
oppure
x -> true/false
I Predicate ricevono in input un oggetto e, sulla base di questo oggetto, valutano una condizione specificata nella lambda expression e restituiscono il booleano risultato di questa condizione.
L’interfaccia Predicate mette a disposizione un metodo test() che, quando chiamato, restituisce true o false a seconda che la condizione venga rispettata o meno.
Come per le altre interfacce funzionali, il comportamento del Predicate è definito da una lambda expression.
Supponiamo di voler definire un Predicate che valuti se un numero intero sia maggiore di 10.
Viene definita una variabile Predicate<Integer> dove il generic Integer indica il tipo di oggetto ricevuto in input e valutato nella condizione.
Il Predicate necessita di un oggetto in input e restituisce in output un booleano, risultato della condizione valutata. La lambda parte da un intero x e valuta se questo sia maggiore di 10, restituendo poi il risultato.
Quando si rende necessario utilizzare realmente il Predicate, occorre chiamare il metodo test()passandogli in input l’oggetto del Predicate, che restituisce il risultato della lambda expression.
Esistono alcune specializzazioni dell’interfaccia Predicate che permettono di gestire implicitamente determinati tipi di oggetti.
Con queste specializzazioni di Predicate, non è più necessario specificare il tipo del generic tra le parentesi angolari.
Esistono anche i BiPredicate, che sono Predicate che accettano in input due oggetti e non restituiscono nulla in output.
BiPredicate<String,Integer> bp = (s, i) -> {…};
Function
L’interfaccia Function di Java è un’interfaccia funzionale che permette di definire una generica funzione che, dato in input un oggetto, ne restituisca un altro in output
x -> {
istruzione1
…
istruzioneN
return y;
}
oppure
x -> y
Le Function ricevono in input un oggetto, svolgono determinate operazioni e restituiscono in output un altro oggetto come risultato.
L’interfaccia Function mette a disposizione un metodo apply() che, quando chiamato, esegue le azioni definite dalla lambda expression e restituisce un altro oggetto in output.
Come per le altre interfacce funzionali, il comportamento delle Function è definito da una lambda expression.
Supponiamo di voler definire una Function che prenda in input una stringa e ne restituisca in output la trasformazione in caratteri minuscoli.
Viene definita una variabile Function<String,String> dove il primo generic String indica il tipo di oggetto ricevuto in input e il secondo generic String indica il tipo di oggetto restituito in output.
La Function necessita di un oggetto in input e restituisce in output un altro oggetto. La lambda parte da una stringa s e restituisce la stinga convertita in caratteri minuscoli.
Quando si rende necessario utilizzare realmente la Function, occorre chiamare il metodo apply()passandogli come argomento l’oggetto da far elaborare alla Function, che restituisce il risultato della lambda expression.
Esistono alcune specializzazioni dell’interfaccia Function che permettono di gestire implicitamente determinati tipi di oggetti:
- BiFunction
- UnaryOperator
- BinaryOperator
BiFunction
Una BiFunction è una specializzazione dell’interfaccia funzionale Function che prevede in input due oggetti e restituisce un terzo oggetto in output.
BiFunction<Integer,Integer, String> bf = (x, y) -> {
istruzione1
….
istruzioneN
return s;
}
UnaryOperator
L’interfaccia UnaryOperator di Java è un’interfaccia funzionale che costituisce una specializzazione di Function e permette di definire una generica funzione che, dato in input un oggetto, ne restituisca un altro in output con la particolarità che input e output devono avere lo stesso tipo (è quindi necessario specificare un solo generic al momento della dichiarazione).
UnaryOperator<Integer>unOp = x -> {
…
return y;
}
Similmente ad altre interfacce funzionali (es. Predicate), anche per gli UnaryOperator esistono ulteriori specializzazioni che permettono di gestire implicitamente determinati tipi di valori.
Supponiamo di voler definire un UnaryOperator che, dato un numero intero, ne restituisce il valore moltiplicato per 100.
L’UnaryOperator viene definito per mezzo di una lambda che, preso in input l’intero, ne restituisce il valore moltiplicato per 100.
Quando l’UnaryOperator deve essere utilizzato, occorre chiamare su di esso il metodo apply()passandogli in input un valore intero, che verrà elaborato dalla lambda.
Vediamo ora come svolgere lo stesso compito ma con le specializzazioni dell’UnaryOperator e valori numerici di tipo Integer, Long e Double.
BinaryOperator
L’interfaccia BinaryOperator di Java è un’interfaccia funzionale che costituisce una specializzazione di Function e permette di definire una generica funzione che, dato in input due oggetti, ne restituisca un terzo in output con la particolarità che input e output devono avere lo stesso tipo (è quindi necessario specificare un solo generic al momento della dichiarazione).
BinaryOperator<Integer>biOp = (x, y) -> {
…
return z;
}
Similmente agli UnaryOperator, anche per i BinaryOperator esistono ulteriori specializzazioni che permettono di gestire implicitamente determinati tipi di valori.
Supponiamo di voler definire un BinaryOperator che, dati due numeri interi, ne restituisca la somma.
Il BinaryOperator viene definito per mezzo di una lambda che, presi in input due interi, ne restituisce la somma (anch’essa di tipo intero).
Quando il BinaryOperator deve essere utilizzato, occorre chiamare su di esso il metodo apply()passandogli in input due valori interi, che verranno elaborati dalla lambda.
Vediamo ora come svolgere lo stesso compito ma con le specializzazioni del BinaryOperator e valori numerici di tipo Integer, Long e Double.
Java Stream API
Cosa sono, come funzionano, dove vengono usate e quali sono i metodi più usati?
Le Java Stream API sono delle funzioni introdotte a partire dalla versione 8 di Java che mettono a disposizione una serie di operazioni ad alto livello per eseguire operazioni divario tipo su aggregazioni di dati (es. collections) tra cui:
- ciclo
- filtraggio
- trasformazione
- aggregazione
A partire da un’aggregazione di elementi (come può essere ad esempio una Map, una List, un Set o una qualunque altra forma di istanza di Collection), si apre uno Stream, ovvero un oggetto che rappresenta la sequenza di elementi presenti nella Collection su cui vogliamo operare.
Su questo Stream è poi possibile chiamare i metodi delle Java Stream API per leggere, modificare o elaborare gli elementi del suddetto Stream.
ForEach
Il metodo forEach() delle Java Stream API permette di eseguire una determinata azione su ogni elemento di uno Stream.
Il metodo richiede come argomento un Consumer che descriva le azioni da svolgere su ogni elemento dello Stream.
Ad esempio, supponiamo di voler ciclare su una Collection di stringhe e di voler stampare ogni elemento.
Dopo aver definito una lista di stringhe, si apre uno Stream su di essa usando il metodo stream() dopodiché si chiama sullo Stream il metodo forEach() passandogli come argomento un Consumer definito per mezzo di una lambda.
Tale lambda, dato ogni elemento s dello Stream, lo stampa.
Filter e collect
Il metodo filter() delle Java Stream API, dato uno Stream di partenza, permette di generare un secondo Stream contenente tutti gli elementi del primo che soddisfano una certa condizione, espressa per mezzo di un Predicate.
Ad esempio, supponiamo di voler estrarre da una lista di stringhe solo quelle che iniziano con la lettera M.
Dopo aver aperto uno Stream su una lista di stringhe, si chiama il metodo filter() su di esso passandogli come argomento un Predicate con la condizione da verificare, espressa in questo caso per mezzo di una lambda.
Per convertire lo Stream restituito da filter() in una List, si può usare si di esso il metodo collect(Collectors.toList()).
Map
Il metodo map() delle Java Stream API permette di applicare una Function ad ogni elemento di uno Stream. Tale funzione viene usata per generare un secondo Stream contenente gli elementi risultanti dall’applicazione di tale funzione.
Ad esempio, supponiamo di avere a disposizione una lista di stringhe e di voler ottenere una lista di interi che indicano la lunghezza di ogni elemento dello Stream di partenza.
Dopo aver aperto uno Stream su una lista di stringhe, si chiama il metodo map() su di esso passandogli una Function come argomento, espressa in questo caso per mezzo di una lambda.
Tale Function prende in input ogni stringa dello Stream di partenza e restituisce in output la sua lunghezza. Lo Stream risultante sarà quindi uno Stream di Integer.
Si vede quindi che map() può essere utilizzato anche per «trasformare» il tipo di oggetti che sista considerando nello Stream.
Count e distinct
Il metodo count() delle Java Stream API restituisce il numero di elementi presenti in uno Stream.
Il metodo distinct(), invece, dato uno Stream, permette di generarne un secondo contenente gli elementi del primo senza duplicati.
Ad esempio, supponiamo di avere a disposizione una lista di stringhe con duplicati e di volerne contare gli elementi dopo aver rimosso i duplicati.
Dopo aver aperto uno Stream su una lista di stringhe, si chiama il metodo distinct() su di esso per generare un secondo Stream contenente i soli elementi del primo senza duplicati.
Dopodiché si chiama count() che restituisce il numero di elementi dello Stream generato da distinct() sotto forma di valore numerico long.
Sorted
Il metodo sorted() delle Java Stream API permette di generare uno Stream contenente gli elementi dello Stream su cui è stato chiamato ordinati secondo uno specifico criterio.
Ad esempio, supponiamo di avere a disposizione una lista di stringhe e di volerla ordinare in ordine alfabetico inverso.
Dopo aver aperto uno Stream su una lista di stringhe, si chiama il metodo sorted() su di esso. passandogli come argomento un Comparator che indichi il tipo di ordinamento che va applicato agli elementi dello Stream.
In caso non venga specificato alcun Comparator, sorted() applicherà l’ordinamento naturale sulla base del tipo di elementi trattati, posto che ve ne sia uno.
Si ottiene quindi un secondoStream contenente gli elementi di quello di partenza ordinati secondo il criterio stabilito.
AnyMatch, allMatch e noneMatch
Il metodo anyMatch() delle Java Stream API permette di verificare che almeno un elemento delloStream soddisfi una certa condizione, espressa per mezzo di un Predicate.
Analogamente allMatch() verifica se tutti gli elementi dello Stream soddisfano una certa condizione, mentre noneMatch() verifica che nessuno di essi la soddisfi.
Ad esempio, supponiamo di avere a disposizione una lista di stringhe e di voler verificare se almeno una di esse inizi per M, se tutte iniziano per M e se nessuna di esse inizia per M.
Dopo aver aperto uno Stream su una lista di stringhe, si chiama il metodo anyMatch() su di esso passandogli come argomento un Predicate che descriva la condizione da verificare. Il metodo restituisce true/false a seconda dell’esito della valutazione.
Essendoci nello Stream almeno una stringa che inizia per M, il metodo restituisce true.
I metodi allMatch() e noneMatch() funzionano in modo del tutto analogo.
Method reference
Con method reference si indica una funzionalità di Java introdotta a partire dalla versione 8 che consente di riferirsi ai metodi (in determinate circostanze) con una sintassi più concisa rispetto anche alle espressioni lambda.
Il method reference viene spesso utilizzato congiuntamente alle Java Stream API in sostituzione delle espressioni lambda.
Supponiamo di avere una lista di stringhe e di volerle stampare tutte.
Usando le Java Stream API e le espressioni lambda, potremmo scrivere un frammento di codice del genere.
Sfruttando il method reference, potremmo rendere la scrittura ancora più sintetica e concisa, facendo riferimento al metodo statico println().
Si indica quindi il nome della classe seguito dall’operatore di riferimento :: e dal nome del metodo da utilizzare, senza specificare gli argomenti.
NomeClasse::nomeMetodo
Il risultato può essere interpretato in questo modo:
- Si apre uno Stream sulla lista di stringhe.
- Si chiama il metodo forEach() che, come spiegato in precedenza, vuole come argomento un Consumer.
- Per ogni stringa s dello Stream, si chiama System.out.println() passandogli come argomento proprio s.
Occorre notare che, usando il method reference al posto di una lambda, bisogna fare in modo che il tipo di input della lambda sia compatibile in numero e tipo con la lista dei parametri del metodo referenziato.
In altre parole, sfruttando sempre l’esempio precedente, abbiamo che la lambda prende in input una stringa s data da forEach(), che è compatibile con la lista dei parametri che è possibile passare a System.out.println().
Pertanto la lambda è sostituibile con il method reference.