W3docs

Java BinaryOperator und UnaryOperator

Spezialisierte funktionale Interfaces in Java für Operationen auf Operanden desselben Typs — BinaryOperator und UnaryOperator.

Die letzten beiden Detailbetrachtungen funktionaler Interfaces in Teil 12 schließen die Vier-Ecken-Taxonomie mit den gleiche-Typ-Spezialisierungen ab:

  • UnaryOperator<T> erweitert Function<T, T> — eine Eingabe, eine Ausgabe, gleicher Typ. Die Form hinter List.replaceAll, Map.replaceAll und jedem „In-Place-Transformations"-Aufruf.
  • BinaryOperator<T> erweitert BiFunction<T, T, T> — zwei Eingaben und eine Ausgabe, alle vom gleichen Typ. Die Form hinter Stream.reduce, Map.merge und dem parallelen Schritt „zwei Teilergebnisse zu einem zusammenführen".

Keines der Interfaces fügt neue SAMs hinzu — sie erben apply vom übergeordneten Interface. Was sie jedoch hinzufügen, sind zwei kurze statische Methoden auf BinaryOperator, nämlich minBy und maxBy, die häufig genug vorkommen, um sie namentlich zu kennen.

UnaryOperator<T> — Transformation gleichen Typs

@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
  static <T> UnaryOperator<T> identity();          // returns t -> t
}

Das ist die vollständige Deklaration. Alles andere (apply, andThen, compose) wird von Function<T, T> geerbt.

Ein UnaryOperator<T> ist gleichzeitig ein Function<T, T>, sodass überall dort, wo ein Function<String, String> erwartet wird, auch ein UnaryOperator<String> passt. Das Umgekehrte gilt nicht: Ein Function<String, Object> ist kein UnaryOperator<String>. Dieser Unterschied ist wichtig, wenn die API gezielt die Gleiche-Typ-Garantie benötigt:

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>) schreibt jedes Element an Ort und Stelle um. Da der Parameter UnaryOperator<E> ist, verweigert der Compiler jede Transformation, die den Elementtyp ändern würde — was genau das ist, was man für eine In-Place-Mutation benötigt.

Primitive Spezialisierungen sind vorhanden, wo sie sich im Stream-Code lohnen:

IntUnaryOperator    doubleIt = i -> i * 2;
LongUnaryOperator   biggify  = n -> n + 1_000_000L;
DoubleUnaryOperator halve    = d -> d / 2.0;

IntStream.map(IntUnaryOperator) ist die Boxing-freie Variante von Stream<Integer>.map(Function<Integer, Integer>).

BinaryOperator<T> — zwei Werte gleichen Typs kombinieren

@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);
}

Ein BinaryOperator<T> ist „kombiniere diese zwei Ts zu einem T." Diese Form existiert, weil Kombinieren die Operation ist, die parallele Reduktion benötigt:

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; };

Jede Methode nimmt zwei Dinge desselben Typs und gibt eines desselben Typs zurück. Das ist die einzige Anforderung.

Wo BinaryOperator<T> auftaucht

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 ist der Hauptanwendungsfall. Der übergebene BinaryOperator<T> wird wiederholt aufgerufen, um einen Stream von T auf ein einziges T zu falten. In einem parallelen Stream werden Teilergebnisse aus verschiedenen Threads mit demselben Operator kombiniert — weshalb der Operator assoziativ sein muss: (a ⊕ b) ⊕ c und a ⊕ (b ⊕ c) müssen dasselbe Ergebnis liefern, unabhängig davon, wie die JVM die Arbeit aufteilt.

Map.merge(key, value, remapping) ist der andere Ort, an dem ein BinaryOperator<V> im alltäglichen Code vorkommt — und es ist die sauberste Möglichkeit, einen Zähler in einer Map zu inkrementieren:

Map<String, Integer> counts = new HashMap<>();
for (String word : words) counts.merge(word, 1, Integer::sum);

Ist der Schlüssel nicht vorhanden, wird der Wert unverändert gespeichert; ist der Schlüssel vorhanden, kombiniert der BinaryOperator<V> als Remapping-Funktion den alten und den neuen Wert.

minBy und maxBy — die offensichtliche Reduktion benennen

Zwei kurze statische Factory-Methoden, die einen Comparator kapseln:

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

Man könnte die Lambdas von Hand schreiben — (a, b) -> a.age() > b.age() ? a : b — aber BinaryOperator.maxBy(cmp) vermittelt direkt die Absicht und nutzt einen bestehenden Comparator wieder. Collectors.maxBy(cmp) ist die Collector-Form; beide gelangen über unterschiedliche APIs zum gleichen Ergebnis.

