gabrel.com.br
~/blog/post

Consumer, Supplier, Function e as outras: o vocabulário funcional do Java

Se você já usou flatMap, map ou filter em Streams do Java, já usou uma interface funcional sem perceber. Neste post vamos abrir o capô do pacote java.util.function e entender por que essas interfaces existem, o que cada uma resolve, e como elas se encaixam entre si.

O problema que elas resolvem

Antes do Java 8, passar "comportamento" como parâmetro era doloroso. Você não podia simplesmente passar uma função — precisava criar uma interface, implementá-la (geralmente com uma classe anônima) e só então passar o objeto:

Comparator<String> porTamanho = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.length() - b.length();
    }
};

Muito código para uma ideia simples: "dado a e b, devolva um número". O Java 8 trouxe lambdas para resolver a sintaxe, mas lambdas precisam de um "molde" — uma interface com exatamente um método abstrato (chamada de interface funcional). Em vez de cada desenvolvedor inventar seu próprio molde toda hora, o Java criou um conjunto padrão de interfaces genéricas para os formatos de função mais comuns. É isso que vive em java.util.function.

A pergunta que toda interface desse pacote responde é sempre a mesma: "quantos argumentos essa função recebe, e o que ela devolve?"

Function<T, R>: recebe um valor, devolve outro

A mais básica de todas. Recebe algo do tipo T, devolve algo do tipo R.

Function<String, Integer> tamanho = s -> s.length();

tamanho.apply("Anthropic"); // 9

É a base de map em Streams:

List<Integer> tamanhos = nomes.stream()
    .map(nome -> nome.length()) // Function<String, Integer>
    .collect(Collectors.toList());

Quando usar: sempre que você transforma um valor em outro.

Bônus: compondo Functions

Function tem dois métodos default muito úteis para encadear transformações:

Function<Integer, Integer> dobro = x -> x * 2;
Function<Integer, Integer> maisUm = x -> x + 1;

dobro.andThen(maisUm).apply(5);  // (5*2)+1 = 11
dobro.compose(maisUm).apply(5);  // (5+1)*2 = 12

andThen aplica a primeira e depois a segunda. compose faz o inverso: aplica a segunda e depois a primeira.

Consumer<T>: recebe um valor, não devolve nada

Recebe algo, faz alguma coisa com ele (efeito colateral: imprimir, salvar, logar), e não devolve nada.

Consumer<String> imprimir = s -> System.out.println(s);

imprimir.accept("olá"); // imprime "olá"

Aparece toda vez que você itera uma coleção só para executar uma ação:

nomes.forEach(nome -> System.out.println(nome)); // Consumer<String>

Quando usar: sempre que a ideia é "fazer algo com o valor", não "transformar o valor".

Bônus: encadeando Consumers

Consumer<String> logar = s -> System.out.println("LOG: " + s);
Consumer<String> salvar = s -> repositorio.salvar(s);

logar.andThen(salvar).accept("evento");
// executa os dois, na ordem

Supplier<T>: não recebe nada, devolve um valor

O oposto do Consumer. Não recebe argumento nenhum, só produz um valor quando chamado.

Supplier<Double> gerarAleatorio = () -> Math.random();

gerarAleatorio.get(); // 0.234...

Um uso muito comum: valores calculados de forma preguiçosa (lazy), só quando realmente forem necessários.

Optional<Usuario> usuario = buscarUsuario(id);

usuario.orElseGet(() -> criarUsuarioPadrao());
// criarUsuarioPadrao() só roda SE o Optional estiver vazio

Compare com orElse(criarUsuarioPadrao()), que sempre executaria criarUsuarioPadrao(), mesmo quando desnecessário. Supplier existe exatamente para adiar esse custo.

Quando usar: fábricas de objetos, valores padrão preguiçosos, geração sob demanda.

Predicate<T>: recebe um valor, devolve um booleano

Uma pergunta sobre um valor: verdadeiro ou falso.

Predicate<Integer> ehPar = n -> n % 2 == 0;

ehPar.test(4); // true

É a base do filter em Streams:

List<Integer> pares = numeros.stream()
    .filter(n -> n % 2 == 0) // Predicate<Integer>
    .collect(Collectors.toList());

Bônus: combinando Predicates com lógica booleana

Predicate<Integer> ehPositivo = n -> n > 0;

Predicate<Integer> ehParEPositivo = ehPar.and(ehPositivo);
Predicate<Integer> ehParOuPositivo = ehPar.or(ehPositivo);
Predicate<Integer> naoEhPar = ehPar.negate();

