Programação Java

Lambdas, method references e interfaces funcionais em Java

Intermediário9 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

As lambdas transformaram o Java em uma linguagem em que comportamento circula com a mesma facilidade que dados. Este guia explica o que uma lambda realmente é — uma instância de uma interface funcional —, como o compilador infere o seu tipo, as interfaces prontas de java.util.function, os quatro tipos de method reference, as regras de captura de variáveis e como as lambdas são compiladas. Você vai conseguir escrever, ler e projetar APIs baseadas em lambdas com segurança.

O que você vai aprender

  • Explicar como uma expressão lambda é tipada em relação a uma interface funcional
  • Escolher a interface certa de java.util.function, incluindo as especializações para primitivos
  • Converter entre lambdas e as quatro formas de method reference
  • Aplicar a regra effectively final e entender por que uma lambda não pode alterar variáveis locais capturadas
  • Compor funções, predicados e comparadores, e projetar métodos que recebem comportamento

O Java 8 trouxe as expressões lambda e, com elas, uma mudança na forma de escrever código Java: comportamento passou a ser um argumento de primeira classe. Ordenação, filtros, políticas de retry, event handlers e corpos de threads viraram uma linha só em vez de uma classe anônima. A sintaxe é fácil; o que confunde é o modelo de tipos (uma lambda não tem tipo próprio), as regras de captura e o catálogo cada vez maior de interfaces em java.util.function. Este guia cobre esses fundamentos, dos quais dependem a API de streams e boa parte do Java moderno. O código tem como alvo o Java 21.

O que é uma lambda

Uma expressão lambda é uma forma compacta de criar uma instância de uma interface funcional — uma interface com exatamente um método abstrato. Os parâmetros e o corpo da lambda passam a ser a implementação desse método.

// Before: an anonymous class
Comparator<String> byLength = new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
};

// After: a lambda
Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());

O formato é parameters -> body. Os tipos dos parâmetros costumam ser inferidos; um único parâmetro dispensa parênteses; um corpo com uma só expressão dispensa chaves e return; um corpo em bloco usa os dois. Desde o Java 11, var é permitido nos tipos dos parâmetros, principalmente para que você possa anotá-los.

Runnable hello = () -> System.out.println("hi");             // no parameters
Function<String, Integer> len = s -> s.length();             // one parameter, expression body
BiFunction<Integer, Integer, Integer> add = (Integer a, Integer b) -> {   // explicit types, block body
    int sum = a + b;
    return sum;
};

Tipo-alvo

Uma lambda não tem tipo intrínseco. (a, b) -> a + b poderia ser um BinaryOperator<Integer>, uma BiFunction<String, String, String> ou um Comparator<Integer> (um péssimo). O compilador determina o tipo-alvo a partir do contexto — a variável que recebe a atribuição, o parâmetro do método, o tipo de retorno, um cast — e verifica a lambda contra o único método abstrato dessa interface. É por isso que não dá para escrever var f = x -> x + 1; (não há alvo) e por isso métodos sobrecarregados que aceitam interfaces funcionais diferentes às vezes precisam de um cast ou de tipos de parâmetro explícitos para desfazer a ambiguidade.

Qualquer interface com um único método abstrato serve, inclusive as suas e as antigas, como Runnable, Callable, Comparator e ActionListener. Marque as suas com @FunctionalInterface: a anotação não é obrigatória, mas faz o compilador rejeitar um segundo método abstrato que quebraria silenciosamente todas as lambdas que têm a interface como alvo.

O catálogo de java.util.function

Em vez de cada biblioteca inventar as suas próprias interfaces de método único, o JDK oferece um conjunto padrão. Aprenda as seis formas básicas e o esquema de nomes, e você decifra todo o resto.

InterfaceMétodoSignificado
Supplier<T>T get()Produz um valor, sem entrada
Consumer<T>void accept(T t)Usa um valor, não retorna nada
Function<T, R>R apply(T t)Transforma T em R
Predicate<T>boolean test(T t)Decisão de sim ou não
UnaryOperator<T>T apply(T t)Função de T para T
BinaryOperator<T>T apply(T a, T b)Combina dois T em um único T

