Interfacce Funzionali Built-in di Java
Il package java.util.function — Function, Predicate, Consumer, Supplier e le loro varianti specializzate.
Il package java.util.function è stato introdotto con Java 8 per fornire al JDK — e al tuo codice — un vocabolario condiviso per le lambda. Senza di esso, ogni metodo che accettava una funzione avrebbe dovuto definire la propria interfaccia ad hoc (StringMapper, IntToBool, RowHandler, …), e le lambda definite per una non potevano essere riutilizzate per un'altra. Il package risolve questo problema con 43 piccole interfacce che coprono le forme che si ripresentano continuamente: "prendi una cosa, restituiscine un'altra", "prendi una cosa, decidi sì o no", "prendi una cosa, fai qualcosa", "dammi una cosa".
Se impari solo quattro interfacce da questo package, impara Function, Predicate, Consumer e Supplier. Quasi tutto il resto è una variante di una di esse — versioni a due argomenti, specializzazioni primitive per evitare il boxing, o helper per la composizione.
Le quattro principali
Function<T, R> f = t -> ...; // takes a T, returns an R — r = f.apply(t)
Predicate<T> p = t -> ...; // takes a T, returns a boolean — boolean b = p.test(t)
Consumer<T> c = t -> { ... }; // takes a T, returns nothing — c.accept(t)
Supplier<T> s = () -> ...; // takes nothing, returns a T — t = s.get()Ognuna è annotata con @FunctionalInterface e ha un metodo astratto con un nome di una parola (apply, test, accept, get). Raramente chiamerai quei metodi direttamente quando si lavora con gli stream — stream().filter(predicate).map(function).forEach(consumer) esegue la chiamata al posto tuo — ma conoscere il nome del metodo è importante quando scrivi codice che accetta un Function<T, R> come parametro e deve invocarlo.
Le forme corrispondono a domande comuni:
| Domanda | Interfaccia |
|---|---|
| "Trasformare una X in una Y?" | Function<X, Y> |
| "Questa X è valida?" | Predicate<X> |
| "Fare qualcosa con questa X" | Consumer<X> |
| "Dammi una X" | Supplier<X> |
Varianti a due argomenti
Quando l'operazione richiede due input, aggiungi il prefisso Bi:
BiFunction<T, U, R> f = (t, u) -> ...; // two ins, one out — apply
BiPredicate<T, U> p = (t, u) -> ...; // two ins, a boolean — test
BiConsumer<T, U> c = (t, u) -> { ... }; // two ins, no out — acceptNon esiste BiSupplier — Supplier per definizione non prende argomenti, quindi un "supplier a due argomenti" sarebbe semplicemente una BiFunction.
Le varianti Bi sono esattamente ciò che Map.forEach((k, v) -> ...), Map.merge e Map.compute si aspettano:
Map<String, Integer> scores = new HashMap<>();
scores.forEach((name, score) -> System.out.println(name + "=" + score)); // BiConsumer
scores.merge("alice", 1, Integer::sum); // BinaryOperator<Integer>BinaryOperator<T> è una BiFunction<T, T, T> — stesso tipo per entrambi gli input e per l'output. UnaryOperator<T> è analogamente una Function<T, T>.
Specializzazioni primitive — evitare il costo del boxing
Function<Integer, Integer> funziona, ma ogni chiamata esegue il boxing dell'input e dell'output. In un ciclo stretto questo ha un costo reale. Il package offre quindi versioni specializzate per i tipi primitivi:
IntFunction<R> f = i -> ...; // int in, R out
IntPredicate p = i -> ...; // int in, boolean out
IntConsumer c = i -> { ... }; // int in, void
IntSupplier s = () -> 42; // void in, int out
IntUnaryOperator u = i -> i * 2; // int in, int out
IntBinaryOperator b = (a, c2) -> a + c2;
ToIntFunction<T> f1 = t -> t.hashCode(); // T in, int out
ToIntBiFunction<T, U> f2 = (t, u) -> t.hashCode() + u.hashCode();
IntToLongFunction f3 = i -> (long) i * i; // int in, long out
IntToDoubleFunction f4 = i -> Math.sqrt(i);La stessa famiglia esiste per Long e Double. La convenzione di denominazione si legge come una frase:
IntX— opera su unint.ToIntX— produce unint.IntToLongX—intin ingresso,longin uscita.
Nel codice con gli stream, mapToInt(...) restituisce un IntStream, le cui operazioni terminali (sum, average, min, max) restituiscono tutte valori primitivi senza boxing — che è uno dei maggiori vantaggi pratici delle varianti primitive.
Composizione integrata nelle interfacce
La maggior parte delle interfacce include metodi default che consentono di comporre senza scrivere nuove lambda:
// Function: andThen (left-to-right), compose (right-to-left)
Function<String, String> trim = String::trim;
Function<String, Integer> len = String::length;
Function<String, Integer> trimLen = trim.andThen(len); // trim, then length
Function<String, Integer> sameThing = len.compose(trim); // length applied after trim
// Predicate: and / or / negate
Predicate<String> notNull = Objects::nonNull;
Predicate<String> notBlank = s -> !s.trim().isEmpty();
Predicate<String> useful = notNull.and(notBlank);
Predicate<String> blank = notBlank.negate();
// Consumer: andThen (run two consumers in sequence)
Consumer<String> log = System.out::println;
Consumer<String> save = s -> writeToFile(s);
Consumer<String> both = log.andThen(save);
// Comparator (in java.util, not java.util.function, but the same idea):
Comparator<Person> byName = Comparator.comparing(Person::name);
Comparator<Person> ordered = byName.thenComparing(Person::age);Esiste anche un utile factory statico: Predicate.not(p) è una scorciatoia per p.negate() e si legge in modo più naturale nel punto di chiamata:
list.removeIf(Predicate.not(String::isBlank)); // remove all blank stringsFunction.identity e Predicate.isEqual — i piccoli statici utili
Due metodi factory che incontrerai nel codice con gli stream e che dovresti riconoscere:
Function<T, T> id = Function.identity(); // t -> t — useful as a no-op map
Predicate<Object> isFoo = Predicate.isEqual("foo"); // o -> Objects.equals(o, "foo")Function.identity() è usato più spesso come mapper di chiavi o valori in Collectors.toMap:
Map<String, Person> byName = people.stream()
.collect(Collectors.toMap(Person::name, Function.identity()));Predicate.isEqual raramente è più breve di s -> s.equals("foo"), ma confronta in modo null-safe tramite Objects.equals, il che è importante quando lo stream può contenere null.
Un esempio pratico: le quattro principali, composizione e specializzazione primitiva
Il programma qui sotto utilizza Function, Predicate, Consumer e Supplier, ne compone alcune e confronta una Function<Integer, Integer> (con boxing) con un IntUnaryOperator (primitiva) sommando una piccola lista.
Cosa ricavare dall'esecuzione:
- Le quattro interfacce principali si mappano chiaramente su quattro tipi di lavoro: trasformare (
Function), verificare (Predicate), agire (Consumer), produrre (Supplier). Vale la pena memorizzare i nomi dei loro metodi astratti (apply,test,accept,get). trim.andThen(length)enotNull.and(notBlank)hanno costruito nuovi valori da quelli esistenti senza dichiarare metodi helper. Questa è l'algebra di composizione che le interfacce portano come metodidefault.- La
Function<Integer, Integer>con boxing è significativamente più lenta dell'IntUnaryOperatorprimitiva perché ogni chiamata alloca due oggettiInteger. Nei percorsi critici — pipeline di stream che elaborano milioni di valori — le specializzazioni primitive si giustificano pienamente. Predicate.not(notBlank)si legge in modo più naturale dinotBlank.negate()in un punto di chiamataremoveIf. Entrambi compilano nella stessa cosa.
Cosa viene dopo
Hai ora visto il vocabolario standard. La domanda rimanente sull'ergonomia delle lambda è "quando il corpo della lambda si limita a delegare a un metodo esistente, posso scriverlo in modo più conciso?" Sì — con i riferimenti a metodi. Il prossimo capitolo, Riferimenti a Metodi di Java, tratta l'operatore :: e le sue quattro forme (statico, istanza vincolata, istanza non vincolata, costruttore), e spiega quando un riferimento a metodo è più chiaro di una lambda e quando è il contrario.