Introduzione
Dopo circa un anno e mezzo dal rilascio (marzo 2014) gli strumenti sono oramai abbastanza maturi e in questo articolo esploreremo aspetti pratici di alcune novità introdotte per lavorare con le funzioni anonime, confrontandoci con quello che si sarebbe fatto quando c’era solo Java 7 a disposizione.
Una nota personale
Utilizzo Eclipse Luna e lavoro con una JDK 1.8 da più di un anno: lo installai presto perché ero curioso di vedere come funzionassero le funzioni Lambda in Java, e trascorsi diverso tempo per riuscire a riportare nella nuova versione dell’IDE tutte le configurazioni e i plug-in che utilizzavo su Kepler. Poi però, forse perché nel setup dell’ambiente avevo esaurito tutto il tempo a disposizione, o forse perché, raggiunto l’obiettivo di far funzionare tutti i plug-in su Luna, mi ero ormai dimenticato del perché lo avevo fatto… fino a qualche settimana fa non ho mai dedicato un attimo per fare qualche prova con le nuove funzionalità del linguaggio.
Da poco tempo ho migrato a Eclipse Mars, la seconda versione dell’ambiente di sviluppo su cui il supporto alla nuova sintassi di Java 8 è disponibile in modo nativo, e mi sono allora ricordato della curiosità che un anno fa mi aveva spinto a provare Luna. Ispirato da questa riflessione, ci ho riprovato; da questo “esperimento” è nata l’idea di scrivere questo articolo, che si presenta come un’esplorazione guidata ed è indirizzato agli sviluppatori curiosi che, per qualunque motivo, ancora non hanno provato a scrivere e usare le nuove funzioni anonime.
Funzioni anonime, funzioni di ordine superiore e stile di programmazione funzionale
Con la nuova sintassi Lambda si possono definire in Java direttamente delle funzioni anonime. Questa caratteristica è oggi disponibile in moltissimi altri linguaggi di programmazione: l’elenco è lungo e per farvi una idea date una occhiata alla pagina inglese di Wikipedia [1]. Questa feature prende il nome dal lambda-calcolo [2], una teoria matematica degli anni Trenta con la quale difficilmente uno sviluppatore Java come noi si troverà mai ad avere a che fare.
La sintassi introdotta in Java 8 è pensata per esserci utile e pratica quando adoperiamo funzioni di ordine superiore [3], ossia funzioni che prendono come argomento un’altra funzione: si usano funzioni di ordine superiore per ordinare delle collezioni, per eseguire un task programmato, per implementare una applicazione MapReduce; magari le avrete già usate in pratica, ma senza accorgervene. La tipica soluzione Java prevede di usare oggetti speciali che avvolgono la funzione (un Comparator, un Runnable, un Mapper…): con le funzioni anonime possiamo risparmiarci questo overhead.
Non solo Lambda…
Ma, sotto un ottica funzionale, ci sono altre novità interessanti in Java 8. Sono state introdotte le stream operations, per lavorare con le collezioni Java: non introducono nuovi costrutti sintattici, piuttosto sono implementate attraverso una specie di Java DSL disponibile a partire da una Collection e permettono di definire passi di computazione con uno stile nuovo, proprio della programmazione funzionale, come si farebbe in Scala o in Haskell. Senza scendere nei complessi dettagli della nozione, sappiate solo che uno Stream di Java 8 è una monade.
Stile di programmazione funzionale
Ci si potrebbe chiedere come mai oggi sia così “di moda” lo stile di programmazione funzionale e perché le novità di Java 8 si siano spinte in questa direzione. Una importante spiegazione è questa: tipicamente la programmazione orientata agli oggetti ci spinge a modellare oggetti che hanno uno stato, e le nostre soluzioni si articolano in istruzioni che variano lo stato di questi oggetti; invece una programmazione funzionale si concentra sulla valutazione di funzioni minimizzando il mutare dello stato di oggetti.
In un mondo informatico fatto di processori multicore e sistemi distribuiti dove è necessario aumentare il parallelismo per sfruttare al massimo la potenza di calcolo disponibile, evitare la complessità della sincronizzazione di oggetti dovuta alla concorrenza offre significativi vantaggi di gestione del codice.
A ben guardare, si può seguire uno stile funzionale in Java [4] senza necessariamente aver bisogno di Lambda e si possono implementare funzioni di ordine superiore passando classi anonime, ottenendo sostanzialmente gli stessi risultati. Anzi, come vedremo dopo, anche in Java 8 con Lambda non succede qualcosa di molto diverso. E proprio per questo che allora mi sono chiesto se Lambda in Java 8 non fosse soltanto “zucchero sintattico” (syntactic sugar) per “addolcire” qualcos’altro: ho scoperto che il compilatore Java è capace di effettuare delle interessanti ottimizzazioni quando si adopera la nuova sintassi, ma in effetti per lo sviluppatore comune le novità sono esclusivamente un “dolcificante” e si possono scrivere soluzioni equivalenti anche solo con Java 7. Nonostante questo, Java 8 avrà forte impatto: accelererà l’inarrestabile avanzata dello stile di programmazione funzionale e forse non avremo bisogno di imparare Scala per cercare il prossimo lavoro.
Al lavoro!
Per farci quindi un’idea di cosa ci aspetta, esploriamo le novità scrivendo un’applicazione di esempio usando Lambda e streams: implementeremo questa applicazione in diverse forme, e poi ripeteremo l’implementazione usando solo Java 7 per comprendere tramite confronto. Sia in Java 8 che in Java 7, useremo uno stile funzionale e implementeremo l’applicazione con le funzioni di ordine superiore Map e Reduce.
Giochiamo con Lambda
Ne prosieguo dell’articolo “giocheremo” un po’ con la nuova sintassi. Tutto il codice mostrato è pubblicato su github [5] e, se volete, potete scaricarlo per seguire l’articolo con il codice sotto mano nel vostro IDE.
La struttura del progetto
Il progetto contiene due classi: la prima è LambdaGame.java e per compilarla è necessario avere una JDK 1.8 o superiore. L’altra classe, LambdaGameWithoutLambda.java invece potete compilarla anche con Java 7: se volete provare, usate maven e compilate il progetto utilizzando il profilo java7; con questo profilo, la classe LambdaGame.java sarà esclusa dalla compilazione per evitare errori. Il codice nei file .java presenta gli esempi che tratteremo e si sviluppa linearmente dall’alto verso il basso.
Un semplice caso di Map e Reduce
Come caso di esempio, implementiamo una semplice applicazione che conta il numero di consonanti su una lista di stringhe. Data una lista di stringhe:
List<String> list;
contiamo in modo procedurale il numero di consonanti totali:
Integer out = 0; for (String name : list) { String consonants = name.replaceAll(“[aeiou]”, ““); int length = consonants.length(); out += length; }
Per semplicità in input, avremo parole intere minuscole e la soluzione può essere descritta così: si eliminano da ogni parola le vocali, si prende la lunghezza delle stringe risultanti e si sommano.
Applicare al caso lo stile funzionale
Il problema può essere risolto in stile funzionale, come illustra la figura 1, applicando due passi di mapping e uno di riduzione
In Java 8 lo si può esprimere in questo modo:
Integer out = list.stream() // process with a stream .map((p) -> p.replaceAll(“[aeiou]”, “”)) // strip all (lower case) vowels from each name .map((a) -> a.length()) // count the length of the remaining word .reduce((a, b) -> a + b) // sum each of the previously computed lengths .get(); // get the result
Nel precedente estratto di codice si è usata per tre volte la sintassi Lambda, con la lista degli argomenti racchiusi tra parentesi, e l’arrow token e il corpo della funzione Lambda passati come argomenti alle funzioni map() e reduce().
Voi siete qui
Siamo saltati da una implementazione procedurale direttamente ad una implementazione che usa la sintassi Lambda. Come siamo arrivati dalla prima alla seconda? Vediamolo a ritroso, passo dopo passo.
Innanzitutto dovete sapere che il compilatore usa la Type Inference e si può riscrivere la precedente porzione di codice con dichiarazione esplicita dei tipi delle funzioni anonime:
Integer out = list.stream() .map((String p) -> p.replaceAll(“[aeiou]”, ““)) .map((String a) -> a.length()) .reduce((Integer a, Integer b) -> a + b) .get();
Le funzioni Lambda possono essere assegnate a variabili:
Integer out = list.stream() .map(devowelizerMapper) .map(lengthMapper) .reduce(accumulator) .get();
E l’assegnazione di queste tre variabili si scrive nel seguente modo:
BinaryOperator<Integer> accumulator = (a, b) -> a + b; Function<String, Integer> lengthMapper = (a) -> a.length(); Function<String, String> devowelizerMapper = (p) -> p.replaceAll(“[aeiou]”, ““);
La prima sorpresa è il tipo di queste variabili: non si tratta di una nuova categoria di keyword Java; i tipi dichiarati a sinistra dei nomi sono infatti delle classiche interfacce, e quindi l’assegnazione di una funzione anonima crea degli oggetti.
Interfacce funzionali
Le interfacce a cui si assegna una lambda devono essere delle interfacce funzionali, ossia devono essere annotate con @java.lang.FunctionalInterface ed essere composte di un unico metodo che rispetti la stessa firma della funzione Lambda definita. Se si sbaglia qualcosa, la compilazione dà origine a degli errori interessanti che vi presento più avanti.
In Java 8 sono state introdotte diverse interfacce funzionali predefinite, disponibili sotto al package java.util.function.*. Inoltre, nostre vecchie conoscenze, come java.lang.Runnable e java.util.Comparator, sono state trasformate in interfacce funzionali e adesso possono essere usate per assegnare loro espressioni Lambda. In Java 8 si può quindi scrivere per esempio:
Runnable r = () -> System.out.println(“Hello world”);
ed usarla come siamo soliti usare le Runnable definite come classi anonime.
Stream
Torniamo sull’altro aspetto del nostro codice di esempio, ossia l’aver utilizzato i Java 8 Streams per elaborare in modo sequenziale i dati.
Puntualizziamo che il nome potrebbe confondere, ma i Java 8 Stream non hanno niente a che fare con InputStream e OutputStream che esistono in Java da sempre: questi nuovi stream si trovano sotto il package java.util.stream.* e sono una novità. Vediamo come interagiscono nella nostra applicazione di esempio, separando ogni passo dall’altro con un piccolo refactoring:
Stream<String> stream = list.stream(); Stream<String> devowelizedStream = stream.map(devowelizerMapper); Stream<Integer> lengthStream = devowelizedStream.map(lengthMapper); Optional<Integer> reduce = lengthStream.reduce(accumulator); Integer out = reduce.get();
Alle Collection in Java 8 sono stati aggiunti alcuni il metodi tra cui il metodo stream() che ne restituisce uno e permette di cominciare l’elaborazione della collezione. Uno Stream offre diversi metodi di ordine superiore, tra cui il map e il reduce: il primo restituisce un nuovo stream basato sul risultato dell’applicazione della funzione di map e su cui poter continuare l’elaborazione. L’operazione di reduce combina i risultati della collezione in input in un singolo valore, opzionale in quanto, cominciando da un insieme vuoto, l’operazione di reduce non fornirebbe nessun risultato.
Method References
Abbandoniamo gli stream per continuare ad esplorare le novità della sintassi. Se da un lato può essere comodo usare funzioni anonime per scrivere codice compatto e leggibile, è vero che ci sono casi in cui a una funzione sia opportuno dare nome e cognome. I Method Reference vengono in soccorso proprio in questo caso e ci permettono di usare metodi statici di classe o di istanze di oggetti come argomenti di funzioni di ordine superiore. Per questi si utilizza il nuovo operatore :: e l’espressione è assegnabile a una Functional Interface così come lo è una espressione Lambda.
Ad esempio possiamo oggi scrivere in Java 8:
Runnable runGarbageCollector = System::gc;
per creare un nuovo Runnable che chiama il metodo statico gc() sulla classe System. Oppure, prendendo come esempio le istanze dello standard output System.out e standard error System.err, possiamo scrivere due Runnable che inviano a nuova riga l’output su quelle specifiche istanze:
Runnable printNewLineOnOut = System.out::println; Runnable printNewLineOnErr = System.err::println
Pertanto, tornando al nostro esempio, possiamo scrivere la nostra applicazione in questo modo:
Integer out = list.stream() .map(LambdaMethods::devowelizerMapper) .map(LambdaMethods::lengthMapper) .reduce(LambdaMethods::accumulator) .get();
dove invece di funzioni Lambda si sono usati dei method references alla classe LambdaMethods che è stata definita come nel codice qui sotto:
public class LambdaMethods { public static Integer accumulator(Integer a, Integer b) { return a + b; }; public static Integer lengthMapper(String s) { return s.length(); }; public static String devowelizerMapper(String s) { return s.replaceAll(“[aeiou]”, ““); }; }
Implementazione con classi anonime
Senza la sintassi Java 8 avremmo dovuto usare funzioni anonime, e una possibile soluzione è riportata nel codice seguente:
Integer out = list.stream().map(new Function<String, String>() { @Override public String apply(String p) { return p.replaceAll(“[aeiou]”, ““); } }).map(new Function<String, Integer>() { @Override public Integer apply(String a) { return a.length(); } }).reduce(new BinaryOperator<Integer>() { @Override public Integer apply(Integer a, Integer b) { return a + b; } }).get();
Una nota di cautela: è vero che non si è usata la nuova sintassi, ma il codice non compila senza una JDK 1.8 a disposizione perché abbiamo comunque usato gli stream e le interfacce Function e BinaryOperator che non sono disponibili in Java 7.
Java 7 puro
Sul codice pubblicato su github trovate a questo proposito la classe LambdaGameWithoutLambda.java che è implementata usando Java 7 puro. Vediamo come è stato sviluppato il nostro esempio applicativo in questo caso:
Integer out = streamFromList(list) .map(devowelizerMapperClass) .map(lengthMapperClass) .reduce(accumulatorClass) .get();
Scomponiamolo come abbiamo fatto per gli esempi di Java 8 per capirlo meglio:
Stream<String> stream = streamFromList(list); Stream<String> devowelizedStream = stream.map(devowelizerMapperClass); Stream<Integer> lengthStream = devowelizedStream.map(lengthMapperClass); Optional<Integer> reduce = lengthStream.reduce(accumulatorClass); Integer out = reduce.get();
Vediamo inoltre come sono state definite le functional interface con classi anonime non potendo usare Lambda:
Function<String, String> devowelizerMapperClass = new Function<String, String>() { @Override public String apply(String p) { return p.replaceAll(“[aeiou]”, ““); } }; Function<String, Integer> lengthMapperClass = new Function<String, Integer>() { @Override public Integer apply(String a) { return a.length(); } }; BinaryOperator<Integer> accumulatorClass = new BinaryOperator<Integer>() { @Override public Integer apply(Integer a, Integer b) { return a + b; } };
Fino ad ora, l’unica differenza visibile è che lo stream iniziale è creato con una funzione streamFromList invece che dal metodo stream() disponibile a partire dalla versione Java 8.
Dettagli importanti
In verità, se sorvolate con il vostro cursore del mouse sopra le keyword Stream oppure Function, potete notare che non si stratta di quelli introdotti in Java 8, ma di versioni (semplificate) scritte per l’articolo:
Le interfacce di Function e di Stream sono state infatti ridefinite in questo modo:
public interface Function<I, O> { O apply(I input); }
public interface Stream<T> { <R> Stream<R> map(Function<? super T, ? extends R> mapper); Optional<T> reduce(BinaryOperator<T> accumulator); }
L’intera implementazione compatibile con Java 7 può essere ispezionata nel codice disponibile su github; le estensioni necessarie al development kit si trovano dentro la classe annidata chiamata SmallJava8. Avendo implementato solo il minimo indispensabile. il codice risulta semplice ed è interessante esaminarlo per capire con cosa avremo a che fare quando useremo le vere classi della Java 8.
Sbagliando si impara
Vi ricordate quando parlavo di errori a proposito delle interfacce funzionali? Di seguito vi presento degli esempi che spero possano risultare utili.
Se create una nuova interfaccia vuota
interface MyFuncInt {}
e la usate per assegnargli una espressione Lambda:
MyFuncInt myVar = (String a, String b) -> a.length() - b.length();
il compilatore si lamenterà che MyFuncInt non è una functional interface. Aggiungere l’annotazione non è condizione necessaria:
@FunctionalInterface interface MyFuncInt { }
ma almeno a questo modo il compilatore fornirà un errore questa volta su MyFuncInt informandovi che non segue la convenzione. Se aggiungiamo quindi un metodo:
@FunctionalInterface interface MyFuncInt { void foo(); }
la definizione di functional interface sarà stata soddisfatta, ma il compilatore si “lamenterà” di nuovo, questa volta sull’espressione di assegnazione, dicendo che la firma non è corretta.
MyFuncInt myVar = (String a, String b) -> a.length() - b.length(); // incompatible signature!
Se aggiungete un metodo con la firma compatibile con quella usata nella espressione
@FunctionalInterface interface MyFuncInt { void foo(); int bar(String param1, String param2); }
di nuovo l’errore sarà su MyFuncInt che non segue la convenzione. Per farlo compilare e funzionare, dovete scrivere una functional interface corretta, con un solo metodo
@FunctionalInterface interface MyFuncInt { int bar(String param1, String param2); }
e assegnarle una funzione anonima compatibile
MyFuncInt myVar = (String a, String b) -> a.length() - b.length();
Si noti che a questo punto è legale invocare il metodo bar sull’istanza myVar:
myVar2.bar(“hello”, “world”); // ok and outputs is 0
La funzione Lambda che abbiamo scritto implementa anche un comparatore di (lunghezza di) stringhe:
Comparator<String> comparator = (String a, String b) -> a.length() - b.length();
Ma non si può usare direttamente la nostra interfaccia perché la funzione sort richiede esattamente un tipo Comparator e la seguente istruzione darà errore:
Collections.sort(list, myVar); // error Comparator<String> c = myVar; // error
Tuttavia si può però usare il method reference del metodo bar su myVar:
Collections.sort(list, myVar::bar); // ok Comparator<String> c = myVar::bar; // ok
Conclusioni
Personalmente avevo visto esempi di sintassi Lambda già da prima dell’uscita di Java 8, ma ignoravo l’esistenza degli stream fino a quando non mi sono cimentato in qualche esempio e mi sono chiesto come applicare una map a una collection. Lo considero un aspetto molto importante delle novità di Java 8 in quanto la sintassi nuova ci permette di fare cose che si potevano già fare con classi anonime; ma grazie agli stream è invece possibile cimentarci più facilmente in programmazione funzionale.
Raccomando prudenza nell’adottare la nuova sintassi: infatti si riesce a essere compatti ed espressivi con Lambda, ma si potrebbe rischiare di scrivere codice criptico soprattutto per altri membri del vostro team che non conoscono ancora la nuova sintassi. In caso di dubbio, considerate di rifattorizzare le funzioni Lambda con classi anonime.
Invece le vostre capacità di sviluppo possono migliorare molto imparando a conoscere lo stile di programmazione funzionale. A questo proposito suggerisco il breve manuale di Dean Wampler [4], orientato proprio agli sviluppatori Java e antecedente a Java 8. Nulla vieta, comunque, di imparare un vero linguaggio funzionale come Scala o Haskell: anche se mai lo applicherete a veri progetti, professionali o no, scoprirete che le idee e i concetti appresi porteranno miglioramenti anche alle vostre capacità di sviluppo in Java; e grazie alle funzionalità di Java 8 come Lambda e Stream, oggi queste potenzialità possono essere sfruttare al massimo.