Comportamento como Valor — Lambdas e Interfaces Funcionais

Comportamento como Valor — Lambdas e Interfaces Funcionais

Como passar comportamento como argumento com expressões lambda em Java: interfaces funcionais de java.util.function, referências a métodos, composição de funções, closures e a regra das variáveis efetivamente finais. Inclui removeIf, merge, computeIfAbsent e o invokedynamic por baixo.
Java

• • 19 min de leitura

A primeira aula da Fase 4: como passar um comportamento como argumento em uma linha, as interfaces funcionais da biblioteca, as referências a métodos e o que muda no uso das coleções.

"Tempo, Dinheiro e Padrões" fechou a Fase 3 com uma observação: os programas continuam escritos com laços, acumuladores e classes anônimas de seis linhas para dizer "compare pela idade". Esta aula abre a Fase 4 com a ideia que muda esse estilo: um comportamento pode ser tratado como um valor, guardado numa variável, passado como argumento e devolvido por um método. O Java expressa isso com as expressões lambda, introduzidas no Java 8, em 2014, a maior mudança da linguagem desde os genéricos. Você vai ver como elas nascem das classes anônimas que já conhece, que interfaces as recebem, como ficam ainda mais curtas com referências a métodos, e como elas cumprem três promessas deixadas nas aulas anteriores.

Da classe anônima à lambda

Em "Ordem e Critério", um comparador de alunos por idade era escrito assim:

Comparator<Aluno> porIdade = new Comparator<Aluno>() {
    @Override
    public int compare(Aluno a, Aluno b) {
        return Integer.compare(a.idade(), b.idade());
    }
};

Olhe o que é realmente informação nessas seis linhas: os parâmetros a e b, e a expressão Integer.compare(a.idade(), b.idade()). Todo o resto o compilador já sabe. Que é um Comparator<Aluno> está na declaração da variável. Que o método se chama compare e devolve int, a interface diz. Uma expressão lambda escreve só o que é informação:

Comparator<Aluno> porIdade = (Aluno a, Aluno b) -> Integer.compare(a.idade(), b.idade());

E como os tipos dos parâmetros também podem ser deduzidos, a forma usual é ainda mais curta:

Comparator<Aluno> porIdade = (a, b) -> Integer.compare(a.idade(), b.idade());

Lê-se: "dados a e b, o resultado é Integer.compare(...)". A seta -> separa os parâmetros do corpo. As formas possíveis:

() -> 42                             // sem parâmetros
s -> s.isBlank()                     // um parâmetro: os parênteses são opcionais
(a, b) -> a + b                      // vários parâmetros
(a, b) -> {                          // corpo com várias instruções: chaves e return
    int soma = a + b;
    return soma * 2;
}

Quando o corpo é uma única expressão, o valor dela é o resultado, sem return. Quando precisa de várias instruções, vai entre chaves, com return explícito, como um método comum.

Interfaces funcionais

Uma lambda não tem tipo próprio. Ela sempre implementa uma interface funcional: uma interface com exatamente um método abstrato. Comparator é uma. O tipo da lambda vem do contexto em que ela aparece, o seu tipo alvo: a mesma lambda (a, b) -> a + b pode ser um Comparator, um BinaryOperator<Integer> ou outra interface qualquer com um método de dois parâmetros, conforme a variável ou o parâmetro que a recebe.

Métodos default e estáticos não contam na regra do método abstrato único. Comparator tem vários métodos default, como reversed e thenComparing, e continua sendo uma interface funcional. A anotação @FunctionalInterface declara a intenção e faz o compilador verificar:

@FunctionalInterface
interface Validador<T> {
    boolean valido(T valor);

    default Validador<T> e(Validador<T> outro) {
        return v -> valido(v) && outro.valido(v);
    }
}
Validador<String> naoVazio = s -> !s.isBlank();
Validador<String> tamanhoOk = s -> s.length() <= 10;
Validador<String> nomeValido = naoVazio.e(tamanhoOk);

IO.println(nomeValido.valido("Ana"));                  // true
IO.println(nomeValido.valido(""));                     // false
IO.println(nomeValido.valido("Maximiliano Silva"));    // false

Uma interface anotada com dois métodos abstratos é recusada: Unexpected @FunctionalInterface annotation ... is not a functional interface. Repare no método default e: ele devolve uma lambda, construída a partir de duas outras. É uma forma de compor comportamentos que vai reaparecer várias vezes.

As interfaces funcionais da biblioteca

Criar uma interface para cada formato de função seria cansativo. O pacote java.util.function traz as mais comuns, prontas:

