Interfacce funzionali in Java 8

1. Introduzione

Questo articolo è una guida alle diverse interfacce funzionali presenti in Java 8, ai loro casi d'uso generali e all'utilizzo nella libreria JDK standard.

2. Lambda in Java 8

Java 8 ha portato un nuovo e potente miglioramento sintattico sotto forma di espressioni lambda. Una lambda è una funzione anonima che può essere gestita come un cittadino del linguaggio di prima classe, ad esempio passata o restituita da un metodo.

Prima di Java 8, di solito creavi una classe per ogni caso in cui avevi bisogno di incapsulare un singolo pezzo di funzionalità. Ciò implicava un sacco di codice boilerplate non necessario per definire qualcosa che servisse come rappresentazione di una funzione primitiva.

Lambda, interfacce funzionali e best practice per lavorare con esse, in generale, sono descritte nell'articolo "Espressioni Lambda e interfacce funzionali: suggerimenti e best practice". Questa guida si concentra su alcune interfacce funzionali particolari presenti nel pacchetto java.util.function .

3. Interfacce funzionali

Si consiglia a tutte le interfacce funzionali di avere un'annotazione informativa @FunctionalInterface . Questo non solo comunica chiaramente lo scopo di questa interfaccia, ma consente anche a un compilatore di generare un errore se l'interfaccia annotata non soddisfa le condizioni.

Qualsiasi interfaccia con un SAM (Single Abstract Method) è un'interfaccia funzionale e la sua implementazione può essere trattata come espressioni lambda.

Nota che i metodi predefiniti di Java 8 non sono astratti e non contano: un'interfaccia funzionale può ancora avere più metodi predefiniti . È possibile osservarlo guardando la documentazione della funzione .

4. 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 Function che è parametrizzata dai tipi del suo argomento e da un valore di ritorno:

public interface Function { … }

Uno degli utilizzi del tipo Function nella libreria standard è il metodo Map.computeIfAbsent che 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 nameMap = new HashMap(); Integer value = nameMap.computeIfAbsent("John", s -> s.length());

Un valore, in questo caso, verrà calcolato applicando una funzione ad una chiave, inserita in una mappa e restituita anche da una chiamata al metodo. A proposito, possiamo sostituire lambda con un riferimento al metodo che corrisponde ai tipi di valore passati e restituiti .

Ricorda che un oggetto su cui viene invocato il metodo è, infatti, il primo argomento implicito di un metodo, che consente di eseguire il cast di un riferimento della lunghezza del metodo di istanza a un'interfaccia Function :

Integer value = nameMap.computeIfAbsent("John", String::length);

L' interfaccia Function ha anche un metodo di composizione predefinito che consente di combinare più funzioni in una ed eseguirle in sequenza:

Function intToString = Object::toString; Function quote = s -> "'" + s + "'"; Function quoteIntToString = quote.compose(intToString); assertEquals("'5'", quoteIntToString.apply(5));

La funzione quoteIntToString è una combinazione della funzione quote applicata a un risultato della funzione intToString .

5. Specializzazioni di funzioni primitive

Poiché un tipo primitivo non può essere un argomento di tipo generico, esistono versioni dell'interfaccia Function per i tipi primitivi più utilizzati double , int , long e le loro combinazioni nei tipi di argomenti e restituiti:

  • IntFunction , LongFunction , DoubleFunction: gli argomenti sono del tipo specificato, il tipo restituito è parametrizzato
  • ToIntFunction , ToLongFunction , ToDoubleFunction: il tipo restituito è del tipo specificato, gli argomenti sono parametrizzati
  • DoubleToIntFunction , DoubleToLongFunction , IntToDoubleFunction , IntToLongFunction , LongToIntFunction , LongToDoubleFunction , con entrambi gli argomenti e il tipo restituito definiti come tipi primitivi, come specificato dai loro nomi

Non esiste un'interfaccia funzionale pronta all'uso, ad esempio, per una funzione che richiede un valore breve e restituisce un byte , ma nulla ti impedisce di scriverne una tua:

@FunctionalInterface public interface ShortToByteFunction { byte applyAsByte(short s); }

Ora possiamo scrivere un metodo che trasforma un array di short in un array di byte usando una regola definita da ShortToByteFunction :

public byte[] transformArray(short[] array, ShortToByteFunction function) { byte[] transformedArray = new byte[array.length]; for (int i = 0; i < array.length; i++) { transformedArray[i] = function.applyAsByte(array[i]); } return transformedArray; }

Ecco come potremmo usarlo per trasformare un array di short in array di byte moltiplicati 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);

6. Specializzazioni di funzioni a due arità

Per definire espressioni 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 generati, mentre ToDoubleBiFunction e altri consentono di restituire un valore primitivo.

Uno dei tipici esempi di utilizzo di questa interfaccia nell'API standard è il metodo Map.replaceAll , che consente di sostituire tutti i valori in una mappa con un valore calcolato.

Usiamo un BiFunction implementazione che riceve una chiave e un vecchio valore per calcolare un nuovo valore per il salario e restituirlo.

Map 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);

7. Fornitori

L' interfaccia funzionale del fornitore è un'altra specializzazione della funzione che non accetta argomenti. Viene in genere utilizzato per la generazione lenta di valori. Ad esempio, definiamo una funzione che piazza un valore doppio . Non riceverà un valore in sé, ma un Fornitore di questo valore:

public double squareLazy(Supplier lazyValue) { return Math.pow(lazyValue.get(), 2); }

Questo ci consente di generare pigramente l'argomento per il richiamo di questa funzione utilizzando un'implementazione del fornitore . Ciò può essere utile se la generazione di questo argomento richiede una notevole quantità di tempo. Lo simuleremo usando il metodo sleepUninterruptibly di Guava :

Supplier lazyValue = () -> { Uninterruptibles.sleepUninterruptibly(1000, TimeUnit.MILLISECONDS); return 9d; }; Double valueSquared = squareLazy(lazyValue);

Un altro caso d'uso per il fornitore è la definizione di una logica per la generazione della sequenza. Per dimostrarlo, utilizziamo un metodo Stream.generate statico per creare un flusso di numeri di Fibonacci:

int[] fibs = {0, 1}; Stream fibonacci = Stream.generate(() -> { int result = fibs[1]; int fib3 = fibs[0] + fibs[1]; fibs[0] = fibs[1]; fibs[1] = fib3; return result; });

La funzione passata al metodo Stream.generate 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 è composto da due ultimi numeri di sequenza di Fibonacci.

Per implementare questo stato, utilizziamo un array invece di un paio di variabili, perché tutte le variabili esterne utilizzate all'interno del lambda devono essere effettivamente finali .

Altre specializzazioni dell'interfaccia funzionale del fornitore includono BooleanSupplier , DoubleSupplier , LongSupplier e IntSupplier , i cui tipi restituiti sono primitive corrispondenti.

8. Consumatori

A differenza del Fornitore , il Consumatore accetta un argomento generato 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 passato al metodo List.forEach implementa l' interfaccia funzionale Consumer :

List names = Arrays.asList("John", "Freddy", "Samuel"); names.forEach(name -> System.out.println("Hello, " + name));

There are also specialized versions of the ConsumerDoubleConsumer, IntConsumer and LongConsumer — that receive primitive values as arguments. More interesting is the BiConsumer interface. One of its use cases is iterating through the entries of a map:

Map 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"));

Another set of specialized BiConsumer versions is comprised of ObjDoubleConsumer, ObjIntConsumer, and ObjLongConsumer which receive two arguments one of which is generified, and another is a primitive type.

9. Predicates

In mathematical logic, a predicate is a function that receives a value and returns a boolean value.

The Predicate functional interface is a specialization of a Function that receives a generified value and returns a boolean. A typical use case of the Predicate lambda is to filter a collection of values:

List names = Arrays.asList("Angela", "Aaron", "Bob", "Claire", "David"); List namesWithA = names.stream() .filter(name -> name.startsWith("A")) .collect(Collectors.toList());

In the code above we filter a list using the Stream API and keep only names that start with the letter “A”. The filtering logic is encapsulated in the Predicate implementation.

As in all previous examples, there are IntPredicate, DoublePredicate and LongPredicate versions of this function that receive primitive values.

10. Operators

Operator interfaces are special cases of a function that receive and return the same value type. The UnaryOperator interface receives a single argument. One of its use cases in the Collections API is to replace all values in a list with some computed values of the same type:

List names = Arrays.asList("bob", "josh", "megan"); names.replaceAll(name -> name.toUpperCase());

The List.replaceAll function returns void, as it replaces the values in place. To fit the purpose, the lambda used to transform the values of a list has to return the same result type as it receives. This is why the UnaryOperator is useful here.

Of course, instead of name -> name.toUpperCase(), you can simply use a method reference:

names.replaceAll(String::toUpperCase);

One of the most interesting use cases of a BinaryOperator is a reduction operation. Suppose we want to aggregate a collection of integers in a sum of all values. With Stream API, we could do this using a collector, but a more generic way to do it would be to use the reduce method:

List values = Arrays.asList(3, 5, 8, 9, 12); int sum = values.stream() .reduce(0, (i1, i2) -> i1 + i2); 

The reduce method receives an initial accumulator value and a BinaryOperator function. The arguments of this function are a pair of values of the same type, and a function itself contains a logic for joining them in a single value of the same type. Passed function must be associative, which means that the order of value aggregation does not matter, i.e. the following condition should hold:

op.apply(a, op.apply(b, c)) == op.apply(op.apply(a, b), c)

The associative property of a BinaryOperator operator function allows to easily parallelize the reduction process.

Of course, there are also specializations of UnaryOperator and BinaryOperator that can be used with primitive values, namely DoubleUnaryOperator, IntUnaryOperator, LongUnaryOperator, DoubleBinaryOperator, IntBinaryOperator, and LongBinaryOperator.

11. Legacy Functional Interfaces

Non tutte le interfacce funzionali apparivano in Java 8. Molte interfacce delle versioni precedenti di Java sono conformi ai vincoli di una FunctionalInterface e possono essere utilizzate come lambda. Un esempio importante sono le interfacce Runnable e Callable utilizzate nelle API di concorrenza. In Java 8 queste interfacce sono anche contrassegnate con un'annotazione @FunctionalInterface . Questo ci consente di semplificare notevolmente il codice di concorrenza:

Thread thread = new Thread(() -> System.out.println("Hello From Another Thread")); thread.start();

12. Conclusione

In questo articolo, abbiamo descritto diverse interfacce funzionali presenti nell'API Java 8 che possono essere utilizzate come espressioni lambda. Il codice sorgente dell'articolo è disponibile su GitHub.