W3docs

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> estende Function<T, T> — un input, un output, stesso tipo. La forma alla base di List.replaceAll, Map.replaceAll e di qualsiasi chiamata di tipo "trasformazione in-place".
  • BinaryOperator<T> estende BiFunction<T, T, T> — due input e un output, tutti dello stesso tipo. La forma alla base di Stream.reduce, Map.merge e 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 -> Integer

List.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.

java— editable, runs on the server

Cosa ricavare dall'esecuzione:

  • names.replaceAll(String::toUpperCase) ha riscritto la lista in-place. La forma UnaryOperator<String> è ciò che l'ha resa type-safe — String::length non sarebbe compilato perché non restituisce una String.
  • Stream.reduce(0, Integer::sum) ha ridotto cinque interi in uno usando un BinaryOperator<Integer> associativo. L'elemento identità 0 ha reso significativo il caso di stream vuoto: uno stream vuoto si riduce all'identità.
  • Stream.reduce(BinaryOperator) senza un'identità ha restituito Optional<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. Inserisce 1 quando la chiave è assente e aggiunge 1 al valore esistente quando è presente. Il BinaryOperator<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 - b ha 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.

Esercitazione

Pratica
Vuoi un idioma in una riga che incrementi un contatore per parola in una `Map<String, Integer>`. Quale chiamata lo esegue correttamente con un `BinaryOperator<Integer>`?
Vuoi un idioma in una riga che incrementi un contatore per parola in una `Map<String, Integer>`. Quale chiamata lo esegue correttamente con un `BinaryOperator<Integer>`?
Was this page helpful?