Interface Método Recebe Devolve Uso típico
Predicate<T> test um T boolean um teste, um filtro
Function<T, R> apply um T um R uma transformação
Supplier<T> get nada um T fornecer ou criar um valor
Consumer<T> accept um T nada uma ação sobre um valor
BiFunction<T, U, R> apply um T e um U um R uma transformação de dois valores
UnaryOperator<T> apply um T um T transformar sem mudar o tipo
BinaryOperator<T> apply dois T um T combinar dois valores
Predicate<String> vazio = s -> s.isBlank();
Function<String, Integer> tamanho = s -> s.length();
Supplier<LocalDate> hoje = () -> LocalDate.of(2026, 9, 25);
Consumer<String> gritar = s -> IO.println(s.toUpperCase() + "!");
UnaryOperator<String> inverter = s -> new StringBuilder(s).reverse().toString();
BinaryOperator<Integer> maior = (a, b) -> a >= b ? a : b;

IO.println(vazio.test("  ") + " " + tamanho.apply("lambda") + " " + hoje.get());   // true 6 2026-09-25
gritar.accept("olá");                                                               // OLÁ!
IO.println(inverter.apply("Java") + " " + maior.apply(3, 9));                       // avaJ 9

Os nomes são um vocabulário que vale decorar, porque aparecem em toda a biblioteca e em todos os frameworks. Quando um método recebe um Predicate<Produto>, você sabe imediatamente que ele quer um teste sobre um produto.

Por causa do custo do autoboxing de "Escreva uma Vez para Todo Tipo", existem versões especializadas para primitivos, como IntPredicate, IntFunction<R>, ToIntFunction<T>, IntUnaryOperator e IntBinaryOperator, e as equivalentes para long e double. IntPredicate par = n -> n % 2 == 0; testa inteiros sem criar nenhum objeto Integer.

Referências a métodos

Muitas lambdas apenas chamam um método que já existe: s -> s.length(), s -> Integer.parseInt(s). Para esses casos, há uma forma ainda mais curta, a referência a método, com :::

Function<String, Integer> converter = Integer::parseInt;       // estático
IO.println(converter.apply("42") + 1);                          // 43

String prefixo = "Item: ";
Function<String, String> rotular = prefixo::concat;             // de um objeto específico
IO.println(rotular.apply("caneta"));                            // Item: caneta

Function<Produto, String> nome = Produto::nome;                 // de um objeto qualquer do tipo
Predicate<Produto> caro = Produto::caro;

BiFunction<String, Double, Produto> fabrica = Produto::new;     // construtor
Supplier<List<String>> novaLista = ArrayList::new;

São quatro formas:

Forma Exemplo Equivale a
método estático Integer::parseInt s -> Integer.parseInt(s)
método de um objeto específico prefixo::concat s -> prefixo.concat(s)
método de um objeto qualquer do tipo Produto::nome p -> p.nome()
construtor Produto::new (n, v) -> new Produto(n, v)

A terceira forma confunde na primeira vez: Produto::nome não diz de qual produto, porque o produto é o primeiro parâmetro da função. Quando a lambda chama um método existente sem fazer mais nada, a referência costuma ser mais legível. Quando há alguma lógica a mais, a lambda é a escolha certa.

Com lambdas e referências, a ordenação de "Ordem e Critério" fica como prometido:

turma.sort(Comparator.comparingDouble(Aluno::nota).reversed().thenComparing(Aluno::nome));

Comparator.comparingDouble recebe uma função que extrai a chave de comparação (a nota) e devolve um comparador pronto. thenComparing desempata pela outra chave. A linha se lê como a especificação: "compare pela nota, do maior para o menor, e depois pelo nome".

Compondo funções

Como as funções agora são valores, elas podem ser combinadas por métodos default das próprias interfaces:

Predicate<String> comecaComA = s -> s.startsWith("A");
Predicate<String> curto = s -> s.length() <= 4;
Predicate<String> filtro = comecaComA.and(curto.negate());

for (String nome : List.of("Ana", "Antônio", "Bruno", "Alice")) {
    IO.println(nome + " -> " + filtro.test(nome));
}
Ana -> false
Antônio -> true
Bruno -> false
Alice -> true

Predicate tem and, or e negate. Function tem andThen, que aplica a outra função depois, e compose, que a aplica antes:

Function<Integer, Integer> dobro = x -> x * 2;
Function<Integer, Integer> maisTres = x -> x + 3;
IO.println(dobro.andThen(maisTres).apply(5));    // 13: (5 * 2) + 3
IO.println(dobro.compose(maisTres).apply(5));    // 16: (5 + 3) * 2