O prefixo Bi dobra as entradas (BiFunction<T, U, R>, BiConsumer<T, U>, BiPredicate<T, U>). Existem especializações para primitivos que evitam o boxing: IntPredicate, IntFunction<R> (recebe int), ToIntFunction<T> (retorna int), IntUnaryOperator, IntBinaryOperator, IntSupplier, ObjIntConsumer<T>, com variantes Long e Double. É por isso que um stream chama mapToInt(ToIntFunction).

Supplier<List<String>> newList = ArrayList::new;
Consumer<String> print = System.out::println;
Function<String, Integer> parse = Integer::parseInt;
Predicate<String> isBlank = String::isBlank;
UnaryOperator<String> trim = String::strip;
BinaryOperator<Integer> max = Integer::max;
IntBinaryOperator gcd = (a, b) -> { while (b != 0) { int t = b; b = a % b; a = t; } return a; };

Recorra a essas interfaces antes de definir a sua. Uma interface funcional própria se justifica quando o nome carrega significado de domínio (PricingRule, RetryPolicy) ou quando o método precisa declarar uma checked exception, o que nenhuma das interfaces padrão faz.

Method references

Uma method reference nomeia um método existente onde uma lambda apenas o chamaria. Há quatro formas:

FormaSintaxeLambda equivalente
Método estáticoInteger::parseInts -> Integer.parseInt(s)
Método de instância de um objeto específicoSystem.out::printlnx -> System.out.println(x)
Método de instância de um objeto qualquer de um tipoString::toUpperCases -> s.toUpperCase()
ConstrutorArrayList::new() -> new ArrayList<>()

A terceira forma é a que parece estranha no começo: String::compareToIgnoreCase usado como Comparator<String> significa “chame compareToIgnoreCase no primeiro argumento, passando o segundo”. O primeiro parâmetro vira o receptor.

As referências vinculadas (a segunda forma) avaliam a expressão do receptor uma única vez, no momento em que a referência é criada. this::handle ou logger::info capturam o objeto atual; se o objeto mudar depois, a referência continua apontando para o antigo.

Method references nem sempre são mais claras. Person::getName é melhor que p -> p.getName(); mas entre (a, b) -> a.compareTo(b) e String::compareTo é questão de gosto, e uma referência a um método sobrecarregado pode deixar o compilador sem conseguir escolher a sobrecarga. Na dúvida, escreva a lambda.

Captura de variáveis e a regra effectively final

O corpo de uma lambda pode ler variáveis locais, parâmetros, campos e this do escopo externo. Variáveis locais e parâmetros, porém, precisam ser effectively final: atribuídos exatamente uma vez, quer você escreva a palavra-chave final ou não. O compilador rejeita uma lambda que escreve em uma variável local, e também rejeita uma lambda que lê uma variável local alterada em qualquer ponto após a inicialização.

int base = 10;
Function<Integer, Integer> addBase = x -> x + base;   // fine: base is effectively final

int counter = 0;
list.forEach(item -> counter++);                      // compile error: counter is modified

A regra existe porque a lambda recebe uma cópia do valor. A variável local vive no stack frame do método externo; a lambda pode ser executada depois que esse frame deixou de existir, ou em outra thread. Compartilhar uma posição mutável exigiria que a JVM movesse a variável para o heap e sincronizasse o acesso, e os projetistas da linguagem preferiram proibir isso a esconder o custo.

Quando você realmente precisa acumular, as opções, em ordem de preferência, são: expressar o cálculo como uma redução ou um collector, para que não exista estado compartilhado; usar um AtomicInteger ou LongAdder quando a lambda é um callback que precisa contar; ou, em último caso, um array de um elemento (int[] counter = {0}), que compila porque a referência é final mesmo que o conteúdo mude — e que vira uma data race se a lambda rodar concorrentemente.

Campos não estão sujeitos à regra. this.counter++ dentro de uma lambda compila, porque o que é capturado é this, e o campo é acessado por meio dele. Isso é conveniente em um handler de interface gráfica single-thread e é um bug em um parallel stream.

this em lambdas versus classes anônimas

Dentro de uma lambda, this é a instância externa, exatamente como no método que a contém. Dentro de uma classe anônima, this é o próprio objeto anônimo. Essa diferença é o motivo mais visível pelo qual uma lambda não é açúcar sintático para uma classe anônima: converter uma na outra pode mudar aquilo a que this.name se refere.

