W3docs

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> estende Function<T, T> — uma entrada, uma saída, mesmo tipo. O formato por trás de List.replaceAll, Map.replaceAll e qualquer chamada de "transformação no lugar".
  • BinaryOperator<T> estende BiFunction<T, T, T> — duas entradas e uma saída, todas do mesmo tipo. O formato por trás de Stream.reduce, Map.merge e 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 -> Integer

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

java— editable, runs on the server

O que observar na execução:

  • names.replaceAll(String::toUpperCase) reescreveu a lista no lugar. O formato UnaryOperator<String> foi o que garantiu a segurança de tipos — String::length teria falhado na compilação porque não retorna uma String.
  • Stream.reduce(0, Integer::sum) dobrou cinco inteiros em um usando um BinaryOperator<Integer> associativo. O elemento identidade 0 tornou o caso de stream vazio significativo: um stream vazio reduz ao elemento identidade.
  • Stream.reduce(BinaryOperator) sem um elemento identidade retornou Optional<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. Insere 1 quando a chave está ausente e adiciona 1 ao valor existente quando ela está presente. O BinaryOperator<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 - b retornou 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.

Prática

Prática
Você quer um idioma de uma linha que incremente um contador por palavra em um `Map<String, Integer>`. Qual chamada faz isso corretamente com um `BinaryOperator<Integer>`?
Você quer um idioma de uma linha que incremente um contador por palavra em um `Map<String, Integer>`. Qual chamada faz isso corretamente com um `BinaryOperator<Integer>`?
Was this page helpful?