Function<String, String> normalizar = ((Function<String, String>) String::strip)
        .andThen(String::toLowerCase)
        .andThen(s -> s.replace(' ', '-'));
IO.println(normalizar.apply("  Curso de Java  "));   // curso-de-java

(O cast na primeira referência dá ao compilador o tipo alvo, que ele precisa para chamar andThen. Na prática, declara-se primeiro uma variável Function<String, String> limpar = String::strip; e compõe-se a partir dela.) Composição transforma pequenas funções testáveis em processamentos maiores, sem nenhum laço, e é exatamente o que as streams da próxima aula fazem em grande escala.

Métodos que recebem e devolvem funções

Um método pode receber um comportamento como parâmetro, o que permite separar o que fazer de quantas vezes ou quando fazer:

void repetir(int vezes, Runnable acao) {
    for (int i = 0; i < vezes; i++) {
        acao.run();
    }
}

repetir(3, () -> IO.print("eco "));   // eco eco eco

Runnable é uma interface funcional antiga, dos tempos do Java 1.0, sem parâmetros e sem retorno, que você vai reencontrar na Fase 7, onde representa uma tarefa a ser executada por uma thread. E um método pode devolver um comportamento, fabricando funções sob medida:

IntUnaryOperator multiplicador(int fator) {
    return x -> x * fator;
}

IntUnaryOperator triplo = multiplicador(3);
IntUnaryOperator dezVezes = multiplicador(10);
IO.println(triplo.applyAsInt(7) + " " + dezVezes.applyAsInt(7));   // 21 70

Cada chamada a multiplicador devolve uma função diferente, que lembra o fator com que foi criada. Uma função que carrega consigo valores do contexto em que nasceu se chama closure.

A regra das variáveis efetivamente finais

Como as classes anônimas de "Ordem e Critério", uma lambda só pode usar variáveis locais que sejam final ou efetivamente finais:

int contador = 0;
List.of(1, 2, 3).forEach(x -> contador++);
Captura.java:6: error: local variables referenced from a lambda expression must be final or effectively final

A lambda recebe uma cópia do valor da variável no momento da criação. Se a variável pudesse mudar, a cópia e a original divergiriam, e o código seria enganoso. Há ainda um motivo mais sério: uma lambda pode ser executada em outra thread, depois que o método que a criou já terminou, e aí a variável local original nem existe mais. Contornar a regra com truques, como guardar o contador num array de um elemento (int[] contador = {0}), funciona, mas é um sinal de que o estilo está errado. Na próxima aula, contar elementos vai ser uma única operação, sem nenhuma variável mutável.

Uma diferença entre lambdas e classes anônimas: dentro de uma lambda, this se refere ao objeto que contém o código, como se a lambda fosse parte do método. Dentro de uma classe anônima, this é o próprio objeto anônimo. As lambdas não criam um escopo novo, e isso as torna mais previsíveis.

Novidades nas coleções

Com lambdas, as coleções ganharam métodos que recebem comportamentos. Eles cumprem as promessas de "Listas, Conjuntos e Mapas":

List<Integer> valores = new ArrayList<>(List.of(3, -1, 4, -5, 9, 2));
valores.removeIf(v -> v < 0);             // [3, 4, 9, 2]
valores.replaceAll(v -> v * 10);          // [30, 40, 90, 20]
valores.forEach(v -> IO.print(v + " "));  // 30 40 90 20

removeIf substitui o laço com iterador daquela aula, sem risco de ConcurrentModificationException. Nos mapas, dois métodos eliminam os padrões mais repetitivos:

Map<String, Integer> contagem = new TreeMap<>();
for (String palavra : texto.split(" ")) {
    contagem.merge(palavra, 1, Integer::sum);
}

Map<String, List<String>> agenda = new TreeMap<>();
agenda.computeIfAbsent("Ana", k -> new ArrayList<>()).add("81 9999-0000");
agenda.computeIfAbsent("Ana", k -> new ArrayList<>()).add("81 3333-0000");
agenda.computeIfAbsent("Bia", k -> new ArrayList<>()).add("11 98888-0000");
IO.println(agenda);   // {Ana=[81 9999-0000, 81 3333-0000], Bia=[11 98888-0000]}

agenda.forEach((nome, tels) -> IO.println(nome + ": " + tels.size() + " telefone(s)"));