Composição

As interfaces funcionais vêm com métodos default para combinar instâncias, de modo que peças pequenas podem ser montadas sem escrever novas lambdas.

Predicate<String> notBlank = Predicate.not(String::isBlank);
Predicate<String> shortEnough = s -> s.length() <= 80;
Predicate<String> validTitle = notBlank.and(shortEnough);

Function<String, String> normalise = String::strip;
Function<String, String> pipeline = normalise.andThen(String::toLowerCase);   // strip, then lower-case
Function<String, Integer> lengthAfter = pipeline.andThen(String::length);

Comparator<Employee> ordering = Comparator.comparing(Employee::department)
        .thenComparing(Employee::salary, Comparator.reverseOrder())
        .thenComparing(Employee::name, String.CASE_INSENSITIVE_ORDER);

UnaryOperator<String> identity = UnaryOperator.identity();

andThen aplica primeiro o receptor e depois o argumento; compose faz o contrário. Predicate oferece and, or, negate e o método estático not. Comparator é a mais rica: comparing, comparingInt, thenComparing, reversed, nullsFirst, nullsLast e naturalOrder. Compor comparadores dessa forma substitui métodos compare escritos à mão, que eram uma fonte garantida de erros de sinal.

Projetando APIs em torno de lambdas

O ganho das lambdas não está só em chamadas mais curtas; está em permitir que bibliotecas sejam construídas em torno da parametrização de comportamento: um método que recebe uma estratégia como argumento em vez de um flag. Compare um método com três parâmetros booleanos com outro que recebe um Predicate<Order> — o segundo é menor, mais genérico e dá para testá-lo sem o código que o chama.

public final class Retry {
    public static <T> T withRetry(int attempts, Predicate<Exception> retryable, Supplier<T> action) {
        Exception last = null;
        for (int i = 0; i < attempts; i++) {
            try {
                return action.get();
            } catch (Exception e) {
                if (!retryable.test(e)) throw e instanceof RuntimeException r ? r : new RuntimeException(e);
                last = e;
            }
        }
        throw new IllegalStateException("Gave up after " + attempts + " attempts", last);
    }
}

String body = Retry.withRetry(3, e -> e instanceof IOException, () -> client.fetch("/health"));

Diretrizes para esse tipo de API:

  • Aceite a interface padrão mais genérica que servir: Supplier<T> em vez de um Loader<T> próprio; Function<? super T, ? extends R> com wildcards, como explica o guia de generics, para que quem chama possa passar mais do que os tipos exatos.
  • Prefira uma especialização para primitivos quando o valor for primitivo; IntPredicate evita boxing em um loop crítico.
  • Deixe a lambda ser pura sempre que possível, e documente quando a API a chama mais de uma vez, de forma lazy ou em outra thread.
  • Considere uma pequena interface própria quando ela melhorar a legibilidade da chamada ou precisar de uma checked exception.

A única limitação real são as checked exceptions. Um Supplier<T> não pode lançar IOException, então uma lambda que chama Files.readString precisa capturá-la e encapsulá-la. Ou você declara o seu próprio ThrowingSupplier<T, E extends Exception> para APIs em que isso é esperado, ou encapsula em uma UncheckedIOException no ponto de chamada.

Como as lambdas são compiladas

Entender a implementação explica o comportamento. O compilador transforma o corpo de uma lambda em um método sintético privado da classe externa e emite uma instrução invokedynamic no ponto em que a lambda aparece. Na primeira execução, a LambdaMetafactory da JVM gera uma pequena classe oculta que implementa a interface funcional e liga o call site a ela. Consequências:

  • Nenhum arquivo .class extra no seu JAR, ao contrário das classes anônimas, que geram cada uma um Outer$1.class.
  • Lambdas sem captura (sem referência a variáveis locais nem a this) são criadas uma vez e ficam em cache; cada avaliação retorna a mesma instância. Lambdas com captura alocam uma nova instância a cada avaliação, com cópias dos valores capturados.
  • A identidade não é especificada. Nunca compare lambdas com == nem as use como chaves de map; duas avaliações do mesmo código-fonte podem ou não resultar no mesmo objeto.
  • A serialização só funciona se a interface-alvo for Serializable, e é frágil entre recompilações. Evite.

