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.