Blog

Lambdas e streams em Java: avaliação preguiçosa, collectors e quando usar um laço

Lambdas em Java transformam um pequeno comportamento num valor, e streams encadeiam esses valores em pipelines preguiçosos. Veja como um pipeline roda de verdade, quais collectors usar e quando um laço simples fica mais claro.

Uma lambda é um trecho curto de código que você passa adiante como se fosse um valor. Um stream é um pipeline que leva uma sequência de valores por etapas feitas dessas lambdas. Juntos, eles substituem muitos dos laços que enchiam o código Java mais antigo.

Este post cobre lambdas e as interfaces funcionais por trás delas, referências de método, o que uma lambda pode capturar, pipelines de stream, avaliação preguiçosa, collectors, streams primitivos, reduce, gatherers e os casos em que um laço ainda é a ferramenta certa. Todo programa abaixo rodou no Java 25, e a saída foi colada da execução. Para rodar um você mesmo, salve como Main.java e rode java Main.java.

De classe anônima a lambda

Uma lambda é um jeito mais curto de escrever um objeto que implementa uma interface com um único método abstrato. Ordenar palavras pelo tamanho mostra o caminho. Aqui está o mesmo Comparator escrito de três jeitos:

List<String> sortedBy(List<String> words, Comparator<String> order) {
    var copy = new ArrayList<>(words);
    copy.sort(order);
    return copy;
}

void main() {
    var words = List.of("banana", "fig", "cherry", "kiwi");

    var anonymous = new Comparator<String>() {
        @Override
        public int compare(String a, String b) {
            return Integer.compare(a.length(), b.length());
        }
    };
    Comparator<String> lambda = (a, b) -> Integer.compare(a.length(), b.length());
    Comparator<String> byKey = Comparator.comparing(String::length);

    IO.println(sortedBy(words, anonymous));
    IO.println(sortedBy(words, lambda));
    IO.println(sortedBy(words, byKey));
}

Ele imprime:

[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]

A classe anônima escreve por extenso a interface, o nome do método e os tipos dos parâmetros. A lambda fica só com os parâmetros e o corpo, porque o compilador já sabe que o alvo é um Comparator<String>, e um Comparator tem um método para preencher. A terceira linha nem escreve uma comparação: Comparator.comparing monta uma a partir de uma chave, o tamanho.

banana fica na frente de cherry em todas as linhas, embora as duas tenham seis letras. List.sort é estável, então elementos iguais mantêm a ordem original.

Interfaces funcionais

Uma interface funcional é uma interface com exatamente um método abstrato, e uma lambda pode fazer o papel de qualquer uma delas. O JDK traz um conjunto de interfaces genéricas em java.util.function, então raramente você precisa criar a sua:

@FunctionalInterface
interface PriceRule {
    int apply(int cents);
}

void main() {
    Function<String, Integer> length = s -> s.length();
    Predicate<String> isLong = s -> s.length() > 4;
    Supplier<List<String>> fresh = () -> new ArrayList<>();
    Consumer<String> shout = s -> IO.println(s.toUpperCase() + "!");
    BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
    UnaryOperator<String> tidy = s -> s.strip();
    PriceRule discount = cents -> cents * 90 / 100;

    IO.println(length.apply("coffee"));
    IO.println(isLong.test("tea"));
    var list = fresh.get();
    list.add("new");
    IO.println(list);
    shout.accept("hello");
    IO.println(add.apply(2, 3));
    IO.println("[" + tidy.apply("  milk ") + "]");
    IO.println(discount.apply(500));
}

Ele imprime:

6
false
[new]
HELLO!
5
[milk]
450

Cada uma tem o nome do seu formato:

  • Function<T, R> recebe um T e retorna um R. Você chama com apply.
  • Predicate<T> recebe um T e retorna um boolean, com test.
  • Supplier<T> não recebe nada e retorna um T, com get.
  • Consumer<T> recebe um T e não retorna nada, com accept.
  • BiFunction<T, U, R> recebe dois argumentos e retorna um R.
  • UnaryOperator<T> é uma Function<T, T>: o mesmo tipo na entrada e na saída.