Em desempenho, uma chamada de lambda após o warm-up do JIT é comparável a uma chamada de interface e normalmente sofre inlining. Os custos que aparecem nos profiles vêm do boxing (use as interfaces para primitivos), das alocações por captura em loops críticos e de call sites megamórficos, em que uma mesma linha de código despacha para muitas lambdas diferentes.

Erros comuns

Alterar estado capturado. O truque da posição de array e a alteração de campos compilam, mas geram race conditions em execução paralela. Use redução ou collector.

Corpos de lambda longos. Um bloco de dez linhas em um pipeline de streams esconde o formato do pipeline. Extraia um método com nome e referencie-o.

Sobrecargas que recebem interfaces funcionais diferentes. submit(Runnable) e submit(Callable<T>) em ExecutorService já causam ambiguidade com () -> doWork(). Evite projetar sobrecargas assim; se não tiver jeito, dê nomes diferentes a elas.

Ignorar o descompasso com checked exceptions. Envolver cada chamada em try/catch dentro da lambda é sinal de que vale introduzir uma interface que lança exceções ou reestruturar o código.

Supor execução imediata. Passar uma lambda para um método não a executa. Se o método chamado a guardar e chamar depois, os objetos capturados continuam vivos até lá, o que pode prender objetos grandes na memória.

Usar Optional.get em uma cadeia de lambdas. Componha com map/flatMap/orElse; veja o guia de Optional.

Lambdas de comparação com subtração. (a, b) -> a.value - b.value estoura com valores grandes. Use Integer.compare ou Comparator.comparingInt.

Principais pontos

  • Uma lambda é uma instância de uma interface funcional; o seu tipo vem do contexto em que ela aparece, nunca da própria lambda.
  • java.util.function fornece as formas padrão — Supplier, Consumer, Function, Predicate, UnaryOperator, BinaryOperator — além das variantes Bi e para primitivos. Use-as antes de escrever as suas.
  • Method references têm quatro formas; use-as quando a lambda apenas repassaria os seus argumentos.
  • Variáveis locais capturadas precisam ser effectively final porque a lambda recebe uma cópia; campos e this são capturados por referência.
  • Componha comportamento com andThen, compose, and, or, negate e os builders de Comparator em vez de escrever novas lambdas.
  • Lambdas são compiladas para invokedynamic mais um método sintético, não para classes anônimas: sem arquivos .class, instâncias sem captura em cache, this é o objeto externo e a identidade não é garantida.

Fontes e leituras complementares

Perguntas frequentes

O que é uma interface funcional?
Uma interface com exatamente um método abstrato. Métodos default e static não contam, nem os métodos que Object já fornece, como equals. Qualquer interface funcional pode ser o tipo-alvo de uma lambda ou de uma method reference. A anotação @FunctionalInterface é opcional, mas faz o compilador garantir a regra do método único.
Por que as variáveis usadas em uma lambda precisam ser effectively final?
Uma lambda captura o valor de uma variável local, não a variável em si: a variável local vive na pilha e pode já ter deixado de existir quando a lambda for executada, talvez em outra thread. Permitir escrita faria a cópia e o original divergirem silenciosamente. Se você precisa de estado mutável, use um campo, uma posição de array, um AtomicInteger ou, melhor ainda, reestruture o código com uma redução ou um collector.
Uma lambda é só uma sintaxe abreviada para uma classe interna anônima?
Não. Uma lambda é compilada para um método privado mais um call site invokedynamic que cria a instância da interface funcional sob demanda; nenhum arquivo .class é gerado em tempo de compilação, this se refere à instância externa e uma lambda sem captura normalmente é alocada uma única vez e reutilizada. Uma classe anônima é uma classe separada, com o seu próprio this e um objeto novo a cada avaliação.
Quando usar uma method reference em vez de uma lambda?
Quando a lambda não faria nada além de repassar os seus parâmetros para um único método existente: String::length em vez de s -> s.length(). Se for preciso adaptar os argumentos de alguma forma, ou se a referência ficar ambígua ou obscura, escreva a lambda.