W3docs

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> extends Function<T, T> — one input, one output, same type. The shape behind List.replaceAll, Map.replaceAll, and any "transform-in-place" call.
  • BinaryOperator<T> extends BiFunction<T, T, T> — two inputs and one output, all the same type. The shape behind Stream.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 -> Integer

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

java— editable, runs on the server

What to take from the run:

  • names.replaceAll(String::toUpperCase) rewrote the list in place. The UnaryOperator<String> shape was what made it type-safe — String::length would have failed to compile because it doesn't return a String.
  • Stream.reduce(0, Integer::sum) folded five integers into one using an associative BinaryOperator<Integer>. The identity element 0 made the empty-stream case meaningful: an empty stream reduces to the identity.
  • Stream.reduce(BinaryOperator) without an identity returned Optional<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 puts 1 when the key is absent and adds 1 to the existing value when it's present. The BinaryOperator<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 - b reduction 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

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>`?