PriceRule é nossa. @FunctionalInterface é opcional, mas faz o compilador conferir a regra do “exatamente um”. Adicionamos um segundo método abstrato, int undo(int cents), e o build falhou com Unexpected @FunctionalInterface annotation, seguido de multiple non-overriding abstract methods found in interface PriceRule.

Referências de método, quatro tipos

Uma referência de método, escrita com ::, é uma lambda cujo corpo inteiro é uma chamada de método. Existem quatro tipos, e a diferença está em de onde vem o objeto:

record Point(int x, int y) {}

void main() {
    // static method: s -> Integer.parseInt(s)
    Function<String, Integer> parse = Integer::parseInt;
    // method on one object you already have: s -> prefix.concat(s)
    String prefix = "id-";
    Function<String, String> tag = prefix::concat;
    // method on whatever object arrives: s -> s.toUpperCase()
    Function<String, String> upper = String::toUpperCase;
    // constructor: (x, y) -> new Point(x, y)
    BiFunction<Integer, Integer, Point> make = Point::new;

    IO.println(parse.apply("42") + 1);
    IO.println(tag.apply("7"));
    IO.println(upper.apply("tea"));
    IO.println(make.apply(3, 4));
}

Ele imprime:

43
id-7
TEA
Point[x=3, y=4]

O comentário acima de cada linha é a lambda que ela substitui. A que mais confunde é String::toUpperCase. Parece uma chamada estática, mas toUpperCase é um método de instância, então o primeiro argumento vira o objeto em que o método roda.

O que uma lambda pode capturar

Uma lambda pode ler variáveis locais do método em volta dela, mas só se elas forem final ou efetivamente final, ou seja, nada atribui um novo valor a elas depois de definidas. Contar com uma lambda quebra essa regra:

void main() {
    int count = 0;
    List.of("a", "b", "c").forEach(s -> count++);
    IO.println(count);
}

O build falha com:

Main.java:3: error: local variables referenced from a lambda expression must be final or effectively final
    List.of("a", "b", "c").forEach(s -> count++);
                                        ^

Uma lambda pode viver mais que o método que a criou, por exemplo quando fica guardada num campo ou roda em outra thread. Por isso o Java copia o valor para dentro da lambda, em vez de compartilhar a variável. Se a variável pudesse mudar depois, a cópia e o original iam discordar, e o Java proíbe isso em tempo de compilação. Campos não têm essa limitação, porque a lambda chega a eles através de um objeto.

Mais uma diferença em relação às classes anônimas: dentro de uma lambda, this significa o mesmo que no código em volta.

void main() {
    Runnable lambda = () -> IO.println("lambda this: " + this.getClass().getName());
    Runnable anonymous = new Runnable() {
        @Override
        public void run() {
            IO.println("anonymous this: " + this.getClass().getName());
        }
    };
    lambda.run();
    anonymous.run();
}

Ele imprime:

lambda this: Main
anonymous this: Main$1

O this da lambda é o objeto Main em que main roda. A classe anônima é uma classe nova, Main$1, então o this dela é ela mesma.

Um pipeline de stream: fonte, etapas, resultado

Um pipeline de stream tem três partes: uma fonte, qualquer número de operações intermediárias e uma operação terminal que produz um resultado. Aqui está uma lista de pedidos limpa numa passada só:

void main() {
    var orders = List.of("tea", "coffee", "tea", "juice", "water", "coffee", "milk");

    var result = orders.stream()
        .filter(o -> !o.equals("water"))
        .distinct()
        .map(String::toUpperCase)
        .sorted()
        .limit(3)
        .toList();

    IO.println(result);
    IO.println(orders);
}

Ele imprime:

[COFFEE, JUICE, MILK]
[tea, coffee, tea, juice, water, coffee, milk]
  • Fonte: orders.stream(). Coleções, arrays, Stream.of(...) e arquivos podem ser fontes.
  • Operações intermediárias: filter mantém os elementos que passam num Predicate, distinct descarta repetidos, map transforma cada elemento em outra coisa, sorted ordena e limit fica com os primeiros. Cada uma retorna um stream novo.
  • Operação terminal: toList() coleta o que sobrou.