Isso evita escrever n -> n % 2 == 0 && n > 0 toda vez — você compõe predicados pequenos e reutilizáveis.

Quando usar: filtros, validações, condições reutilizáveis.

As variantes de dois argumentos: BiFunction, BiConsumer, BiPredicate

Cada uma das interfaces acima tem uma prima que recebe dois argumentos em vez de um:

BiFunction<Integer, Integer, Integer> soma = (a, b) -> a + b;
soma.apply(2, 3); // 5

BiConsumer<String, Integer> imprimirComIndice = (nome, indice) ->
    System.out.println(indice + ": " + nome);

BiPredicate<String, Integer> temTamanho = (texto, tamanho) ->
    texto.length() == tamanho;

Aparecem, por exemplo, em Map.forEach, que passa chave e valor:

mapa.forEach((chave, valor) -> System.out.println(chave + " = " + valor));
// BiConsumer<K, V>

As variantes especializadas para tipos primitivos

Genéricos em Java não funcionam com tipos primitivos (int, double, long) — eles exigem os "wrappers" (Integer, Double, Long), o que causa autoboxing (conversão automática entre primitivo e objeto) e um custo de performance.

Para evitar isso em operações de alta frequência (como Streams numéricos), existem versões especializadas:

IntFunction<String> paraTexto = n -> "número " + n;
ToIntFunction<String> paraTamanho = s -> s.length();
IntPredicate ehPositivo = n -> n > 0;
IntConsumer imprimir = n -> System.out.println(n);
IntSupplier gerador = () -> 42;

O mesmo existe para Long e Double (LongFunction, DoublePredicate, etc.). Você provavelmente não vai criar essas manualmente com frequência, mas vai encontrá-las em IntStream, LongStream e DoubleStream.

Quando usar: quando performance importa e você está trabalhando com muitos valores numéricos (Streams grandes, por exemplo).

UnaryOperator e BinaryOperator: quando entrada e saída são do mesmo tipo

São casos especiais de Function e BiFunction, onde o tipo de entrada é igual ao tipo de saída:

UnaryOperator<Integer> dobro = x -> x * 2;
// equivalente a Function<Integer, Integer>

BinaryOperator<Integer> soma = (a, b) -> a + b;
// equivalente a BiFunction<Integer, Integer, Integer>

Por que não usar Function<Integer, Integer> direto? Porque UnaryOperator comunica a intenção de forma mais clara — "isso transforma um valor no mesmo tipo dele" — e é o tipo exigido por certos métodos, como List.replaceAll:

lista.replaceAll(x -> x * 2); // espera um UnaryOperator<Integer>

BinaryOperator também é o tipo usado em reduce:

int total = numeros.stream()
    .reduce(0, (a, b) -> a + b); // BinaryOperator<Integer>

Tabela-resumo para colar na parede

Interface Recebe Devolve Pergunta que ela responde
Function<T, R> 1 valor 1 valor "transforme isso em outra coisa"
Consumer<T> 1 valor nada "faça algo com isso"
Supplier<T> nada 1 valor "me dê um valor"
Predicate<T> 1 valor booleano "isso é verdadeiro?"
BiFunction<T,U,R> 2 valores 1 valor "combine esses dois em outra coisa"
BiConsumer<T,U> 2 valores nada "faça algo com esses dois"
BiPredicate<T,U> 2 valores booleano "isso é verdadeiro pra esses dois?"
UnaryOperator<T> 1 valor mesmo tipo "transforme mantendo o tipo"
BinaryOperator<T> 2 valores (mesmo tipo) mesmo tipo "combine dois do mesmo tipo"

Como elas se conectam com flatMap, map e filter

Se você leu o post anterior sobre flatMap, agora fica mais claro o papel de cada interface nas Streams:

lista.stream()
    .filter(predicate)   // Predicate<T>: decide o que fica
    .map(function)        // Function<T, R>: transforma cada item
    .flatMap(function)    // Function<T, Stream<R>>: transforma e achata
    .forEach(consumer);    // Consumer<T>: faz algo com cada item final

Cada etapa do pipeline é, literalmente, uma dessas interfaces sendo passada como argumento. Entender esse vocabulário é entender a gramática por trás de qualquer Stream em Java.

Pra fechar

Consumer, Supplier, Function, Predicate e suas variantes não são um conjunto arbitrário de interfaces — são o vocabulário mínimo necessário para descrever "que tipo de função" você está passando: quantos argumentos ela recebe e o que ela devolve (ou não devolve). Uma vez que esse vocabulário faz sentido, ler qualquer assinatura de método que recebe uma lambda em Java deixa de ser adivinhação e vira leitura direta.

← voltar ao blog