Using Java BinaryOperator for Concise Reductions
Learn how Java's BinaryOperator simplifies same-type two-argument operations, with practical stream reduction examples, `andThen` composition, and common pitfalls.
BinaryOperator<T> is a Java functional interface for an operation that takes two arguments of type T and returns a value of the same type. It is a specialized form of BiFunction<T, T, T> and is a natural fit for reductions, where stream elements are repeatedly combined into one value.
BinaryOperator as a Specialization of BiFunction
BinaryOperator<T> extends BiFunction<T, T, T>. Its inherited functional method is therefore T apply(T left, T right), because the input and output type parameters are the same. This specialization means you do not need to declare three distinct type parameters when the input and output types match.
Because BinaryOperator extends BiFunction, it inherits the default method andThen, which can transform the result after the two-argument operation has run.
Using BinaryOperator with Lambda Expressions
The most direct way to create a BinaryOperator is with a lambda expression. For example, to add two integers:
BinaryOperator<Integer> add = (a, b) -> a + b; int sum = add.apply(10, 20); // 30
The lambda infers a and b as Integer from the generic type parameter, and the + operator uses unboxing. You can also use a method reference when an existing method matches the expected shape:
BinaryOperator<Integer> max = Integer::max; int larger = max.apply(10, 20); // 20
Method references are especially useful for operations that already exist on a class, such as static utility methods or comparator-based helpers.
Common Use Cases with Streams
The most common place you will encounter BinaryOperator is with Stream.reduce. The overload that takes a BinaryOperator combines stream elements into one result. For example, summing a list of integers:
List<Integer> numbers = List.of(1, 2, 3, 4); int total = numbers.stream() .reduce(0, (a, b) -> a + b);
The initial value 0 is the identity for addition. If the stream is empty, reduce returns that identity. The overload without an identity returns an Optional, which is empty for an empty stream.
BinaryOperator also appears when you need to merge partial results, such as a collector combiner or a merge function. For example, Collectors.toMap has an overload that takes a merge BinaryOperator. Assuming words is a List<String>, you can merge duplicate keys with a BinaryOperator:
Map<String, Integer> counts = words.stream() .collect(Collectors.toMap(w -> w, w -> 1, Integer::sum));
Here Integer::sum is the BinaryOperator<Integer> used to merge values for duplicate keys.
Combining BinaryOperators with andThen
BinaryOperator inherits andThen from BiFunction, which lets you pass the result of the binary operation to another function. For example:
BiFunction<Integer, Integer, Integer> addAndSquare = add.andThen(x -> x * x); int result = addAndSquare.apply(3, 4); // 49
andThen returns a BiFunction<T, T, R>, not a BinaryOperator<T>, because the output type R can differ from T. Even when R is the same as T, the static return type is still BiFunction, so you cannot assign it directly to a BinaryOperator variable. To keep a BinaryOperator, write the composition explicitly:
BinaryOperator<Integer> addAndSquare = (a, b) -> { int sum = add.apply(a, b); return sum * sum; };
Performance and Allocation Considerations
A stateless lambda implementing BinaryOperator can often be allocated cheaply, but allocation behavior is a JVM implementation detail. The more important correctness concerns are associativity and state.
When reduce runs on a parallel stream, the BinaryOperator must be associative; otherwise the result can vary depending on how the stream is split. Subtraction is not associative: (10 - 5) - 3 is 2, while 10 - (5 - 3) is 8. Prefer stateless lambdas, because a lambda that captures mutable state can introduce concurrency issues in parallel execution.
Edge Cases and Type Inference
When generic types are involved, the compiler usually infers the type from the target type. For example:
static <T> BinaryOperator<T> choose(boolean first) { return (a, b) -> first ? a : b; }
The lambda (a, b) -> first ? a : b compiles because the target type tells the compiler that a and b are both T. Avoid raw BinaryOperator; it weakens type safety and causes unchecked warnings.
BinaryOperator also provides minBy and maxBy, which take a Comparator:
BinaryOperator<String> longest = BinaryOperator.maxBy(Comparator.comparingInt(String::length)); String longestWord = longest.apply("cat", "elephant"); // "elephant"
These static methods are useful when reducing a stream to a single extreme value with a comparator.
Choosing Between BinaryOperator and BiFunction
Use BinaryOperator when the input and output types are the same. Use BiFunction when the output type differs. For example, a function that concatenates the string representations of two integers should be declared as BiFunction<Integer, Integer, String>, while a function that adds two integers can be a BinaryOperator<Integer>.
Choosing BinaryOperator also makes the symmetry of the operation visible in the type, which can improve readability in code that composes functions.
Method Reference Compatibility
A method reference is compatible with BinaryOperator<T> when it can be adapted to the target descriptor T apply(T, T). For an unbound instance method reference, the first argument becomes the receiver. String::concat, for example, works as a BinaryOperator<String> because the receiver and the argument are both String, and the result is a String.
A static method with primitive parameters can also be adapted through boxing and unboxing. Integer::compare is a valid BinaryOperator<Integer>: the target Integer arguments are unboxed to int, and the int result is boxed back to Integer. If you prefer to make those conversions explicit, a lambda such as (a, b) -> Integer.compare(a, b) is also fine.