A última linha mostra que a lista de origem continua intacta. Um stream lê a fonte. Ele não a altera.

toList() ou collect(Collectors.toList())

Stream.toList() chegou no Java 16. Conferimos: com javac --release 15, a chamada falha com cannot find symbol. Antes disso, você escrevia collect(Collectors.toList()), e muito código ainda escreve. Os dois não são exatamente iguais:

void main() {
    var viaCollect = Stream.of("b", "a").collect(Collectors.toList());
    viaCollect.add("c");
    IO.println(viaCollect);

    var viaToList = Stream.of("b", "a").toList();
    IO.println(viaToList);
    viaToList.add("c");
}

Ele imprime e para:

[b, a, c]
[b, a]
Exception in thread "main" java.lang.UnsupportedOperationException

toList() retorna uma lista não modificável, então add lança exceção. Collectors.toList() não promete nada nem num sentido nem no outro, e hoje ele acaba devolvendo um ArrayList. Use toList(), a menos que você realmente precise mudar a lista depois. Se precisar, deixe isso explícito com Collectors.toCollection(ArrayList::new).

Avaliação preguiçosa: nada roda até você pedir

Operações intermediárias não fazem trabalho nenhum quando você as chama. Elas só descrevem o trabalho. O trabalho começa quando uma operação terminal pede um resultado:

void main() {
    var pipeline = Stream.of(1, 2, 3).map(n -> {
        IO.println("map saw " + n);
        return n * 10;
    });
    IO.println("pipeline built");

    var result = pipeline.toList();
    IO.println(result);
}

Ele imprime:

pipeline built
map saw 1
map saw 2
map saw 3
[10, 20, 30]

pipeline built vem primeiro. Quando a linha do map rodou, ela não chamou a lambda nem uma vez. Ela retornou um stream que lembra “multiplique por dez”. A lambda só rodou quando toList() puxou os elementos pelo pipeline.

Um elemento de cada vez

Um stream não roda filter na lista inteira e depois map no resultado. Cada elemento percorre o pipeline todo antes de o próximo começar:

void main() {
    var result = Stream.of("apple", "fig", "cherry", "kiwi")
        .filter(w -> {
            IO.println("filter " + w);
            return w.length() > 3;
        })
        .map(w -> {
            IO.println("map    " + w);
            return w.toUpperCase();
        })
        .toList();
    IO.println(result);
}

Ele imprime:

filter apple
map    apple
filter fig
filter cherry
map    cherry
filter kiwi
map    kiwi
[APPLE, CHERRY, KIWI]

Leia de cima para baixo. apple passa no filtro e vai direto para o map. fig não passa no filtro, então o map nunca o vê. Depois cherry vai até o fim, e então kiwi. Um laço com um if dentro faz exatamente a mesma coisa, e é essa a ideia: um stream é um laço que você descreve em vez de escrever.

sorted é a exceção. Ele não pode passar nada adiante até ter visto todos os elementos, então primeiro junta todos:

void main() {
    var result = Stream.of("kiwi", "apple", "fig")
        .peek(w -> IO.println("before sorted " + w))
        .sorted()
        .peek(w -> IO.println("after sorted  " + w))
        .toList();
    IO.println(result);
}

Ele imprime:

before sorted kiwi
before sorted apple
before sorted fig
after sorted  apple
after sorted  fig
after sorted  kiwi
[apple, fig, kiwi]

peek roda um Consumer em cada elemento que passa, o que ajuda a enxergar o fluxo. sorted e distinct são chamadas de operações stateful (com estado), porque precisam guardar elementos. filter e map são stateless.

Curto-circuito: parar cedo, até num stream infinito

Algumas operações param o pipeline assim que têm a resposta. findFirst para no primeiro elemento que chega até ele, e limit(n) para depois de n. É isso que torna segura uma fonte infinita:

void main() {
    var firstBig = Stream.iterate(1, n -> n * 2)
        .peek(n -> IO.println("looked at " + n))
        .filter(n -> n > 20)
        .findFirst();
    IO.println(firstBig);

    var firstFive = Stream.iterate(1, n -> n * 2).limit(5).toList();
    IO.println(firstFive);
}