Assoziativität ist der Vertrag

Der Compiler kann nicht prüfen, ob ein BinaryOperator<T> assoziativ ist. Das JDK setzt es voraus. Bei einem sequentiellen reduce fällt ein Assoziativitätsfehler nur auf, wenn der Operator auch nicht kommutativ ist; bei einem parallelen reduce liefern nicht-assoziative Operatoren nicht-deterministische Ergebnisse — dieselbe Eingabe, unterschiedliche Gesamtergebnisse bei verschiedenen Ausführungen:

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, Listverkettung, Mengenvereinigung und Zeichenkettenverkettung sind alle assoziativ. Subtraktion und Division sind es nicht. Werden diese in einem BinaryOperator verwendet, entsteht ein Parallelitätsfehler, der nur darauf wartet, aufzutreten.

Ein durchgearbeitetes Beispiel: replaceAll, reduce, merge und die statischen minBy/maxBy-Methoden

Das folgende Programm verwendet UnaryOperator<String>, um eine Liste in-place in Großbuchstaben umzuwandeln, reduziert einen IntStream mit einem BinaryOperator über die Methodenreferenz Integer::sum, nutzt Map.merge zum Aufbau eines Worthäufigkeits-Histogramms und verwendet BinaryOperator.maxBy mit Stream.reduce, um die älteste Person in einer Liste zu finden.

java— editable, runs on the server

Was aus der Ausführung mitgenommen werden sollte:

  • names.replaceAll(String::toUpperCase) hat die Liste in-place umgeschrieben. Die Form UnaryOperator<String> war es, die Typsicherheit garantierte — String::length hätte nicht kompiliert, da es keinen String zurückgibt.
  • Stream.reduce(0, Integer::sum) hat fünf Ganzzahlen mithilfe eines assoziativen BinaryOperator<Integer> zu einer einzigen gefaltet. Das Identitätselement 0 machte den Fall eines leeren Streams sinnvoll: Ein leerer Stream wird auf das Identitätselement reduziert.
  • Stream.reduce(BinaryOperator) ohne ein Identitätselement gab Optional<T> zurück — für einen leeren Stream gibt es keine sinnvolle Antwort, wenn kein Identitätselement angegeben ist.
  • counts.merge(w, 1, Integer::sum) ist das einzeilige Worthäufigkeits-Idiom. Es speichert 1, wenn der Schlüssel fehlt, und addiert 1 zum vorhandenen Wert, wenn er vorhanden ist. Der BinaryOperator<Integer> ist der Kombinationsschritt.
  • BinaryOperator.maxBy(Comparator.comparingInt(Person::age)) benennt die Reduktion als „nach Alter vergleichen und den Größeren behalten". Das Lambda-Äquivalent funktioniert, aber die benannte statische Methode drückt die Absicht direkt aus.
  • Die nicht-assoziative (a, b) -> a - b-Reduktion lieferte in sequenziellem und parallelem Modus unterschiedliche Zahlen — das parallele Ergebnis ist das, was die Arbeitsaufteilung zufällig ergeben hat. Assoziativität ist ein Vertrag, der im Typ nicht sichtbar ist, auf den die Laufzeitumgebung aber vollständig angewiesen ist.

Wie es weitergeht

Damit schließt Teil 12. Die vollständige funktionale Terminologie des JDK wurde behandelt: funktionale Interfaces und @FunctionalInterface, Lambdas, Methodenreferenzen, das java.util.function-Paket von Anfang bis Ende, die Stream-Pipeline (Quellen, Zwischenoperationen, Terminaloperationen, Collectors, parallel), Optional und schließlich Predicate, Function, Consumer/Supplier sowie die Operator-Familie im Einzelnen. Der nächste Teil, Datei und I/O, beginnt mit Java I/O-Einführung — die Byte-vs.-Zeichen-Trennung, die gepufferte Stream-Schicht und wie java.io mit der neueren java.nio.file-API zusammenhängt. Mehrere Muster aus diesem Teil — try-with-resources, die Consumer/Supplier-Formen zum Lesen und Schreiben sowie die Stream-Pipeline für zeilenorientierte Dateien — tauchen sofort auf.

Übungen

Übung
Sie möchten ein einzeiliges Idiom, das einen Zähler pro Wort in einer `Map<String, Integer>` inkrementiert. Welcher Aufruf erledigt das korrekt mit einem `BinaryOperator<Integer>`?
Sie möchten ein einzeiliges Idiom, das einen Zähler pro Wort in einer `Map<String, Integer>` inkrementiert. Welcher Aufruf erledigt das korrekt mit einem `BinaryOperator<Integer>`?
Was this page helpful?