Java BinaryOperator and UnaryOperator
Specialized functional interfaces in Java for operations on operands of the same type — BinaryOperator and UnaryOperator.
Java BinaryOperator and UnaryOperator
The last two functional-interface zoom-ins in Part 12 close the four-corner taxonomy with the same-type specialisations:
UnaryOperator<T>extendsFunction<T, T>— one input, one output, same type. The shape behindList.replaceAll,Map.replaceAll, and any "transform-in-place" call.BinaryOperator<T>extendsBiFunction<T, T, T>— two inputs and one output, all the same type. The shape behindStream.reduce,Map.merge, and the parallel "combine two partials into one" step.
Neither interface adds new SAMs — they inherit apply from their parent. What they do add is two short statics on BinaryOperator, minBy and maxBy, that come up often enough to know by name.
UnaryOperator<T> — same-type transformation
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity(); // returns t -> t
}That's the whole declaration. Everything else (apply, andThen, compose) is inherited from Function<T, T>.
A UnaryOperator<T> is also a Function<T, T>, so anywhere a Function<String, String> is accepted, a UnaryOperator<String> fits. The reverse is not true: a Function<String, Object> is not a UnaryOperator<String>. The difference matters when the API specifically wants the same-type guarantee:
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>) rewrites every element in place. Because the parameter is UnaryOperator<E>, the compiler refuses any transformation that would change the element type — which is exactly what you want for an in-place mutation.
Primitive specialisations exist where they pay off in stream code:
IntUnaryOperator doubleIt = i -> i * 2;
LongUnaryOperator biggify = n -> n + 1_000_000L;
DoubleUnaryOperator halve = d -> d / 2.0;IntStream.map(IntUnaryOperator) is the no-box version of Stream<Integer>.map(Function<Integer, Integer>).
BinaryOperator<T> — combining two values of the same type
@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);
}A BinaryOperator<T> is "combine these two Ts into one T." The shape exists because combining is the operation parallel reduction needs:
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; };Each takes two of the same thing and returns one of the same thing. That's the only requirement.
Where BinaryOperator<T> shows up
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 is the headline use site. The BinaryOperator<T> you pass is called repeatedly to fold a stream of T into a single T. In a parallel stream, partial results from different threads are combined with the same operator — which is why the operator must be associative: (a ⊕ b) ⊕ c and a ⊕ (b ⊕ c) must give the same result, regardless of how the JVM splits the work.
Map.merge(key, value, remapping) is the other place a BinaryOperator<V> lives in everyday code — and it's the cleanest way to implement "increment a counter in a map":
Map<String, Integer> counts = new HashMap<>();
for (String word : words) counts.merge(word, 1, Integer::sum);If the key is absent, the value is stored as is; if the key is present, the remapping BinaryOperator<V> combines the old and new values.
minBy and maxBy — naming the obvious reduction
Two short static factories that wrap a 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);You could write the lambdas by hand — (a, b) -> a.age() > b.age() ? a : b — but BinaryOperator.maxBy(cmp) reads as the intent and reuses an existing Comparator. Collectors.maxBy(cmp) is the collector form; the two land at the same answer through different APIs.
Associativity is the contract
The compiler can't check that your BinaryOperator<T> is associative. The JDK assumes it. In a sequential reduce an associativity bug only changes the result if the operator also isn't commutative; in a parallel reduce, non-associative operators give non-deterministic answers — same input, different totals on different runs:
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, list concatenation, set union, and string concatenation are all associative. Subtraction and division are not. Use those in a BinaryOperator and you're shipping a parallelism bug waiting to surface.
A worked example: replaceAll, reduce, merge, and the minBy/maxBy statics
The program below uses UnaryOperator<String> to upper-case a list in place, reduces an IntStream with a BinaryOperator via the Integer::sum method reference, walks Map.merge to build a word-count histogram, and uses BinaryOperator.maxBy with Stream.reduce to find the oldest person in a list.
What to take from the run:
names.replaceAll(String::toUpperCase)rewrote the list in place. TheUnaryOperator<String>shape was what made it type-safe —String::lengthwould have failed to compile because it doesn't return aString.Stream.reduce(0, Integer::sum)folded five integers into one using an associativeBinaryOperator<Integer>. The identity element0made the empty-stream case meaningful: an empty stream reduces to the identity.Stream.reduce(BinaryOperator)without an identity returnedOptional<T>— there's no sensible answer for an empty stream when no identity is supplied.counts.merge(w, 1, Integer::sum)is the one-line word-count idiom. It puts1when the key is absent and adds1to the existing value when it's present. TheBinaryOperator<Integer>is the combine step.BinaryOperator.maxBy(Comparator.comparingInt(Person::age))named the reduction as "compare by age and keep the larger." The lambda equivalent works, but the named static reads as the intent.- The non-associative
(a, b) -> a - breduction returned different numbers in sequential and parallel modes — the parallel result is whatever the work-split happened to compute. Associativity is a contract you can't see in the type but the runtime depends on entirely.
What's next
That closes Part 12. You've now seen the full functional vocabulary the JDK ships: functional interfaces and @FunctionalInterface, lambdas, method references, the java.util.function package end to end, the stream pipeline (sources, intermediates, terminals, collectors, parallel), Optional, and finally Predicate, Function, Consumer/Supplier, and the operator family one at a time. The next part, File and I/O, picks up with Java I/O Introduction — the byte vs. character split, the buffered-stream layer, and how java.io relates to the newer java.nio.file API. Several of the patterns from this part — try-with-resources, the Consumer/Supplier shapes for reading and writing, and the stream pipeline for line-oriented files — show up immediately.
Practice
You want a one-line idiom that increments a counter per word in a `Map<String, Integer>`. Which call does it correctly with a `BinaryOperator<Integer>`?