Ele imprime:

looked at 1
looked at 2
looked at 4
looked at 8
looked at 16
looked at 32
Optional[32]
[1, 2, 4, 8, 16]

Stream.iterate(1, n -> n * 2) descreve 1, 2, 4, 8 e assim por diante, para sempre. O pipeline olhou seis valores, achou 32 e parou. findFirst retorna um Optional, porque um stream pode não ter primeiro elemento. A parte sobre Optional trata disso. Troque findFirst() por toList() e o programa nunca termina.

A avaliação preguiçosa pode pular suas lambdas

A avaliação preguiçosa trouxe uma surpresa que a gente não esperava. Uma operação terminal pode decidir que nem precisa rodar o pipeline:

void main() {
    long quick = Stream.of(1, 2, 3)
        .peek(n -> IO.println("peek " + n))
        .count();
    IO.println("count " + quick);

    long slow = Stream.of(1, 2, 3)
        .filter(n -> n > 1)
        .peek(n -> IO.println("peek " + n))
        .count();
    IO.println("count " + slow);
}

Ele imprime:

count 3
peek 2
peek 3
count 2

O primeiro peek nunca rodou. Stream.of(1, 2, 3) sabe que tem três elementos, peek não pode mudar isso, então count responde 3 sem puxar nada pelo pipeline. Quando há um filter no caminho, o tamanho não é conhecido, e todo elemento precisa passar. O JDK faz isso desde o Java 9, e a documentação de Stream permite. Então não coloque dentro de peek ou map um trabalho do qual você depende. Uma lambda num stream deve calcular um valor, não causar um efeito.

Explicado como se você tivesse dez anos

Um stream é uma linha de montagem numa fábrica de brinquedos. Ao longo da linha ficam os trabalhadores: um joga fora os brinquedos quebrados, um pinta os bons, um coloca na caixa. Bem no final fica um cliente.

Montar a linha não faz nenhum brinquedo. Nada se move até o cliente no final dizer “quero um brinquedo pronto”.

Aí o primeiro brinquedo percorre a linha inteira: conferido, pintado, embalado, entregue. Só então o segundo brinquedo começa. Se o cliente só queria um brinquedo, a linha para, e os outros brinquedos nunca saem da pilha.

A versão precisa

Um pipeline de stream é uma cadeia de objetos de estágio. Cada operação intermediária, como filter ou map, acrescenta um estágio e retorna na hora. A operação terminal dá a partida na avaliação. Ela pede elementos à fonte um por um, e cada elemento é empurrado pela lambda de cada estágio, em ordem, antes de a fonte receber o pedido do próximo.

Estágios stateless como filter e map tratam um elemento e o passam adiante na hora. Estágios stateful como sorted seguram os elementos até a fonte esvaziar. Operações de curto-circuito, findFirst, anyMatch, limit e companhia, avisam que terminaram, e a fonte para de produzir. Como a operação terminal vê o pipeline inteiro antes de começar, ela também pode pular estágios cujo resultado não precisa, como o count fez.

Onde a analogia falha: uma linha de montagem de verdade com um pintor lento teria brinquedos esperando na fila entre os trabalhadores. Um stream sequencial nunca tem. Não existe fila nem segundo brinquedo a caminho, exceto quando um estágio stateful como sorted para a linha para juntar tudo. E com parallel(), várias linhas rodam ao mesmo tempo e os elementos não chegam em ordem.

Vendo findFirst parar um stream

A animação acompanha Stream.of(1, 2, 3, 4, 5).filter(n -> n % 2 == 1).map(n -> n * 10).findFirst(), que retorna Optional[10]:

Stream.of(1, 2, 3, 4, 5) 1 2 3 4 5 2 a 5: nunca lidos filter(n % 2 == 1) map(n * 10) findFirst() 1 10 1 é ímpar: passa 1 vira 10 achou: para valor: Optional[10] o pipeline está pronto, mas nada se move findFirst() pede um elemento 1 entra no filter: é ímpar, então passa 1 entra no map e sai como 10 10 chega a findFirst(), que tem a resposta o stream para: 2 a 5 nunca são lidos