merge(chave, valor, funcao) grava o valor se a chave não existe, e senão combina o valor antigo com o novo usando a função. Com Integer::sum, é um contador em uma linha. computeIfAbsent(chave, funcao) devolve o valor da chave e, se ela não existe, cria o valor com a função e o guarda antes de devolver. É o padrão da agenda do exercício 5 de "Listas, Conjuntos e Mapas", que tinha cinco linhas e agora tem uma.

O que acontece por baixo

Uma lambda não é uma classe anônima disfarçada. Compilando um programa com uma lambda e uma classe anônima, só a anônima gera um arquivo $1.class. A lambda vira um método privado da própria classe, chamado algo como lambda$main$0, e o ponto em que ela é criada usa uma instrução de bytecode chamada invokedynamic:

0: invokedynamic #7,  0    // InvokeDynamic #0:apply:()Ljava/util/function/Function;
private static java.lang.Integer lambda$main$0(java.lang.String);

Na primeira execução dessa instrução, a JVM decide como construir o objeto que implementa a interface funcional e reaproveita essa decisão nas vezes seguintes. Lambdas que não capturam nenhuma variável costumam virar um único objeto, reutilizado. Para o programador, a consequência prática é que lambdas são baratas, e o JIT consegue otimizá-las tão bem quanto chamadas diretas de métodos.

O que as lambdas não resolvem bem

Uma limitação real: as interfaces de java.util.function não declaram exceções verificadas. Uma lambda que chama um método que lança uma não compila:

Function<Path, String> ler = p -> Files.readString(p);
Checked.java:3: error: unreported exception IOException; must be caught or declared to be thrown

As saídas são tratar a exceção dentro da lambda, com try/catch, convertendo-a numa exceção não verificada com a original como causa, como ensinou "Quando o Contrato se Quebra", ou declarar uma interface funcional própria cujo método declare throws IOException. É uma das razões do debate sobre exceções verificadas, e uma das razões pelas quais a biblioteca de arquivos ganhou a classe UncheckedIOException.

As lambdas trouxeram o comportamento como valor, mas os programas ainda percorrem as coleções com laços: para filtrar, para transformar, para somar, para agrupar. Com funções como valores, uma pergunta se torna natural: e se, em vez de escrever o laço, você descrevesse o resultado, como "os alunos aprovados, ordenados por nota, só os nomes", e deixasse a biblioteca decidir como percorrer? É isso que a API de Streams faz.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Reescreva cada trecho usando lambda ou referência a método, o que for mais legível:

Comparator<String> porTamanho = new Comparator<String>() {
    public int compare(String a, String b) { return Integer.compare(a.length(), b.length()); }
};
Runnable saudacao = new Runnable() {
    public void run() { IO.println("Olá!"); }
};
Function<String, String> maiusculas = new Function<String, String>() {
    public String apply(String s) { return s.toUpperCase(); }
};
Ver resposta

✓ Resposta:

Comparator<String> porTamanho = Comparator.comparingInt(String::length);
Runnable saudacao = () -> IO.println("Olá!");
Function<String, String> maiusculas = String::toUpperCase;

O primeiro poderia ser a lambda (a, b) -> Integer.compare(a.length(), b.length()), mas Comparator.comparingInt(String::length) diz diretamente qual é o critério, sem repetir a extração para a e b. O segundo é uma lambda sem parâmetros, e não há método existente que corresponda exatamente a "imprimir Olá!". O terceiro é uma referência a método de um objeto qualquer do tipo, equivalente a s -> s.toUpperCase(). As três versões juntas ocupam três linhas, contra as nove das classes anônimas, e cada uma diz só o que é específico dela.

Exercício 2

Diga qual interface de java.util.function combina com cada lambda: (a) p -> p.preco() > 100, recebendo um Produto; (b) () -> new ArrayList<String>(); (c) s -> IO.println(s); (d) (a, b) -> a + b, com dois Integer; (e) s -> s.length(), recebendo um String.

Ver resposta

✓ Resposta: (a) Predicate<Produto>: recebe um valor e devolve boolean. (b) Supplier<List<String>>: não recebe nada e fornece um valor. Equivale à referência ArrayList::new. (c) Consumer<String>: recebe um valor e não devolve nada. Equivale a IO::println. (d) BinaryOperator<Integer>: recebe dois valores do mesmo tipo e devolve outro do mesmo tipo. BiFunction<Integer, Integer, Integer> também serviria, mas BinaryOperator diz mais. Para evitar o autoboxing, IntBinaryOperator com int. (e) Function<String, Integer>, ou, melhor, ToIntFunction<String>, que devolve um int primitivo, sem embrulhar o resultado. A mesma lambda pode implementar interfaces diferentes, e é o tipo alvo que decide.

