Con questo articolo proseguiamo la presentazione delle espressioni Lambda: il supporto di Java alla programmazione funzionale. Si tratta di una feature che influenza sia il linguaggio sia la quasi totalità delle librerie: simile a quanto è avvenuto per l’introduzione dei generics (Java SE 5) ma in scala maggiore.
Questo ed i restanti articoli della serie sono un breve sunto estratto dal libro ‘Verso Java 8: appunti dello sviluppatore in Java SE7’.
Introduzione
Con questo articolo proseguiamo la nostra esplorazione di Java SE 8 iniziata nei due precedenti articoli (cfr. [1], [2]). In particolare, dopo aver introdotto nel corso del primo articolo il progetto Coin (più precisamente le feature del progetto Coin non incluse in Java SE 7) e il progetto Jigsaw (modularizzazione di Java e di tutto l’ambiente, anche se già si parla di postporlo alla release di Java SE9…), nel secondo abbiamo avviato la presentazione del progetto Lamdba presentando la discussione relativa alle interfacce funzionali (ossia interfacce con un solo metodo, più o meno), la sintassi e alcuni esempi. In questo articolo continueremo nel percorso presentando altri aspetti legati al supporto della programmazione funzionale in Java, come per esempio la gestione del tipo di destinazione, l’ambito lessicale, riferimenti ai metodi e così via. Prima di proseguire con la lettura di questo articolo, si suggerisce di rivedere brevemente il precedente.
Dal momento che le Lambda Expression in Java non sono ancora disponibili e che esiste una limitatissima letteratura, la maggior parte delle informazioni presenti in questi articoli sono stati presi sia dal codice Java attualmente rilasciato, sia dal Blog di Goetz (cfr. [3]), Java Language Architect alla Oracle Corporation, in precedenza Senior Engineer alla Sun Microsystem.
Il tipo di destinazione
Come visto nell’articolo precedente, ed in particolare nel paragrafo relativo alla sintassi, le espressioni lambda non includono il nome dell’interfaccia funzionale. Ciò significa che il tipo non è definito. Pertanto è compito del compilatore dedurlo dal contesto. Per esempio, da una semplice analisi del contesto e più precisamente del tipo di destinazione, è immediato dedurre che la seguente espressione Lambda è un ActionListener:
ActionListener l = (ActionEvent e) -> ui.dazzle(e.getModifiers());
L’assenza del nome dell’interfaccia funzionale fa sì che una stessa espressione Lambda può essere di tipo diverso in diversi contesti. Ciò equivale a dire che le espressioni Lambda siano poly expression, (“multiespressioni”). Si considerino le seguenti due espressioni Lambda ( () -> “done” ):
Callable c = () -> "done"; PrivilegedAction a = () -> "done";
Chiaramente si tratta di due espressioni assolutamente identiche. Tuttavia, dall’ispezione del tipo di destinazione è evidente che mentre la prima è di tipo Collable, la seconda è di tipo PriviledgedAction. È compito del compilatore determinare il tipo delle espressioni Lambda dall’analisi del contesto in cui queste sono inserite. Il tipo determinato viene detto target type (“tipo di destinazione”). Il che è complementarmente consistente con la feature del “diamante” introdotta con Java SE 7.
La logica conseguenza del type infer applicato alle espressioni Lambda è che queste possono essere utilizzate esclusivamente in contesti dove è presente un tipo di destinazione. Come è lecito attendersi, non è fattibile/conveniente ipotizzare espressioni Lambda compatibili con ogni possibile tipo di destinazione. Pertanto, è nuovamente compito del compilatore assicurarsi che i tipi utilizzati dall’espressione Lambda siano coerenti con il tipo di destinazione dichiarato dalla firma del metodo.
Alcuni esempi
Dal momento che a partire dal tipo di destinazione delle interfacce funzionali è possibile determinare automaticamente i tipi dei parametri formali che le espressioni devono ricevere, ne segue che non è necessario ripeterli. L’utilizzo del meccanismo dell’identificazione del tipo di destinazione consente, nella maggior parte dei casi, di dedurre anche i tipi dei parametri delle espressioni Lambda. Come mostrato di seguito
Comparator c = (s1, s2) -> s1.compareToIgnoreCase(s2);
il compilatore può facilmente dedurre che s1 e s2 sono di tipo String (quindi non è necessario scrivere (String s1, String s2) ). Inoltre, in tutti quei casi in cui è presente un unico parametro il cui tipo è dedotto (caso molto comune), le parentesi che circondano il singolo parametro diventano superflue.
Nel seguente frammento di codice sono mostrati due esempi. Il primo con l’interfaccia funzionale FileFilter che prevede come unico metodo accept(File pathName) rende evidente che il parametro può essere esclusivamente di tipo File.
FileFilter javaFile = f -> f.getName().endsWith(".java");
Nel secondo caso si utilizza l’interfaccia funzionale ActionListerner che definisce il solo metodo actionPerformed(ActionEvent e). Quindi è facilmente desumibile che il parametro e sia di tipo ActionEvent.
button.addActionListener( e -> ui.dazzle(e.getModifiers()));
Contesti per l’uso delle espressioni Lambda
Come illustrato precedentemente, le espressioni Lambda in Java possono essere specificate esclusivamente in uno dei seguenti contesti dove è presente un tipo di destinazione:
- dichiarazione di variabili
- assegnamenti
- istruzioni di ritorno (return)
- inizializzazione di array
- argomenti di metodi e costruttori
- corpo delle espressioni Lambda
- espressioni condizionali (? :
- istruzioni di casting
Nei primi tre casi, il tipo di destinazione è chiaramente il tipo assegnato o di ritorno. Dall’analisi del codice seguente, si comprende che il tipo della prima espressione Lambda è un Comparator, mentre nel secondo caso è un Runnable, il cui unico metodo run invia la stringa later alla console.
Comparator c; c = (String s1, String s2) -> s1.compareToIgnoreCase(s2); public Runnable toDoLater() { return () -> { System.out.println("later"); }; }
Per quanto concerne i contesti di inizializzazione degli array, si tratta di una particolare versione del contesto dell’assegnazione caratterizzata dal fatto che la “variabile” è un componente di matrice e il tipo è derivato dal tipo della matrice, come mostrato di seguito:
runAll(new Callable[]{ ()->"a", ()->"b", ()->"c" });
Nel caso degli argomenti dei metodi, la situazione è leggermente più complicata: il tipo di destinazione è determinato da due caratteristiche del linguaggio di programmazione: la risoluzione dell’overloading e l’inferenza del tipo dell’argomento. Per ogni metodo potenzialmente applicabile, il compilatore si deve occupare di determinare se l’espressione Lambda sia compatibile con il relativo tipo di destinazione, e di dedurre eventuali argomenti di tipo. Dopo la selezione della miglior dichiarazione di metodo, la dichiarazione fornisce il tipo di destinazione per l’espressione.
void invoke(Runnable r) { r.run(); } T invoke(Callable c) { return c.call(); } String s = invoke(() -> "done"); // invoke(Callable)
Da notare che qualora la selezione del tipo di destinazione risulti ambigua, è sempre possibile ricorrere al casting.
Funzioni che restituiscono altre funzioni
Le stesse espressioni Lambda possono fornire il tipo di destinazione per i loro body, in questo caso, derivano il tipo dal tipo di destinazione esterno. Ciò fa sì che sia possibile e spesso conveniente scrivere funzioni che restituiscono altre funzioni, come mostrato di seguito. Lo stesso concetto si applica anche per le espressioni condizionali dove il tipo è fornito dal contesto.
Callable c = () -> () -> { System.out.println("hi"); };
Ambito lessicale
La determinazione del significato dei nomi (e della parola chiave this) nelle classi annidate spesso non è immediato e anzi è soggetto a errori rispetto allo scenario classico di classi non annidate. Membri ereditati, tra cui i metodi derivanti dalla classe Object, possono accidentalmente nascondere dichiarazioni esterne, e riferimenti non qualificati a this si riferiscono sempre alla stessa classe interna. Le espressioni Lambda sono molto più semplici: non ereditano nomi da un super-tipo, ne’ introducono un nuovo livello di scoping: hanno un ambito/una portata lessicale derivata dal contesto (lexical scoped). Ciò significa che i nomi nel body dell’espressione sono interpretati così come se fossero nell’ambiente che li racchiude (con l’aggiunta dei nuovi nomi definiti dai parametri formali dell’espressioni lambda). Come logica conseguenza, la parola chiave this e i riferimenti ai suoi membri hanno lo stesso significato, come se fossero collocati immediatamente all’esterno dell’espressione Lambda. Si consideri il seguente listato:
public class HelloWorld { Runnable r1 = () -> { System.out.println(this); } Runnable r2 = () -> { System.out.println(toString()); } public String toString() { return "Hello, world!"; } public static void main(String... args) { new HelloWorld().r1.run(); new HelloWorld().r2.run(); } }
Il listato si occupa di stampare a console l’inflazionatissimo messaggio “Hello, Word!” due volte. Da notare tuttavia che qualora si fosse utilizzata un’implementazione basata su classi annidate, il risultato sarebbe stato la stampa degli indirizzi di memoria HelloWorld@… in quanto il this avrebbe fatto riferimento alla classe annidata stessa.
La parola chiave this all’interno delle espressioni Lambda si riferisce alla classe che le contiene e pertanto non può essere utilizzato per riferirsi al valore calcolato dalla funzione Lambda. In alcuni contesti, ciò genera qualche difficoltà giacche’ sarebbe potuto risultare utile nelle implementazioni ricorsive, fondamento della programmazione funzionale. Questo scenario è risolto consentendo, in casi ben definiti, il riferimento alla variabile a cui verrà assegnato il risultato dell’esecuzione dell’espressione. Il compilatore tuttavia vieta di far riferimento alla variabile di assegnazione di un’espressione Lambda quando questa appare in contesti come un’auto-assegnazione, o in un’espressione di ritorno. L’approccio corretto in questi casi consiste nel denominare l’oggetto con una dichiarazione di variabile e sostituire l’espressione originale con un riferimento variabile.
Acquisizione di variabili
In Java, il controllo che esegue il compilatore quando esamina il codice delle classi annidate per verificare riferimenti a variabili locali delle classi che le includono (variabili acquisite) è tradizionalmente molto restrittivo: si verifica un errore ogni qualvolta la variabile acquisita non è dichiarata final. Questa limitazione deve essere rilassata sia per le espressioni Lambda, sia per classi annidate, consentendo l’acquisizione di variabili locali effettivamente finali. Informalmente, una variabile locale è effettivamente finale se il suo valore iniziale non viene mai variato, in altre parole, dichiarandola final non si genera alcun errore di compilazione. Riferimenti a this, compresi riferimenti impliciti attraverso riferimenti a campi non qualificati o invocazioni di metodi, sono, in sostanza, riferimenti a una variabile locale final. Corpi delle espressioni Lambda che contengono tali riferimenti acquisiscono l’apposita istanza di this. Negli altri casi, nessun riferimento a questa viene mantenuto dall’oggetto.
Ciò ha un risvolto benefico per la gestione della memoria: mentre le istanze della classe annidata contengono sempre un riferimento forte all’istanza che le contiene, le espressioni Lambda che non catturano membri dall’istanza che le racchiude non mantengono alcun riferimento ad essa. Questa caratteristica delle istanze delle classi annidate può essere fonte di memory leak. Con le espressioni Lambda si intende vietare la cattura di variabili locali mutevoli. La ragione è che idiomi come quello riportato di seguito sono intrinsecamente seriali: è molto difficile scrivere corpi Lambda come questo che non si prestino a race-condition.
int sum = 0; list.forEach(e -> { sum += e.size(); });
A meno che non si sia disposti a far rispettare, preferibilmente in fase di compilazione, che tale funzione non possa fuoriuscire al proprio Thread di esecuzione, questa funzione finirebbe per causare solo una serie di problemi.
Un approccio migliore consiste nell’elevare il calcolo, consentendo alle librerie di gestire il coordinamento tra i vari Thread. Nell’esempio esaminato, lo sviluppatore potrebbe utilizzare reduce anziche’ forEach come mostrato di seguito. La funzione reduce prende un valore di base, in questo caso una lista vuota, e un operatore (nell’esempio la somma) e lo esegue su tutti gli elementi (0 + list[0]+ list[1]+ … + list[size-1]):
int sum = list .map(e -> e.size()) .reduce(0, (a, b) -> a+b);
Pertanto, piuttosto che sostenere un idioma che è fondamentalmente sequenziale e incline a generare race condition sui dati (gli accumulatori che sono ovviamente mutevoli), è stata preferita una libreria in grado di esprimere operazioni di accumulo in un modo parallelizzabile e meno soggetto a errori.
Riferimenti a metodi
Le espressioni Lambda permettono di definire un metodo anonimo e trattarlo come un esempio di interfaccia funzionale. Spesso è desiderabile fare lo stesso con metodi esistenti. I riferimenti a metodi sono a tutti gli effetti delle espressioni che hanno lo stesso trattamento di espressioni Lambda: hanno bisogno di un tipo di destinazione e della codifica di istanze di interfaccia funzionale ma, invece di fornire il corpo di un metodo, si riferiscono a un metodo di una classe esistente o di un oggetto.
Per esempio, si consideri la seguente classe Person i cui oggetti possono essere ordinati per nome o per età.
class Person { private final String name; private final int age; public static int compareByAge(Person a, Person b) { ... } public static int compareByName(Person a, Person b) { ... } } Person[] people = ... Arrays.sort(people, Person::compareByAge);
In questo caso l’espressione Person::compareByAge può essere considerata una scorciatoia per un’espressione Lambda in cui l’elenco dei parametri formali è ripreso da Comparator e il relativo corpo invoca Person.compareByAge. Poiche’ i tipi di parametro del metodo interfaccia funzionale hanno il ruolo di argomenti in una chiamata di metodo implicito, la firma del metodo di riferimento può modificare i parametri (come per esempio eseguendo un boxing) proprio come una chiamata di metodo.
interface Block { void run(T arg); } // void exit(int status) Block b1 = System::exit; // void sort(Object[] a) Block<String[]> b2 = Arrays::sort; // void main(String... args) Block b3 = MyProgram::main; // void main(String... args) Runnable r = MyProgram::main;
Tipologie di riferimento a un metodo
Gli esempi mostrati precedentemente utilizzano metodi statici. In realtà ci sono tre diversi tipi di riferimenti a un metodo, ognuno con una sintassi leggermente diversa:
- metodo statico;
- metodo di istanza di un determinato oggetto;
- metodo di istanza di un oggetto arbitrario di un tipo specifico.
Il riferimento a un metodo statico, come visto, richiede, in maniera del tutto naturale, di includere la classe a cui appartiene il metodo il delimitatore “::” e quindi il metodo.
Per quanto attiene a un riferimento a un metodo di istanza di un oggetto specifico, il riferimento all’oggetto precede il delimitatore.
Per quanto riguarda i riferimenti a metodi di istanza di un oggetto arbitrario, la sintassi prevede che il tipo a cui appartiene il metodo preceda il delimitatore (“::“), e il ricevitore dell’invocazione sia il primo parametro del metodo dell’interfaccia funzionale, come illustrato di seguito:
Arrays.sort(names, String::compareToIgnoreCase);
In questo caso, l’espressione Lambda implicita utilizza il suo primo parametro come ricevitore ed il secondo parametro come argomento compareToIgnoreCase. Se la classe del metodo di istanza è generico, i tipi dei parametri possono essere forniti prima del delimitatore (“::“) o, in molti casi, possono essere dedotti dal tipo di destinazione.
Riferimenti al costruttore
I metodi costruttori possono essere referenziati in modo analogo ai metodi statici utilizzando l’apposita parola chiave new, come mostrato di seguito:
SocketImplFactory factory = MySocketImpl::new;
Qualora una classe abbia diversi costruttori, si utilizza il tipo di destinazione della firma del metodo per selezionare la migliore corrispondenza in modo analogo in cui viene risolta una chiamata di costruttore classica.
La generazione di una nuova istanza di una classe interna richiede un ulteriore parametro relativo all’oggetto esterno. Per un riferimento a costruttore, questo parametro extra può essere fornito o implicitamente racchiudendo this del riferimento, o può essere il primo parametro del metodo dell’interfaccia funzionale (nello stesso modo in cui il primo parametro di riferimento al metodo può fungere come ricevitore di un metodo di istanza).
Metodi di default
Le espressioni Lambda e i riferimenti ai metodi apportano un notevole incremento dell’espressività del linguaggio Java. Tuttavia è necessario compiere un ulteriore passo per raggiungere l’obiettivo di un supporto nativo del modello code-as-data. È necessario far sì che le nuove feature siano integrate nelle varie librerie al fine di tranne pieno vantaggio. L’aggiunta di nuove funzionalità nelle esistenti librerie Java, come è lecito attendersi non è assolutamente un compito immediato. In particolare, le varie interfacce, una volta rilasciate, non possono essere più modificate. Pertanto lo scopo dei metodi predefiniti (indicati anche come virtual extension methods, “metodi di estensione virtuali”, o “metodi difensivi”) è quello di consentire alle interfacce di poter evolvere in modo compatibile dopo la relativa pubblicazione.
Si consideri la API delle collezioni standard, la quale, per supportare le espressioni Lambda, dovrebbe ovviamente fornire una serie di nuove operazioni. Il metodo removeAll, per esempio, dovrebbe essere generalizzato al fine di consentirgli di rimuovere tutti gli elementi di una determinata collezione allorquando una data proprietà arbitraria sia soddisfatta, dove la proprietà è espressa come un esempio di un predicato di interfaccia funzionale.
La domanda è “dove definire questi nuovi metodi?”. Non si può aggiungere un metodo astratto all’interfaccia Collection giacche’ ciò genererebbe un notevole impatto in molte implementazioni esistenti. Si potrebbe ricorre a un metodo statico nella classe di utilità java.util.Collections, ma ciò consisterebbe nel relegare queste nuove operazioni in una classe in qualche modo di secondo ordine.
La soluzione ottimale invece è stata considerata quella basata sui metodi predefiniti che rappresentano un’elegante disegno OO. Si tratta di un cambiamento non banale dal momento che comporta l’aggiungere comportamento concreto ad elementi astratti per eccellenza, come le interfacce. Questo comportamento concreto è ottenibile per mezzo di nuovo tipo di metodo: un metodo di interfaccia può essere astratto, come al solito, o dichiarare un’implementazione di default.
interface Iterator { boolean hasNext(); E next(); void remove(); void skip(int i) default { for (; i > 0 && hasNext(); i--) next(); } }
Dall’analisi del precedente listato consegue che tutte le classi che implementano questa nuova definizione dell’interfaccia Iterator ereditano anche l’implementazione del metodo skip. Dal punto di vista del codice cliente, il metodo skip è un metodo virtuale fornito dall’interfaccia. L’invocazione del metodo skip su un’istanza di una classe che implementa, direttamente o indirettamente, Iterator genera l’invocazione dell’implementazione predefinita. Ciò genera il beneficio che le “sottoclassi” di Iterator, a meno di esigenze particolari, non devono preoccuparsi di ridefinire l’implementazione di questo metodo. Da notare che un’interfaccia che ne estende un’altra può aggiungere, modificare o rimuovere le implementazioni predefinite dei metodi della interfaccia genitore. Al fine di consentire a una interfaccia di rimuovere un’implementazione predefinita, è necessario introdurre la nuova parola chiave: none.
Ereditarietà (multipla) dei metodi di default
Metodi predefiniti vengono ereditati così come avviene per gli altri metodi e nella maggior parte dei casi, il comportamento è proprio quello che ci si aspetta. Tuttavia, esistono dei casi particolari.
In primo luogo, quando un’interfaccia ridichiara un metodo di uno dei suoi super-tipi (ripete la firma del metodo) senza menzionare la parola chiave default, allora anche l’implementazione di default, se presente, viene ereditata dalla interfaccia che ha incluso l’override. Per capire la motivazione di questa scelta è necessario considerare una pratica di documentazione utilizzata di frequente che consiste nel ridichiarare dei metodi. Si vuole, pertanto, evitare che la semplice ripetizione di un metodo che già implicitamente è un membro dell’interfaccia generi effetti collaterali inattesi.
In secondo luogo, quando un tipo genitore di una classe o di un’interfaccia definisce indirettamente diversi metodi con la stessa firma, le regole di ereditarietà tentano di risolvere il conflitto. I due principi fondamentali che si applicano sono i seguenti:
- Le dichiarazioni di metodi nelle classi hanno la precedenza sui metodi di default predefiniti nelle interfacce. Questo è vero indipendentemente dal fatto che il metodo della classe sia concreto o astratto. Alla parola chiave default deve essere associato sempre un significato di ripiego qualora la gerarchia delle classi non ridefinisca il metodo.
- Metodi già sovrascritti da altri candidati sono ignorati. Questa circostanza può verificarsi quando super-tipi condividano un medesimo antenato. Si consideri il caso in cui le due interfacce, per esempio Collection e List (List eredita da Collection) forniscano diverse implementazioni di default per i metodi removeAll. In questo scenario, la clausola implements riportata nella dichiarazione di seguito fa sì che il metodo di default definito nell’interfaccia List abbia la precedenza, e quindi venga ereditata da Queue, sulla corrispondente dichiarazione definita dall’interfaccia Collection:
class LinkedList implements List, Queue
Risoluzione dei conflitti
Nel caso in cui due metodi di default, definiti in modo indipendente, generino un conflitto, o nella situazione analoga in cui il conflitto è generato da un metodo di default e un corrispondente none (un’attuazione del famoso atavico problema del diamante), il programmatore deve includere esplicitamente l’override dei metodi dei super-tipi che generano il confitto (da notare che questo, in qualche misura, significa accettare elementi di ereditarietà multipla in Java). Nella maggior parte dei casi, ciò dovrebbe risolversi nella selezione del default preferito. Sebbene questa soluzione presenti diversi vantaggi, in qualche modo rappresenta una deviazione della decisione base del linguaggio Java basata sulla ereditarietà singola. Ovviamente è necessario far sì che il linguaggio evolva e non c’è nulla di male nel rivedere alcune scelte iniziali, così come è avvenuto già in diversi ambiti (multi-threading, java.io, etc.). Tuttavia, in questi casi, probabilmente potrebbe valere la pena a quessto punto di rivedere l’intera policy e non solo i singoli casi.
java.util.functions
Le espressioni Lambda in Java sono convertite in istanze di interfacce con un solo metodo (interfacce funzionali). Per supportare questo concetto è necessario disporre di una serie di interfacce funzionali di base. Queste sono state incluse nel nuovo package: java.util.functions e sono i blocchi base utilizzati dalle varie librerie. Le più interessanti sono riportate di seguito:
- Predicate (Predicate): si assicura che la specifica proprietà sia verificata dall’oggetto fornito in input;
- Block (Block): rappresenta un’azione da eseguire sull’oggetto specificato come parametro;
- Mapper (Mapper<T, U>): si occupa di trasformare elementi appartementi al dominio T in elementi appartenenti al dominio U;
- UnaryOperator (UnaryOperator): permette di rappresentare operatori unari in cui sia l’elemento di input (dominio), sia quello di output (codominio) appartengono allo stesso insieme;
- BinaryOperator (BinaryOperator<T, T>): come al punto precedente, con la differenza che l’operatore prende due parametri in input dello stesso dominio anziche’ uno.
Ricorsione
Nelle espressioni Lambda Java la ricorsione non sembra ne’ immediata, ne’ elegante poiche’ la si può utilizzare soltanto se la chiamata ricorsiva fa riferimento a un nome definito nel contesto che racchiude la definizione dell’espressione Lambda. Più precisamente (nel momento in cui viene scritto questo articolo) le definizioni ricorsive possono essere incluse solo in un contesto di assegnamento di variabile e, per via della regola dell’assegnamento prima dell’uso, solo in variabili di istanze o assegnamento di variabili definite statiche, come illustrato di seguito.
public class Recoursion { static FactInt factorial; public static void main(String[] args) { factorial = i -> i == 0 ? 1 : i * factorial.invoke(i - 1); for (int ind=0; ind < 10; ind++) { System.out.println( "Factorial "+ind+"="+factorial.invoke(ind)); } } } public interface FactInt { int invoke(int i); }
L’esecuzione del precedente listato genera il seguente output:
Factorial 0=1 Factorial 1=1 Factorial 2=2 Factorial 3=6 Factorial 4=24 Factorial 5=120 Factorial 6=720 Factorial 7=5040 Factorial 8=40320 Factorial 9=362880
Conclusioni
Con questo articolo abbiamo continuato l’illustrazione del supporto alla programmazione funzionale a cui stanno lavorando gli architetti di Java per la release Java SE 8. In particolare, abbiamo presentato il concetto del tipo di destinazione che, in quanto non previsto dalla sintassi delle espressioni Lambda, pertanto implicito e desumibile dal contesto, fa sì che queste, in realtà siano delle poly expression, ossia delle “multi-espressioni” diverse allo stesso tempo.
Si è poi proseguito con la descrizione del lexical scope (“ambito lessicale”), dove è stato possibile apprezzare che le espressioni Lambda non ne definiscono uno nuovo: i nomi presenti nel corpo delle espressioni sono interpretati così come se fossero presenti nell’ambiente che li racchiude. Questo, insieme alla caratteristica di non ereditare da un super tipo, fa sì che l’utilizzo delle espressioni Lambda sia lineare e che si risolvano i classici problemi presenti nelle classi annidate.
Altro elemento interessante delle espressioni Lambda è l’acquisizione delle variabili e la necessità di rilassare i controlli effettuati dal compilatore circa i riferimenti a variabili locali delle classi che le includono, al fine di consentire l’acquisizione delle variabili effettivamente locali.
Altra feature interessante è data dai riferimenti ai metodi i quali diventano a tutti gli effetti delle espressioni permettendo di semplificare ulteriormente la sintassi rendendola al tempo stesso più potente. I metodi di default, parzialmente già discussi nel contesto del progetto Coin Java SE 8, rappresentano poi un’eccellente strategia al fine di consentire l’evoluzione delle interfacce esistenti per renderle compatibili con le espressioni funzionali senza però alterare i contratti esistenti. Questi metodi, inoltre, permettono di migliorare l’eleganza del disegno in quanto permettono di definire dei metodi di default direttamente nelle interfacce, migliorando il livello di incapsulazione e, al tempo stesso, evitando tutta una serie di classi di utilità.
Da notare, tuttavia, che l’introduzione di questi metodi, di fatto, ripropone il problema dell’ereditarietà multipla in Java e richiede soluzioni che, in qualche modo, aggirano la scelta di disegno iniziale di non implementarla.
Il nuovo package java.util.functions include un insieme di funzioni predefinite che semplificano l’implementazione di espressioni Lambda. Per finire si è presentata la ricorsione con le espressioni Lambda che, ad onor del vero, allo stato attuale non sembrerebbe seguire un disegno elegantissimo.
Come spunti finali, vale la pena evidenziare ancora una volta il grande impatto delle espressioni Lambda sull’intera piattaforma Java a partire dal linguaggio, dal compilatore e dalla libreria. Chiaramente, introdurre le espressioni Lambda senza modificare al contempo l’intero set di librerie sarebbe stato un supporto troppo limitato: sarebbe stato necessario mischiare più del necessario la programmazione funzionale con quella OO come necessario raccordo tra il vecchio e il nuovo. Inoltre, è possibile notare il grande sforzo compiuto dagli architetti Java sia nel mantenere la famosa back-compatibility, sia nell’introdurre una sintassi sintetica, agile e molto espressiva, in linea con i propositi del progetto Coin.
Riferimenti
[1] Luca Vetti Tagliati, “L’evoluzione di Java: verso Java 8 – VI parte: SE 8, il puzzle della moneta”, MokaByte 178, novembre 2012
https://www.mokabyte.it/cms/article.run?articleId=A8C-K78-J26-TIV_7f000001_13046033_f83e5f06
[2] Luca Vetti Tagliati, “L’evoluzione di Java: verso Java 8 – Java SE 8. Quasi Lamdba…”, MokaByte 179, dicembre 2012
https://www.mokabyte.it/cms/article.run?articleId=7K4-X5O-JZE-Q67_7f000001_13046033_719ccb41
[3] Brian Goetz, “A peek past Lambda”, 2011
[4] Brian Goetz, Tim Peierls, Joshua Bloch, Joseph Bowbeer, David Holmes, Doug Lea, “Java Concurrency in Practice”, Addison-Wesley, 2006
[5] Luca Vetti Tagliati, “L’evoluzione di Java: verso Java 8 – IV parte: Java SE 7, fork et impera”, MokaByte 174, giugno 2012
https://www.mokabyte.it/cms/article.run?permalink=mb174_javaevolution-4