Java BinaryOperator y UnaryOperator
Interfaces funcionales especializadas en Java para operaciones sobre operandos del mismo tipo: BinaryOperator y UnaryOperator.
Los dos últimos acercamientos a interfaces funcionales de la Parte 12 cierran la taxonomía de cuatro esquinas con las especializaciones de mismo tipo:
UnaryOperator<T>extiendeFunction<T, T>— una entrada, una salida, mismo tipo. La forma que subyace aList.replaceAll,Map.replaceAll, y cualquier llamada de "transformar en el lugar".BinaryOperator<T>extiendeBiFunction<T, T, T>— dos entradas y una salida, todas del mismo tipo. La forma que subyace aStream.reduce,Map.merge, y el paso paralelo de "combinar dos parciales en uno".
Ninguna interfaz añade nuevos SAMs — heredan apply de su padre. Lo que sí añaden son dos estáticos cortos en BinaryOperator, minBy y maxBy, que aparecen con suficiente frecuencia como para conocerlos por nombre.
UnaryOperator<T> — transformación del mismo tipo
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity(); // returns t -> t
}Esa es la declaración completa. Todo lo demás (apply, andThen, compose) se hereda de Function<T, T>.
Un UnaryOperator<T> es también un Function<T, T>, por lo que en cualquier lugar donde se acepte un Function<String, String>, cabe un UnaryOperator<String>. Lo contrario no es cierto: un Function<String, Object> no es un UnaryOperator<String>. La diferencia importa cuando la API quiere específicamente la garantía de mismo tipo:
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>) reescribe cada elemento en su lugar. Como el parámetro es UnaryOperator<E>, el compilador rechaza cualquier transformación que cambie el tipo del elemento — que es exactamente lo que se desea para una mutación en el lugar.
Existen especializaciones primitivas donde resultan útiles en el código de streams:
IntUnaryOperator doubleIt = i -> i * 2;
LongUnaryOperator biggify = n -> n + 1_000_000L;
DoubleUnaryOperator halve = d -> d / 2.0;IntStream.map(IntUnaryOperator) es la versión sin boxing de Stream<Integer>.map(Function<Integer, Integer>).
BinaryOperator<T> — combinando dos valores del mismo 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> es "combinar estos dos T en un T." La forma existe porque combinar es la operación que necesita la reducción paralela:
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; };Cada uno toma dos del mismo tipo y devuelve uno del mismo tipo. Ese es el único requisito.
Dónde aparece 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 es el caso de uso principal. El BinaryOperator<T> que se pasa se llama repetidamente para plegar un stream de T en un único T. En un stream paralelo, los resultados parciales de diferentes hilos se combinan con el mismo operador — por eso el operador debe ser asociativo: (a ⊕ b) ⊕ c y a ⊕ (b ⊕ c) deben dar el mismo resultado, independientemente de cómo la JVM divida el trabajo.
Map.merge(key, value, remapping) es el otro lugar donde vive un BinaryOperator<V> en el código cotidiano — y es la forma más limpia de implementar "incrementar un contador en un mapa":
Map<String, Integer> counts = new HashMap<>();
for (String word : words) counts.merge(word, 1, Integer::sum);Si la clave está ausente, el valor se almacena tal cual; si la clave está presente, el BinaryOperator<V> de remapping combina los valores viejo y nuevo.
minBy y maxBy — nombrando la reducción obvia
Dos fábricas estáticas cortas que envuelven 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);Se podrían escribir las lambdas a mano — (a, b) -> a.age() > b.age() ? a : b — pero BinaryOperator.maxBy(cmp) se lee como la intención y reutiliza un Comparator existente. Collectors.maxBy(cmp) es la forma en colector; ambas llegan a la misma respuesta a través de APIs diferentes.
La asociatividad es el contrato
El compilador no puede verificar que tu BinaryOperator<T> sea asociativo. El JDK lo asume. En un reduce secuencial, un error de asociatividad solo cambia el resultado si el operador tampoco es conmutativo; en un reduce paralelo, los operadores no asociativos dan respuestas no deterministas — misma entrada, diferentes totales en diferentes ejecuciones:
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 concatenación de listas, la unión de conjuntos y la concatenación de cadenas son todas asociativas. La resta y la división no lo son. Usar estas en un BinaryOperator implica incluir un bug de paralelismo esperando a surgir.
Un ejemplo práctico: replaceAll, reduce, merge y los estáticos minBy/maxBy
El programa a continuación usa UnaryOperator<String> para poner en mayúsculas una lista en el lugar, reduce un IntStream con un BinaryOperator mediante la referencia de método Integer::sum, recorre Map.merge para construir un histograma de palabras, y usa BinaryOperator.maxBy con Stream.reduce para encontrar a la persona más mayor en una lista.
Lo que se puede extraer de la ejecución:
names.replaceAll(String::toUpperCase)reescribió la lista en el lugar. La formaUnaryOperator<String>fue lo que la hizo segura en tipos —String::lengthno habría compilado porque no devuelve unString.Stream.reduce(0, Integer::sum)plegó cinco enteros en uno usando unBinaryOperator<Integer>asociativo. El elemento identidad0hizo significativo el caso del stream vacío: un stream vacío se reduce a la identidad.Stream.reduce(BinaryOperator)sin identidad devolvióOptional<T>— no hay una respuesta sensata para un stream vacío cuando no se proporciona identidad.counts.merge(w, 1, Integer::sum)es el idioma de conteo de palabras en una línea. Pone1cuando la clave está ausente y suma1al valor existente cuando está presente. ElBinaryOperator<Integer>es el paso de combinación.BinaryOperator.maxBy(Comparator.comparingInt(Person::age))nombró la reducción como "comparar por edad y conservar el mayor." El equivalente lambda funciona, pero el estático nombrado se lee como la intención.- La reducción no asociativa
(a, b) -> a - bdevolvió números diferentes en modo secuencial y paralelo — el resultado paralelo es lo que produjo la división del trabajo. La asociatividad es un contrato que no se puede ver en el tipo pero del que el entorno de ejecución depende por completo.
Qué sigue
Con esto se cierra la Parte 12. Ya has visto el vocabulario funcional completo que entrega el JDK: interfaces funcionales y @FunctionalInterface, lambdas, referencias de métodos, el paquete java.util.function de extremo a extremo, el pipeline de streams (fuentes, intermedios, terminales, colectores, paralelo), Optional, y finalmente Predicate, Function, Consumer/Supplier, y la familia de operadores uno a uno. La siguiente parte, File and I/O, comienza con Java I/O Introduction — la separación entre bytes y caracteres, la capa de streams con buffer, y cómo java.io se relaciona con la API más nueva java.nio.file. Varios de los patrones de esta parte — try-with-resources, las formas Consumer/Supplier para leer y escribir, y el pipeline de streams para archivos orientados a líneas — aparecen de inmediato.