Um stream de cinco elementos puxado por filter, map e findFirst. Nada se move até findFirst pedir. O elemento 1 passa no filtro, vira 10 e chega a findFirst, que retorna Optional[10]. O stream para ali, e 2 a 5 nunca são lidos.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. Stream.of, filter e map retornam na hora. Eles montaram um pipeline, e nenhum elemento se moveu.
  2. findFirst() é a operação terminal. Ele pede um elemento ao pipeline, e o pedido chega à fonte.
  3. A fonte entrega o 1. O filtro testa 1 % 2 == 1, que é verdadeiro, então o 1 passa.
  4. O 1 entra no map e sai como 10.
  5. O 10 chega a findFirst(). Era tudo de que ele precisava, então ele embrulha o valor como Optional[10].
  6. findFirst() avisa o pipeline que terminou. A fonte nunca entrega 2, 3, 4 ou 5, e nenhuma lambda roda para eles.

Um stream só pode ser usado uma vez

Um stream é uma viagem só de ida pela fonte, então uma segunda operação terminal no mesmo objeto stream falha:

void main() {
    var words = Stream.of("tea", "coffee", "milk");
    IO.println(words.count());
    IO.println(words.count());
}

Ele imprime e para:

3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed

A solução é voltar à fonte, list.stream(), toda vez que você precisar de um pipeline novo. Para deixar outro código criar streams novos, passe a coleção, não um stream.

Collectors: agrupar, particionar, juntar

collect é a operação terminal que monta algo a partir dos elementos, e Collectors guarda os montadores prontos. Aqui estão vendas agrupadas, divididas e juntadas:

record Sale(String city, String product, int amount) {}

void main() {
    var sales = List.of(
        new Sale("Lisbon", "tea", 12),
        new Sale("Porto", "coffee", 30),
        new Sale("Lisbon", "coffee", 25),
        new Sale("Braga", "tea", 8),
        new Sale("Porto", "tea", 15));

    Map<String, Long> perCity = sales.stream()
        .collect(Collectors.groupingBy(Sale::city, TreeMap::new, Collectors.counting()));
    IO.println(perCity);

    Map<Boolean, List<Integer>> bigOrSmall = sales.stream()
        .map(Sale::amount)
        .collect(Collectors.partitioningBy(a -> a >= 20));
    IO.println(bigOrSmall);

    String cities = sales.stream()
        .map(Sale::city)
        .distinct()
        .sorted()
        .collect(Collectors.joining(", ", "<", ">"));
    IO.println(cities);
}

Ele imprime:

{Braga=1, Lisbon=2, Porto=2}
{false=[12, 8, 15], true=[30, 25]}
<Braga, Lisbon, Porto>
  • groupingBy(key, mapFactory, downstream) coloca os elementos em grupos pela chave e depois roda um segundo collector em cada grupo. counting() transforma cada grupo no seu tamanho. Sem o TreeMap::new, você recebe um HashMap, que não tem uma ordem útil na hora de imprimir. O TreeMap mantém as cidades ordenadas.
  • partitioningBy(predicate) sempre cria exatamente dois grupos, false e true, mesmo quando um deles fica vazio.
  • joining(separator, prefix, suffix) cola strings. Ele só funciona num stream de strings, e é por isso que o map vem antes.

toMap e chaves duplicadas

Collectors.toMap monta um map a partir de uma função de chave e uma função de valor. Quando dois elementos produzem a mesma chave, você precisa dizer o que acontece, ou o stream lança exceção:

record Sale(String city, int amount) {}

void main() {
    var sales = List.of(new Sale("Lisbon", 12), new Sale("Porto", 30), new Sale("Lisbon", 25));

    Map<String, Integer> totals = sales.stream()
        .collect(Collectors.toMap(Sale::city, Sale::amount, Integer::sum, TreeMap::new));
    IO.println(totals);

    Map<String, Integer> broken = sales.stream()
        .collect(Collectors.toMap(Sale::city, Sale::amount));
    IO.println(broken);
}