Exercício 3

Explique o que contagem.merge(palavra, 1, Integer::sum) faz em cada uma das duas situações possíveis, e compare com a versão da aula "Listas, Conjuntos e Mapas".

Ver resposta

✓ Resposta: Se palavra ainda não é chave do mapa, merge simplesmente grava o valor 1 para ela. Se já é chave, merge chama a função com o valor antigo e o novo, Integer.sum(antigo, 1), e grava o resultado, ou seja, incrementa o contador. (Se a função devolvesse null, a chave seria removida, um detalhe útil em outros usos.) A versão de "Listas, Conjuntos e Mapas" fazia o mesmo em duas operações: getOrDefault(palavra, 0) para ler e put(palavra, atual + 1) para gravar. merge expressa numa só chamada a intenção de "combinar um valor novo com o que já existe", e em alguns mapas, como o ConcurrentHashMap da Fase 7, faz isso de forma atômica, algo que a versão em duas etapas não consegue garantir.

Exercício 4

O código abaixo não compila. Explique por quê e reescreva-o sem nenhuma variável mutável capturada.

List<String> nomes = List.of("Ana", "Bruno", "Alice", "Carla");
int quantosComA = 0;
nomes.forEach(n -> {
    if (n.startsWith("A")) {
        quantosComA++;
    }
});
Ver resposta

✓ Resposta: A lambda tenta alterar quantosComA, uma variável local do método. Lambdas só podem usar variáveis locais efetivamente finais, porque recebem uma cópia do valor e podem até ser executadas depois de o método terminar. Uma versão sem captura mutável volta ao laço comum, que para essa tarefa é perfeitamente adequado:

int quantosComA = 0;
for (String n : nomes) {
    if (n.startsWith("A")) {
        quantosComA++;
    }
}

Outra opção, com o que esta aula ensinou, é criar a contagem a partir de uma função, sem acumulador. Na próxima aula, a forma natural será nomes.stream().filter(n -> n.startsWith("A")).count(), que devolve 2 sem nenhuma variável alterada. A lição: forEach com lambda serve para ações sobre cada elemento, como imprimir. Para calcular algo a partir dos elementos, use um laço ou uma stream, e não um forEach que modifica variáveis de fora.

Exercício 5

Escreva um método UnaryOperator<String> encadear(List<UnaryOperator<String>> etapas) que devolva uma única função que aplique todas as etapas em ordem. Teste com as etapas "remover espaços das pontas", "converter para minúsculas" e "trocar sequências de espaços por hífen".

Ver resposta

✓ Resposta:

UnaryOperator<String> encadear(List<UnaryOperator<String>> etapas) {
    UnaryOperator<String> resultado = s -> s;
    for (UnaryOperator<String> etapa : etapas) {
        UnaryOperator<String> anterior = resultado;
        resultado = s -> etapa.apply(anterior.apply(s));
    }
    return resultado;
}

void main() {
    var limpeza = encadear(List.of(
        String::strip,
        String::toLowerCase,
        s -> s.replaceAll("\\s+", "-")));
    IO.println(limpeza.apply("  Olá Mundo   Java "));   // olá-mundo-java
}

A função começa como a identidade, s -> s, e cada etapa a envolve numa função nova que aplica a anterior e depois a etapa. A variável anterior existe por causa da regra da captura: resultado muda a cada volta, então não é efetivamente final e não pode ser usada dentro da lambda. Uma cópia local, que nunca muda, pode. O método devolve uma função construída a partir de outras funções, uma função de ordem superior, e o resultado pode ser guardado e aplicado a quantos textos se quiser. As referências String::strip e String::toLowerCase se encaixam no tipo UnaryOperator<String> porque recebem e devolvem um String.

Comentários

Mais em Java

Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy
Extensão — Do Laptop ao Servidor: Docker, nginx e Deploy

As Trilhas da Ilha saem do localhost: imagem Docker em dois estágios com…

Uma Linguagem para Torradeiras que Conquistou o Mundo — a História do Java
Uma Linguagem para Torradeiras que Conquistou o Mundo — a História do Java

Como um projeto da Sun para eletrodomésticos fracassou, foi salvo pela web e…

Não Crie Threads, Entregue Tarefas — ExecutorService e Futures
Não Crie Threads, Entregue Tarefas — ExecutorService e Futures

Como entregar tarefas a um pool de threads em vez de criar threads à mão…