Java BinaryOperator e UnaryOperator
Interfacce funzionali specializzate in Java per operazioni su operandi dello stesso tipo: BinaryOperator e UnaryOperator.
Le ultime due analisi di interfacce funzionali della Parte 12 chiudono la tassonomia a quattro angoli con le specializzazioni per il tipo identico:
UnaryOperator<T>estendeFunction<T, T>— un input, un output, stesso tipo. La forma alla base diList.replaceAll,Map.replaceAlle di qualsiasi chiamata di tipo "trasformazione in-place".BinaryOperator<T>estendeBiFunction<T, T, T>— due input e un output, tutti dello stesso tipo. La forma alla base diStream.reduce,Map.mergee dello step parallelo "combina due parziali in uno".
Nessuna delle due interfacce aggiunge nuovi SAM — ereditano apply dal genitore. Ciò che aggiungono sono due statici brevi su BinaryOperator, minBy e maxBy, abbastanza comuni da dover conoscere per nome.
UnaryOperator<T> — trasformazione dello stesso tipo
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity(); // returns t -> t
}Questa è l'intera dichiarazione. Tutto il resto (apply, andThen, compose) è ereditato da Function<T, T>.
Un UnaryOperator<T> è anche un Function<T, T>, quindi ovunque venga accettata una Function<String, String>, si adatta anche un UnaryOperator<String>. Il contrario non è vero: una Function<String, Object> non è un UnaryOperator<String>. La differenza conta quando l'API richiede specificamente la garanzia del tipo identico:
List<String> names = new ArrayList<>(List.of("alice", "bob"));
names.replaceAll(String::toUpperCase); // UnaryOperator<String>
// names.replaceAll(String::length); // would not compile — String -> IntegerList.replaceAll(UnaryOperator<E>) riscrive ogni elemento in-place. Poiché il parametro è UnaryOperator<E>, il compilatore rifiuta qualsiasi trasformazione che cambierebbe il tipo dell'elemento — che è esattamente ciò che si vuole per una mutazione in-place.
Le specializzazioni primitive esistono dove risultano utili nel codice degli stream:
IntUnaryOperator doubleIt = i -> i * 2;
LongUnaryOperator biggify = n -> n + 1_000_000L;
DoubleUnaryOperator halve = d -> d / 2.0;IntStream.map(IntUnaryOperator) è la versione senza boxing di Stream<Integer>.map(Function<Integer, Integer>).
BinaryOperator<T> — combinare due valori dello stesso tipo
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
static <T> BinaryOperator<T> minBy(Comparator<? super T> c);
static <T> BinaryOperator<T> maxBy(Comparator<? super T> c);
}Un BinaryOperator<T> significa "combina questi due T in un T." Questa forma esiste perché il combining è l'operazione di cui la riduzione parallela ha bisogno:
BinaryOperator<Integer> sum = Integer::sum;
BinaryOperator<String> concat = String::concat;
BinaryOperator<List<String>> merge = (a, b) -> { var c = new ArrayList<>(a); c.addAll(b); return c; };Ognuno prende due elementi dello stesso tipo e restituisce uno dello stesso tipo. Questo è l'unico requisito.
Dove compare BinaryOperator<T>
int total = nums.stream().reduce(0, Integer::sum); // Stream.reduce(identity, BinaryOperator)
Optional<Integer> max = nums.stream().reduce(Integer::max); // Stream.reduce(BinaryOperator)
Optional<Integer> max2 = nums.stream()
.reduce(BinaryOperator.maxBy(Integer::compare)); // same thing, named
scores.merge("alice", 1, Integer::sum); // Map.merge(K, V, BinaryOperator<V>)Stream.reduce è il sito di utilizzo principale. Il BinaryOperator<T> che si passa viene chiamato ripetutamente per ridurre uno stream di T a un singolo T. In uno stream parallelo, i risultati parziali di thread diversi vengono combinati con lo stesso operatore — motivo per cui l'operatore deve essere associativo: (a ⊕ b) ⊕ c e a ⊕ (b ⊕ c) devono dare lo stesso risultato, indipendentemente da come la JVM divide il lavoro.
Map.merge(key, value, remapping) è l'altro posto dove un BinaryOperator<V> vive nel codice quotidiano — ed è il modo più pulito per implementare "incrementa un contatore in una mappa":
Map<String, Integer> counts = new HashMap<>();
for (String word : words) counts.merge(word, 1, Integer::sum);Se la chiave è assente, il valore viene memorizzato così com'è; se la chiave è presente, il BinaryOperator<V> di remapping combina il vecchio e il nuovo valore.
minBy e maxBy — denominare la riduzione ovvia
Due brevi factory statici che avvolgono un Comparator:
BinaryOperator<Person> oldest = BinaryOperator.maxBy(Comparator.comparingInt(Person::age));
BinaryOperator<Person> shortest = BinaryOperator.minBy(Comparator.comparing(Person::name));
Optional<Person> winner = people.stream().reduce(oldest);Si potrebbero scrivere le lambda a mano — (a, b) -> a.age() > b.age() ? a : b — ma BinaryOperator.maxBy(cmp) esprime l'intento e riutilizza un Comparator esistente. Collectors.maxBy(cmp) è la forma come collector; i due arrivano allo stesso risultato attraverso API diverse.
L'associatività è il contratto
Il compilatore non può verificare che il tuo BinaryOperator<T> sia associativo. La JDK lo assume. In una reduce sequenziale un bug di associatività cambia il risultato solo se l'operatore non è commutativo; in una reduce parallela, operatori non associativi danno risposte non deterministiche — stesso input, totali diversi a ogni esecuzione:
BinaryOperator<Integer> bad = (a, b) -> a - b; // not associative
// ((1 - 2) - 3) = -4
// (1 - (2 - 3)) = 2
// In a parallel reduce, you get whichever the split happened to produce.+, *, min, max, la concatenazione di liste, l'unione di insiemi e la concatenazione di stringhe sono tutte associative. La sottrazione e la divisione non lo sono. Usarle in un BinaryOperator significa introdurre un bug di parallelismo che prima o poi si manifesterà.
Esempio pratico: replaceAll, reduce, merge e gli statici minBy/maxBy
Il programma seguente usa UnaryOperator<String> per rendere maiuscola una lista in-place, riduce un IntStream con un BinaryOperator tramite il riferimento al metodo Integer::sum, usa Map.merge per costruire un istogramma delle parole e usa BinaryOperator.maxBy con Stream.reduce per trovare la persona più anziana in una lista.
Cosa ricavare dall'esecuzione:
names.replaceAll(String::toUpperCase)ha riscritto la lista in-place. La formaUnaryOperator<String>è ciò che l'ha resa type-safe —String::lengthnon sarebbe compilato perché non restituisce unaString.Stream.reduce(0, Integer::sum)ha ridotto cinque interi in uno usando unBinaryOperator<Integer>associativo. L'elemento identità0ha reso significativo il caso di stream vuoto: uno stream vuoto si riduce all'identità.Stream.reduce(BinaryOperator)senza un'identità ha restituitoOptional<T>— non esiste una risposta sensata per uno stream vuoto quando non viene fornita alcuna identità.counts.merge(w, 1, Integer::sum)è l'idioma del conteggio delle parole in una riga. Inserisce1quando la chiave è assente e aggiunge1al valore esistente quando è presente. IlBinaryOperator<Integer>è lo step di combinazione.BinaryOperator.maxBy(Comparator.comparingInt(Person::age))ha denominato la riduzione come "confronta per età e mantieni il maggiore." L'equivalente lambda funziona, ma lo statico con nome esprime l'intento.- La riduzione non associativa
(a, b) -> a - bha restituito numeri diversi in modalità sequenziale e parallela — il risultato parallelo è qualunque cosa la suddivisione del lavoro abbia prodotto. L'associatività è un contratto che non si vede nel tipo ma dal quale il runtime dipende interamente.
Prossimi passi
Questo chiude la Parte 12. Ora hai visto l'intero vocabolario funzionale fornito dalla JDK: interfacce funzionali e @FunctionalInterface, lambda, riferimenti ai metodi, il pacchetto java.util.function dall'inizio alla fine, la pipeline degli stream (sorgenti, intermedie, terminali, collector, parallelo), Optional, e infine Predicate, Function, Consumer/Supplier e la famiglia degli operatori uno alla volta. La parte successiva, File e I/O, inizia con Java I/O Introduction — la distinzione byte vs. carattere, il livello degli stream bufferizzati e come java.io si relaziona con la più recente API java.nio.file. Molti dei pattern di questa parte — try-with-resources, le forme Consumer/Supplier per la lettura e la scrittura, e la pipeline degli stream per file orientati alle righe — emergono immediatamente.