Ele imprime e para:

{Lisbon=37, Porto=30}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key Lisbon (attempted merging values 12 and 25)

O terceiro argumento, Integer::sum, é a função de merge. Ela recebe o valor antigo e o novo e retorna o que manter, então as vendas de Lisbon somaram 37. Sem ela, o segundo Lisbon lança IllegalStateException. A mensagem mostra a chave e os dois valores. Para um map de chave para contagem ou soma, groupingBy com counting() ou summingInt diz a mesma coisa de forma mais direta.

Streams primitivos

IntStream, LongStream e DoubleStream guardam valores int, long e double puros, e têm os métodos numéricos que faltam em Stream<Integer>:

void main() {
    IO.println(IntStream.range(0, 5).sum());
    IO.println(IntStream.rangeClosed(1, 5).boxed().toList());

    var words = List.of("tea", "coffee", "milk");
    int letters = words.stream().mapToInt(String::length).sum();
    OptionalDouble average = words.stream().mapToInt(String::length).average();
    IO.println(letters);
    IO.println(average);
    IO.println(average.orElse(0));
    IO.println(IntStream.empty().average());
}

Ele imprime:

10
[1, 2, 3, 4, 5]
13
OptionalDouble[4.333333333333333]
4.333333333333333
OptionalDouble.empty

range(0, 5) vai de 0 a 4, e rangeClosed(1, 5) inclui o 5. boxed() volta para um Stream<Integer> quando você precisa de uma List.

average() retorna um OptionalDouble, não um double, porque um stream vazio não tem média. A última linha mostra esse caso.

mapToInt é o mais importante. words.stream().map(String::length) daria um Stream<Integer>: cada tamanho embrulhado num objeto Integer, e nenhum método sum() para chamar. mapToInt dá um IntStream de ints puros. Nenhum objeto wrapper é criado, e sum, average, min e max vêm junto.

reduce, em poucas palavras

reduce combina todos os elementos num único valor, aplicando uma função par a par:

void main() {
    int total = Stream.of(3, 4, 5).reduce(0, Integer::sum);
    Optional<Integer> product = Stream.of(3, 4, 5).reduce((a, b) -> a * b);
    Optional<Integer> nothing = Stream.<Integer>empty().reduce((a, b) -> a * b);

    IO.println(total);
    IO.println(product);
    IO.println(nothing);
    IO.println(IntStream.of(3, 4, 5).sum());
}

Ele imprime:

12
Optional[60]
Optional.empty
12

Com um valor inicial, 0 aqui, você recebe um resultado simples. Sem ele, você recebe um Optional, já que pode não haver nada para combinar. reduce é genérico, então quem lê precisa descobrir o que ele calcula. Para somas, contagens, mínimos, máximos e junções, a versão com nome (sum(), count(), max(...), Collectors.joining) diz isso diretamente, como faz a última linha.

Gatherers: etapas intermediárias sob medida

Um gatherer é uma operação intermediária sob medida, usada com Stream.gather. Os gatherers viraram definitivos no Java 24. Conferimos: com javac --release 23, o build falha com Gatherers is a preview API and is disabled by default, e no Java 25 nenhuma flag é necessária. Gatherers traz alguns prontos, como janelas fixas e deslizantes:

void main() {
    var readings = List.of(3, 5, 4, 8, 9, 2, 7);

    IO.println(readings.stream().gather(Gatherers.windowFixed(3)).toList());
    IO.println(readings.stream().gather(Gatherers.windowSliding(3)).toList());

    var movingAverage = readings.stream()
        .gather(Gatherers.windowSliding(3))
        .map(w -> w.stream().mapToInt(Integer::intValue).average().orElseThrow())
        .toList();
    IO.println(movingAverage);
}

Ele imprime:

[[3, 5, 4], [8, 9, 2], [7]]
[[3, 5, 4], [5, 4, 8], [4, 8, 9], [8, 9, 2], [9, 2, 7]]
[4.0, 5.666666666666667, 7.0, 6.333333333333333, 6.0]

