- Introduzione
- Ulteriori letture:
- Iterabile per lo streaming in Java
- Come usare la logica if/else in Java 8 Streams
- Lambda in Java 8
- Interfacce funzionali
- Funzioni
- Specializzazioni di funzioni primitive
- Specializzazioni di funzioni a due arità
- Fornitori
- Consumatori
- Predicati
- Operatori
- Interfacce funzionali legacy
- Conclusione
Introduzione
Questo tutorial è una guida alle diverse interfacce funzionali presenti in Java 8, così come i loro casi d’uso generali e l’utilizzo nella libreria JDK standard.
Ulteriori letture:
Iterabile per lo streaming in Java
Come usare la logica if/else in Java 8 Streams
Lambda in Java 8
Java 8 ha portato un nuovo potente miglioramento sintattico sotto forma di espressioni lambda. Un lambda è una funzione anonima che possiamo gestire come cittadino linguistico di prima classe. Ad esempio, possiamo passarlo o restituirlo da un metodo.
Prima di Java 8, di solito creavamo una classe per ogni caso in cui avevamo bisogno di incapsulare un singolo pezzo di funzionalità. Ciò implicava un sacco di codice boilerplate non necessario per definire qualcosa che fungeva da rappresentazione di una funzione primitiva.
L’articolo “Lambda Expressions and Functional Interfaces: Tips and Best Practice” descrive in modo più dettagliato le interfacce funzionali e le best practice per lavorare con lambda. Questa guida si concentra su alcune interfacce funzionali particolari che sono presenti in java.util.pacchetto di funzioni.
Interfacce funzionali
Si raccomanda che tutte le interfacce funzionali abbiano un’annotazione informativa @FunctionalInterface. Questo comunica chiaramente lo scopo dell’interfaccia e consente anche a un compilatore di generare un errore se l’interfaccia annotata non soddisfa le condizioni.
Qualsiasi interfaccia con un SAM(metodo astratto singolo) è un’interfaccia funzionale e la sua implementazione può essere trattata come espressioni lambda.
Si noti che i metodi predefiniti di Java 8 non sono astratti e non contano; un’interfaccia funzionale può ancora avere più metodi predefiniti. Possiamo osservare questo guardando la documentazione della funzione.
Funzioni
Il caso più semplice e generale di un lambda è un’interfaccia funzionale con un metodo che riceve un valore e ne restituisce un altro. Questa funzione di un singolo argomento è rappresentata dall’interfaccia della funzione, che è parametrizzata dai tipi del suo argomento e da un valore di ritorno:
public interface Function<T, R> { … }
Uno degli usi del tipo di funzione nella libreria standard è la mappa.Metodo computeIfAbsent. Questo metodo restituisce un valore da una mappa per chiave, ma calcola un valore se una chiave non è già presente in una mappa. Per calcolare un valore, utilizza l’implementazione della funzione passata:
Map<String, Integer> nameMap = new HashMap<>();Integer value = nameMap.computeIfAbsent("John", s -> s.length());
In questo caso, calcoleremo un valore applicando una funzione a una chiave, inserita in una mappa e restituita anche da una chiamata al metodo. Possiamo sostituire il lambda con un riferimento al metodo che corrisponde ai tipi di valore passati e restituiti.
Ricorda che un oggetto su cui invochiamo il metodo è, infatti, il primo argomento implicito di un metodo. Ciò ci consente di lanciare un riferimento alla lunghezza del metodo di istanza a un’interfaccia di funzione:
Integer value = nameMap.computeIfAbsent("John", String::length);
L’interfaccia della funzione ha anche un metodo di composizione predefinito che ci permette di combinare diverse funzioni in una sola ed eseguirle in sequenza:
Function<Integer, String> intToString = Object::toString;Function<String, String> quote = s -> "'" + s + "'";Function<Integer, String> quoteIntToString = quote.compose(intToString);assertEquals("'5'", quoteIntToString.apply(5));
La funzione quoteIntToString è una combinazione della funzione quote applicata ad un risultato della funzione intToString.
Specializzazioni di funzioni primitive
Poiché un tipo primitivo non può essere un argomento di tipo generico, esistono versioni dell’interfaccia della funzione per i tipi primitivi più utilizzati double, int, long e le loro combinazioni in argomento e tipi di ritorno:
- IntFunction, LongFunction, DoubleFunction: arguments are of specified type, return type is parameterized
- ToIntFunction, ToLongFunction, ToDoubleFunction: return type is of specified type, arguments are parameterized
- DoubleToIntFunction, DoubleToLongFunction, IntToDoubleFunction, IntToLongFunction, LongToIntFunction, LongToDoubleFunction: avendo entrambi argomento e tipo di ritorno definiti come i tipi primitivi, come specificato dai loro nomi
Come un esempio, non c’è out-of-the-box interfaccia funzionale per una funzione che prende un breve e restituisce un byte, ma nulla ci impedisce di scrivere i nostri:
@FunctionalInterfacepublic interface ShortToByteFunction { byte applyAsByte(short s);}
Ora possiamo scrivere un metodo che trasforma una matrice di breve di un array di byte utilizzando una regola definita da un ShortToByteFunction:
public byte transformArray(short array, ShortToByteFunction function) { byte transformedArray = new byte; for (int i = 0; i < array.length; i++) { transformedArray = function.applyAsByte(array); } return transformedArray;}
Ecco come potremmo utilizzarlo per trasformare un array di shorts a un array di byte moltiplicato per 2:
short array = {(short) 1, (short) 2, (short) 3};byte transformedArray = transformArray(array, s -> (byte) (s * 2));byte expectedArray = {(byte) 2, (byte) 4, (byte) 6};assertArrayEquals(expectedArray, transformedArray);
Specializzazioni di funzioni a due arità
Per definire lambda con due argomenti, dobbiamo utilizzare interfacce aggiuntive che contengono la parola chiave “Bi” nei loro nomi: BiFunction, ToDoubleBiFunction, ToIntBiFunction e ToLongBiFunction.
BiFunction ha sia argomenti che un tipo di ritorno generificato, mentre ToDoubleBiFunction e altri ci permettono di restituire un valore primitivo.
Uno dei tipici esempi di utilizzo di questa interfaccia nell’API standard è nella Mappa.Metodo replaceAll, che consente di sostituire tutti i valori in una mappa con un valore calcolato.
Usiamo un’implementazione BiFunzione che riceve una chiave e un vecchio valore per calcolare un nuovo valore per lo stipendio e restituirlo.
Map<String, Integer> salaries = new HashMap<>();salaries.put("John", 40000);salaries.put("Freddy", 30000);salaries.put("Samuel", 50000);salaries.replaceAll((name, oldValue) -> name.equals("Freddy") ? oldValue : oldValue + 10000);
Fornitori
L’interfaccia funzionale del fornitore è un’altra specializzazione di funzioni che non accetta argomenti. In genere lo usiamo per la generazione pigra di valori. Ad esempio, definiamo una funzione che piazza un doppio valore. Non riceverà un valore stesso, ma un fornitore di questo valore:
public double squareLazy(Supplier<Double> lazyValue) { return Math.pow(lazyValue.get(), 2);}
Questo ci permette di generare pigramente l’argomento per l’invocazione di questa funzione utilizzando un’implementazione Fornitore. Questo può essere utile se la generazione dell’argomento richiede una notevole quantità di tempo. Lo simuleremo usando il metodo sleepUninterruptibly di Guava:
Supplier<Double> lazyValue = () -> { Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS); return 9d;};Double valueSquared = squareLazy(lazyValue);
Un altro caso d’uso per il Fornitore sta definendo la logica per la generazione di sequenze. Per dimostrarlo, usiamo un flusso statico.genera metodo per creare un flusso di numeri di Fibonacci:
int fibs = {0, 1};Stream<Integer> fibonacci = Stream.generate(() -> { int result = fibs; int fib3 = fibs + fibs; fibs = fibs; fibs = fib3; return result;});
La funzione che passiamo al Flusso.il metodo genera implementa l’interfaccia funzionale del fornitore. Si noti che per essere utile come generatore, il Fornitore di solito ha bisogno di una sorta di stato esterno. In questo caso, il suo stato comprende gli ultimi due numeri di sequenza di Fibonacci.
Per implementare questo stato, usiamo un array invece di un paio di variabili perché tutte le variabili esterne utilizzate all’interno del lambda devono essere effettivamente definitive.
Altre specializzazioni dell’interfaccia funzionale del fornitore includono BooleanSupplier, DoubleSupplier, LongSupplier e IntSupplier, i cui tipi di ritorno sono primitive corrispondenti.
Consumatori
Al contrario del Fornitore, il Consumatore accetta un argomento generificato e non restituisce nulla. È una funzione che rappresenta gli effetti collaterali.
Ad esempio, salutiamo tutti in un elenco di nomi stampando il saluto nella console. Il lambda passò alla lista.Il metodo forEach implementa l’interfaccia funzionale del consumatore:
List<String> names = Arrays.asList("John", "Freddy", "Samuel");names.forEach(name -> System.out.println("Hello, " + name));
Esistono anche versioni specializzate del Consumatore — DoubleConsumer, IntConsumer e LongConsumer — che ricevono valori primitivi come argomenti. Più interessante è l’interfaccia BiConsumer. Uno dei suoi casi d’uso è l’iterazione attraverso le voci di una mappa:
Map<String, Integer> ages = new HashMap<>();ages.put("John", 25);ages.put("Freddy", 24);ages.put("Samuel", 30);ages.forEach((name, age) -> System.out.println(name + " is " + age + " years old"));
un Altro insieme di specifici BiConsumer versioni è composto da ObjDoubleConsumer, ObjIntConsumer, e ObjLongConsumer, che riceve due argomenti, uno degli argomenti è generified, e l’altro è un tipo primitivo.
Predicati
Nella logica matematica, un predicato è una funzione che riceve un valore e restituisce un valore booleano.
L’interfaccia funzionale predicato è una specializzazione di una funzione che riceve un valore generificato e restituisce un valore booleano. Un tipico caso d’uso del predicato lambda è quello di filtrare una raccolta di valori:
List<String> names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David");List<String> namesWithA = names.stream() .filter(name -> name.startsWith("A")) .collect(Collectors.toList());
Nel codice sopra, filtriamo un elenco usando l’API Stream e manteniamo solo i nomi che iniziano con la lettera “A”. L’implementazione del predicato incapsula la logica di filtraggio.
Come in tutti gli esempi precedenti, esistono versioni IntPredicate, DoublePredicate e LongPredicate di questa funzione che ricevono valori primitivi.
Operatori
Le interfacce operatore sono casi speciali di una funzione che riceve e restituisce lo stesso tipo di valore. L’interfaccia UnaryOperator riceve un singolo argomento. Uno dei suoi casi d’uso nell’API Collections consiste nel sostituire tutti i valori in un elenco con alcuni valori calcolati dello stesso tipo:
List<String> names = Arrays.asList("bob", "josh", "megan");names.replaceAll(name -> name.toUpperCase());
L’elenco.replaceAll funzione restituisce void in quanto sostituisce i valori in atto. Per adattarsi allo scopo, il lambda utilizzato per trasformare i valori di una lista deve restituire lo stesso tipo di risultato che riceve. Questo è il motivo per cui l’UnaryOperator è utile qui.
Naturalmente, invece di nome- > nome.toUpperCase (), possiamo semplicemente usare un riferimento al metodo:
names.replaceAll(String::toUpperCase);
Uno dei casi d’uso più interessanti di un BinaryOperator è un’operazione di riduzione. Supponiamo di voler aggregare una raccolta di numeri interi in una somma di tutti i valori. Con Stream API, potremmo farlo usando un collector, ma un modo più generico per farlo sarebbe usare il metodo reduce:
List<Integer> values = Arrays.asList(3, 5, 8, 9, 12);int sum = values.stream() .reduce(0, (i1, i2) -> i1 + i2);
Il metodo reduce riceve un valore di accumulatore iniziale e una funzione BinaryOperator. Gli argomenti di questa funzione sono una coppia di valori dello stesso tipo; la funzione stessa contiene anche una logica per unirli in un singolo valore dello stesso tipo. La funzione passata deve essere associativa, il che significa che l’ordine di aggregazione del valore non ha importanza, cioè la seguente condizione dovrebbe contenere:
op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)
La proprietà associativa di una funzione dell’operatore BinaryOperator ci consente di parallelizzare facilmente il processo di riduzione.
Naturalmente, ci sono anche specializzazioni di UnaryOperator e BinaryOperator che possono essere utilizzate con valori primitivi, ovvero DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator, DoubleBinaryOperator, IntBinaryOperator e LongBinaryOperator.
Interfacce funzionali legacy
Non tutte le interfacce funzionali sono apparse in Java 8. Molte interfacce delle versioni precedenti di Java sono conformi ai vincoli di un’interfaccia funzionale e possiamo usarle come lambda. Esempi importanti includono le interfacce Runnable e Callable utilizzate nelle API di concorrenza. In Java 8, queste interfacce sono anche contrassegnate con un’annotazione @ FunctionalInterface. Questo ci permette di semplificare notevolmente il codice di concorrenza:
Thread thread = new Thread(() -> System.out.println("Hello From Another Thread"));thread.start();