Java BinaryOperator e UnaryOperator
Interfaces funcionais especializadas em Java para operações sobre operandos do mesmo tipo — BinaryOperator e UnaryOperator.
Os dois últimos aprofundamentos de interfaces funcionais na Parte 12 fecham a taxonomia dos quatro cantos com as especializações de mesmo tipo:
UnaryOperator<T>estendeFunction<T, T>— uma entrada, uma saída, mesmo tipo. O formato por trás deList.replaceAll,Map.replaceAlle qualquer chamada de "transformação no lugar".BinaryOperator<T>estendeBiFunction<T, T, T>— duas entradas e uma saída, todas do mesmo tipo. O formato por trás deStream.reduce,Map.mergee o passo de "combinar dois parciais em um" no processamento paralelo.
Nenhuma interface adiciona novos SAMs — elas herdam apply de seus pais. O que adicionam são dois métodos estáticos curtos em BinaryOperator, minBy e maxBy, que aparecem com frequência suficiente para conhecer pelo nome.
UnaryOperator<T> — transformação do mesmo tipo
@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> {
static <T> UnaryOperator<T> identity(); // returns t -> t
}Essa é a declaração completa. Todo o resto (apply, andThen, compose) é herdado de Function<T, T>.
Um UnaryOperator<T> também é um Function<T, T>, portanto, onde quer que um Function<String, String> seja aceito, um UnaryOperator<String> se encaixa. O inverso não é verdadeiro: um Function<String, Object> não é um UnaryOperator<String>. A diferença importa quando a API quer especificamente a garantia de mesmo 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>) reescreve cada elemento no lugar. Como o parâmetro é UnaryOperator<E>, o compilador recusa qualquer transformação que altere o tipo do elemento — que é exatamente o que se deseja em uma mutação no lugar.
Especializações primitivas existem onde compensam no código com streams:
IntUnaryOperator doubleIt = i -> i * 2;
LongUnaryOperator biggify = n -> n + 1_000_000L;
DoubleUnaryOperator halve = d -> d / 2.0;IntStream.map(IntUnaryOperator) é a versão sem boxing de Stream<Integer>.map(Function<Integer, Integer>).
BinaryOperator<T> — combinando dois valores do mesmo 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);
}Um BinaryOperator<T> é "combine esses dois Ts em um único T." O formato existe porque combinar é a operação de que a redução paralela necessita:
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 um recebe dois do mesmo tipo e retorna um do mesmo tipo. Esse é o único requisito.
Onde BinaryOperator<T> aparece
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 é o principal local de uso. O BinaryOperator<T> que você passa é chamado repetidamente para dobrar um stream de T em um único T. Em um parallel stream, resultados parciais de threads diferentes são combinados com o mesmo operador — é por isso que o operador deve ser associativo: (a ⊕ b) ⊕ c e a ⊕ (b ⊕ c) devem produzir o mesmo resultado, independentemente de como a JVM divide o trabalho.
Map.merge(key, value, remapping) é o outro lugar onde um BinaryOperator<V> aparece no código cotidiano — e é a forma mais limpa de implementar "incrementar um contador em um mapa":
Map<String, Integer> counts = new HashMap<>();
for (String word : words) counts.merge(word, 1, Integer::sum);Se a chave estiver ausente, o valor é armazenado como está; se a chave estiver presente, o BinaryOperator<V> de remapeamento combina os valores antigo e novo.
minBy e maxBy — nomeando a redução óbvia
Duas fábricas estáticas curtas que envolvem um 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);Você poderia escrever os lambdas à mão — (a, b) -> a.age() > b.age() ? a : b — mas BinaryOperator.maxBy(cmp) expressa a intenção e reutiliza um Comparator existente. Collectors.maxBy(cmp) é a forma de coletor; os dois chegam à mesma resposta por APIs diferentes.
Associatividade é o contrato
O compilador não consegue verificar se seu BinaryOperator<T> é associativo. O JDK assume que é. Em um reduce sequencial, um bug de associatividade só altera o resultado se o operador também não for comutativo; em um reduce paralelo, operadores não associativos produzem respostas não determinísticas — mesma entrada, totais diferentes em execuções diferentes:
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, concatenação de listas, união de conjuntos e concatenação de strings são todos associativos. Subtração e divisão não são. Use-os em um BinaryOperator e você estará introduzindo um bug de paralelismo esperando para aparecer.
Um exemplo completo: replaceAll, reduce, merge e os estáticos minBy/maxBy
O programa abaixo usa UnaryOperator<String> para converter uma lista em maiúsculas no lugar, reduz um IntStream com um BinaryOperator via a referência de método Integer::sum, percorre Map.merge para construir um histograma de contagem de palavras e usa BinaryOperator.maxBy com Stream.reduce para encontrar a pessoa mais velha em uma lista.
O que observar na execução:
names.replaceAll(String::toUpperCase)reescreveu a lista no lugar. O formatoUnaryOperator<String>foi o que garantiu a segurança de tipos —String::lengthteria falhado na compilação porque não retorna umaString.Stream.reduce(0, Integer::sum)dobrou cinco inteiros em um usando umBinaryOperator<Integer>associativo. O elemento identidade0tornou o caso de stream vazio significativo: um stream vazio reduz ao elemento identidade.Stream.reduce(BinaryOperator)sem um elemento identidade retornouOptional<T>— não há resposta sensata para um stream vazio quando nenhum elemento identidade é fornecido.counts.merge(w, 1, Integer::sum)é o idioma de contagem de palavras em uma linha. Insere1quando a chave está ausente e adiciona1ao valor existente quando ela está presente. OBinaryOperator<Integer>é o passo de combinação.BinaryOperator.maxBy(Comparator.comparingInt(Person::age))nomeou a redução como "comparar por idade e manter o maior." O equivalente com lambda funciona, mas o estático nomeado expressa a intenção.- A redução não associativa
(a, b) -> a - bretornou números diferentes nos modos sequencial e paralelo — o resultado paralelo é o que a divisão do trabalho acabou computando. Associatividade é um contrato que você não pode ver no tipo, mas do qual o tempo de execução depende inteiramente.
O que vem a seguir
Isso encerra a Parte 12. Você agora viu todo o vocabulário funcional fornecido pelo JDK: interfaces funcionais e @FunctionalInterface, lambdas, referências de métodos, o pacote java.util.function de ponta a ponta, o pipeline de stream (fontes, intermediários, terminais, coletores, paralelo), Optional e, por fim, Predicate, Function, Consumer/Supplier e a família de operadores um a um. A próxima parte, File and I/O, começa com Java I/O Introduction — a divisão byte vs. caractere, a camada de stream com buffer e como java.io se relaciona com a API mais nova java.nio.file. Vários dos padrões desta parte — try-with-resources, os formatos Consumer/Supplier para leitura e escrita, e o pipeline de stream para arquivos orientados a linhas — aparecem imediatamente.