windowFixed(3) corta o stream em grupos de três, e o último grupo fica com o que sobrar. windowSliding(3) anda um elemento por vez, que é o que uma média móvel precisa. Antes dos gatherers, agrupar elementos vizinhos exigia um laço com índices. Você também pode escrever o seu com Gatherer.of.

Quando um laço é mais claro

Streams são bons em “pegue estes, fique com alguns, transforme, colete”. Fora desse formato, quatro casos aparecem o tempo todo.

Exceções checadas. Nenhuma das interfaces funcionais de java.util.function declara uma exceção checada, então uma lambda não pode lançar uma:

int parsePort(String s) throws IOException {
    if (s.isBlank()) {
        throw new IOException("blank port");
    }
    return Integer.parseInt(s);
}

void main() {
    var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
    IO.println(ports);
}

O build falha com:

Main.java:9: error: unreported exception IOException; must be caught or declared to be thrown
    var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
                                                     ^

As saídas são capturar a exceção dentro da lambda e embrulhá-la numa não checada, ou escrever um helper que faça isso por você. As duas escondem a exceção da assinatura do método. Um laço for pode simplesmente deixá-la propagar.

Mudar estado local, e sair cedo. Aqui está “compre os itens em ordem até o próximo estourar o orçamento”, uma vez como stream e outra como laço:

void main() {
    var prices = List.of(40, 25, 30, 50, 10);
    int budget = 100;

    int[] spentBox = {0};
    long boughtByStream = prices.stream()
        .takeWhile(p -> {
            if (spentBox[0] + p > budget) {
                return false;
            }
            spentBox[0] += p;
            return true;
        })
        .count();
    IO.println("stream: bought " + boughtByStream + ", spent " + spentBox[0]);

    int spent = 0;
    int bought = 0;
    for (int price : prices) {
        if (spent + price > budget) {
            break;
        }
        spent += price;
        bought++;
    }
    IO.println("loop:   bought " + bought + ", spent " + spent);
}

Ele imprime:

stream: bought 3, spent 95
loop:   bought 3, spent 95

Os dois dão a mesma resposta. A versão com stream precisa de um array de um elemento, porque a lambda não pode atribuir valor a uma variável local, então ela muda o conteúdo de um array. O predicado do takeWhile tem um efeito colateral, exatamente o que a surpresa do count lá atrás desaconselhou. O laço diz o que acontece na ordem em que acontece, e o break é a saída antecipada, sem truque.

Depuração. Num laço, você põe um breakpoint numa linha e olha todas as variáveis. Num pipeline, o código que você escreveu roda dentro da biblioteca de streams. Os stack traces se enchem de frames de java.util.stream e de nomes gerados como lambda$main$0, e um breakpoint cai dentro de uma lambda sem nenhuma variável de laço por perto. Se você precisa de várias chamadas a peek para entender um pipeline, ele provavelmente quer ser um laço.

Uma regra curta: use um stream quando o pipeline se lê como uma frase, filtre isto, mapeie aquilo, colete. Use um laço quando você precisa de break, de uma exceção checada, de várias variáveis que mudam juntas ou de um depurador.

O que lembrar

  • Uma lambda implementa uma interface com um único método abstrato. Function, Predicate, Supplier, Consumer, BiFunction e UnaryOperator cobrem a maioria das necessidades.
  • Uma referência de método, como String::length ou Point::new, é uma lambda que só chama um método.
  • Uma lambda só pode capturar variáveis locais efetivamente final.
  • Um stream não roda nada até uma operação terminal pedir, e então leva um elemento por todas as etapas antes do próximo. findFirst e limit param o stream cedo, até numa fonte infinita.
  • Não dependa de efeitos colaterais em peek ou map. A operação terminal pode pulá-los, como o count fez.
  • Um stream só pode ser usado uma vez. toList() é não modificável. Dê um TreeMap ao groupingBy quando a ordem importar, e dê uma função de merge ao toMap quando as chaves puderem se repetir.
  • Use mapToInt para números, e um laço simples para exceções checadas, saídas antecipadas e estado que muda.

Um stream descreve o trabalho, e a operação terminal decide quanto dele realmente roda.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.