Pare de Escrever Laços — a API de Streams

Pare de Escrever Laços — a API de Streams

Como trocar laços pela API de Streams do Java: fontes, operações intermediárias e terminais, streams de primitivos e a execução preguiçosa que processa um elemento por vez. Mostra também por que as lambdas devem ser puras e quando o laço comum continua sendo a escolha mais clara.
Java

• • 15 min de leitura

Como descrever o resultado que você quer de uma coleção, em vez de escrever o laço que o calcula, e o que acontece por baixo quando a descrição é executada.

"Comportamento como Valor" terminou perguntando: se funções podem ser passadas como valores, por que continuar escrevendo laços para filtrar, transformar e somar? A API de Streams, que chegou ao Java 8 junto com as lambdas, responde a isso. Com ela, um processamento de dados é escrito como uma sequência de etapas que descrevem o resultado: "os alunos aprovados, ordenados por nota, só os nomes". Nesta aula você vai aprender a montar essas sequências, conhecer as operações mais usadas, entender a execução preguiçosa que as torna eficientes e ver os cuidados que as streams exigem.

O mesmo problema, dois estilos

Uma turma de alunos, representada por records:

record Aluno(String nome, int idade, double nota) { }

List<Aluno> alunos = List.of(
    new Aluno("Davi", 19, 8.5), new Aluno("Ana", 22, 9.0),
    new Aluno("Bia", 20, 5.5), new Aluno("Caio", 19, 9.0),
    new Aluno("Eva", 21, 6.5), new Aluno("Fábio", 23, 4.0));

O problema: os nomes dos aprovados (nota a partir de 6), do maior para o menor. Com o que o curso ensinou até aqui:

List<Aluno> aprovados = new ArrayList<>();
for (Aluno a : alunos) {
    if (a.nota() >= 6.0) {
        aprovados.add(a);
    }
}
aprovados.sort(Comparator.comparingDouble(Aluno::nota).reversed());
List<String> nomes = new ArrayList<>();
for (Aluno a : aprovados) {
    nomes.add(a.nome());
}

Com streams:

List<String> nomes = alunos.stream()
    .filter(a -> a.nota() >= 6.0)
    .sorted(Comparator.comparingDouble(Aluno::nota).reversed())
    .map(Aluno::nome)
    .toList();

As duas versões produzem [Ana, Caio, Davi, Eva]. A diferença está no que o leitor precisa fazer para entendê-las. Na primeira, é preciso acompanhar duas listas intermediárias, dois laços e uma condição para reconstruir mentalmente o objetivo. Na segunda, cada linha é uma etapa do objetivo, lida de cima para baixo: filtre, ordene, extraia o nome, junte numa lista. O primeiro estilo, que diz como fazer passo a passo, se chama imperativo. O segundo, que diz o que se quer, se chama declarativo.

Anatomia de uma stream

Toda stream tem três partes:

  1. Uma fonte, que fornece os elementos: aqui, alunos.stream().
  2. Operações intermediárias, que transformam a stream em outra stream: filter, sorted, map. Pode haver quantas forem necessárias.
  3. Uma operação terminal, que produz o resultado final: toList(). Só pode haver uma, e ela encerra a stream.

Uma stream não é uma coleção. Ela não guarda elementos: é uma sequência de valores que passa pelas etapas, como numa linha de montagem. Três consequências disso:

  • A fonte não é alterada. filter não remove nada da lista alunos. Produz uma stream nova só com os que passam.
  • Uma stream só pode ser percorrida uma vez. Depois da operação terminal, ela está consumida.
  • As operações intermediárias são preguiçosas. Nada é executado até a operação terminal pedir um resultado. Mais sobre isso adiante.

Fontes

Além de stream() em qualquer coleção, as fontes mais comuns são:

Stream.of("a", "b", "c")                              // valores avulsos
Arrays.stream(new int[] {3, 1, 2})                     // um array
IntStream.rangeClosed(1, 5)                            // 1, 2, 3, 4, 5
IntStream.range(0, 5)                                  // 0 a 4
Stream.iterate(1, x -> x * 2).limit(10)                // 1, 2, 4, ... 512
Stream.iterate(1, x -> x < 100, x -> x * 3)            // 1, 3, 9, 27, 81
"banana".chars()                                       // os caracteres de um texto, como int

Stream.iterate gera uma sequência a partir de um valor inicial e de uma função que calcula o próximo. Na primeira forma, a sequência é infinita, e só termina por causa do limit. A segunda forma, com três argumentos, funciona como um for: valor inicial, condição e passo.

Uma fonte especial são as linhas de um arquivo:

try (Stream<String> linhas = Files.lines(Path.of("log.txt"))) {
    List<String> erros = linhas
        .filter(l -> l.startsWith("ERRO"))
        .map(l -> l.substring(5))
        .toList();
}

Files.lines lê o arquivo aos poucos, conforme a stream pede linhas, então funciona com arquivos de qualquer tamanho, sem o problema de memória do readAllLines visto em "Recursos que se Fecham Sozinhos". Como mantém o arquivo aberto, a stream precisa ser fechada, e por isso vai num try-with-resources. Streams implementam AutoCloseable. As de coleções não precisam ser fechadas, mas as de arquivo sim.

Operações intermediárias

As mais usadas:

Operação Faz Exemplo
filter(Predicate) mantém os elementos que passam no teste .filter(a -> a.nota() >= 6)
map(Function) transforma cada elemento em outro .map(Aluno::nome)
sorted(), sorted(Comparator) ordena .sorted(Comparator.comparing(Aluno::nome))
distinct() remove repetições, pelo equals .distinct()
limit(n), skip(n) mantém os n primeiros / descarta os n primeiros .skip(2).limit(2)
takeWhile(Predicate), dropWhile(Predicate) mantém / descarta enquanto a condição valer
peek(Consumer) executa uma ação sem alterar os elementos útil para depurar
IO.println(alunos.stream().map(Aluno::idade).distinct().sorted().toList());
// [19, 20, 21, 22, 23]

IO.println(alunos.stream().skip(2).limit(2).map(Aluno::nome).toList());
// [Bia, Caio]

IO.println(Stream.of(1, 2, 3, 10, 4, 5).takeWhile(x -> x < 5).toList());
// [1, 2, 3]: para no primeiro que falha, o 10

Repare na diferença entre takeWhile e filter: filter(x -> x < 5) daria [1, 2, 3, 4], porque testa todos os elementos. takeWhile para no primeiro que falha.

Operações terminais

A operação terminal decide o que a stream produz:

alunos.stream().filter(a -> a.nota() < 6).count()                          // 2
alunos.stream().mapToDouble(Aluno::nota).average().orElse(0)               // 7.083...
alunos.stream().mapToInt(Aluno::idade).sum()                               // 124
alunos.stream().max(Comparator.comparingInt(Aluno::idade))                 // o mais velho
alunos.stream().anyMatch(a -> a.nota() == 10)                              // false
alunos.stream().allMatch(a -> a.idade() >= 18)                             // true
alunos.stream().noneMatch(a -> a.nome().isBlank())                         // true
alunos.stream().map(Aluno::nome).filter(n -> n.startsWith("C"))
               .findFirst().orElse("ninguém")                              // Caio
alunos.stream().map(Aluno::nome).collect(Collectors.joining(", ", "[", "]"))
                                                                           // [Davi, Ana, Bia, Caio, Eva, Fábio]
  • toList() reúne os elementos numa lista imutável. Tentar alterá-la lança UnsupportedOperationException, como as listas de List.of.
  • count, sum, average, min e max calculam agregados.
  • anyMatch, allMatch e noneMatch respondem perguntas de sim ou não.
  • findFirst devolve o primeiro elemento que chegou até ali.
  • forEach executa uma ação para cada elemento, como imprimir.
  • collect reúne os elementos de formas variadas, e Collectors.joining junta textos com um separador, um prefixo e um sufixo. Os coletores são o assunto da próxima aula.

max, findFirst e average não devolvem o valor diretamente, porque a stream pode estar vazia, e aí não há máximo nem primeiro. Eles devolvem um Optional (ou OptionalDouble), um recipiente que pode ter ou não um valor, e o orElse fornece um padrão para o caso vazio. O Optional é o tema de "Um Valor que Pode Não Existir", e até lá basta o orElse.

Streams de primitivos

map(Aluno::idade) produz uma Stream<Integer>, com cada idade embrulhada num objeto, e o custo de "Escreva uma Vez para Todo Tipo". Para números, existem as streams especializadas IntStream, LongStream e DoubleStream, obtidas com mapToInt, mapToLong e mapToDouble. Elas trabalham com primitivos e oferecem sum, average e summaryStatistics, que a Stream comum não tem. Para voltar a uma stream de objetos, usa-se boxed() ou mapToObj:

IO.println(IntStream.rangeClosed(1, 5).map(i -> i * i).sum());          // 55
IO.println(IntStream.range(0, 5).mapToObj(i -> "#" + i).toList());      // [#0, #1, #2, #3, #4]

Preguiça: nada acontece até a hora certa

As operações intermediárias não executam quando são chamadas. Elas só montam o processamento, que acontece quando a operação terminal pede resultados. O peek permite ver isso:

List<String> nomes = List.of("Ana", "Bruno", "Carla", "Davi", "Eduarda");
Stream<String> s = nomes.stream()
    .peek(n -> IO.println("  lendo " + n))
    .filter(n -> n.length() > 4)
    .peek(n -> IO.println("  passou no filtro: " + n))
    .map(String::toUpperCase);

IO.println("stream montada, nada executou ainda");
String primeiro = s.findFirst().orElse("?");
IO.println("resultado: " + primeiro);
stream montada, nada executou ainda
  lendo Ana
  lendo Bruno
  passou no filtro: Bruno
resultado: BRUNO

Duas coisas chamam a atenção. Primeiro, nada foi impresso antes do findFirst. Segundo, os elementos não passam etapa por etapa, com todos filtrados primeiro e depois todos transformados: cada elemento atravessa a linha de montagem inteira antes do próximo entrar. E como findFirst precisa de um só resultado, o processamento parou assim que Bruno chegou ao fim. Carla, Davi e Eduarda nunca foram lidos. É por isso que uma stream infinita, como a de Stream.iterate, funciona: o limit ou o findFirst interrompem a geração.

A exceção são as operações que precisam de todos os elementos antes de continuar, como sorted: não dá para saber qual é o menor sem ver todos. Ela funciona como uma barreira na linha de montagem, e o exercício 2 mostra o efeito.

Depois da operação terminal, a stream está consumida:

s.count();
// IllegalStateException: stream has already been operated upon or closed

Sem efeitos colaterais

As lambdas passadas às operações de uma stream devem ser funções puras: calculam o resultado a partir da entrada, sem alterar nada fora delas. Evite isto:

List<Integer> pares = new ArrayList<>();
IntStream.rangeClosed(1, 10).filter(n -> n % 2 == 0).forEach(pares::add);   // evite

O resultado está correto, mas o código usa a stream como um laço disfarçado e alimenta uma lista externa. A forma idiomática deixa a stream produzir o resultado:

List<Integer> pares = IntStream.rangeClosed(1, 10).filter(n -> n % 2 == 0).boxed().toList();

Além da clareza, há um motivo técnico: streams podem ser executadas em paralelo, com vários núcleos processando elementos ao mesmo tempo, como você vai ver na próxima aula. Uma lambda que altera uma lista compartilhada funciona por acaso na versão sequencial e corrompe os dados na paralela. O peek existe para depuração, e não para executar lógica de verdade.

Juntando tudo: o relatório de notas

O relatório de "Recursos que se Fecham Sozinhos" lia o arquivo com um laço, tratava linhas inválidas com try/catch e acumulava as notas numa lista. Com streams, e com uma expressão regular de "Tempo, Dinheiro e Padrões" para descartar as linhas inválidas:

record Nota(String aluno, String disciplina, double valor) { }

static final Pattern LINHA_VALIDA = Pattern.compile("[^;]+;[^;]+;\\d+(\\.\\d+)?");

void main() throws IOException {
    try (Stream<String> linhas = Files.lines(Path.of("dados", "notas.csv"))) {
        List<Nota> notas = linhas
            .skip(1)                                          // cabeçalho
            .filter(l -> LINHA_VALIDA.matcher(l).matches())
            .map(l -> l.split(";"))
            .map(c -> new Nota(c[0], c[1], Double.parseDouble(c[2])))
            .toList();

        IO.println(notas.size() + " notas válidas");
        double mediaGeral = notas.stream().mapToDouble(Nota::valor).average().orElse(0);
        IO.println("média geral %.2f".formatted(mediaGeral));
    }
}
6 notas válidas
média geral 7.92

A leitura, a validação e a conversão ficaram numa única expressão, em que cada linha é uma etapa. A linha com "abc" foi descartada pelo filtro, sem exceção nenhuma, porque a validação acontece antes da conversão. Esta versão, porém, perdeu o aviso sobre as linhas ignoradas que a versão com laço produzia. Streams são ótimas para transformar dados. Quando o processamento precisa acompanhar vários resultados diferentes ao mesmo tempo, como as notas válidas e as linhas com problema, às vezes o laço continua sendo a ferramenta mais clara.

Não é preciso converter todo laço em stream. Streams brilham em transformações de coleções: filtrar, mapear, agregar, agrupar. Laços continuam mais claros quando o processamento tem muitas condições entrelaçadas, precisa sair no meio de várias formas, ou altera estado de propósito. E streams não são automaticamente mais rápidas: para coleções pequenas, o laço costuma ser um pouco mais rápido. A escolha é pela legibilidade.

O que falta ao relatório é justamente a parte mais interessante: a média por aluno, as notas por disciplina, os aprovados separados dos reprovados. Na versão com laço, isso exigia um mapa de listas e três laços. Com streams, cada um desses agrupamentos vira uma operação terminal só, graças aos coletores.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Reescreva o laço abaixo com uma stream:

double total = 0;
for (Produto p : produtos) {
    if (p.categoria().equals("livros") && p.estoque() > 0) {
        total += p.preco() * p.estoque();
    }
}
Ver resposta

✓ Resposta:

double total = produtos.stream()
    .filter(p -> p.categoria().equals("livros"))
    .filter(p -> p.estoque() > 0)
    .mapToDouble(p -> p.preco() * p.estoque())
    .sum();

As duas condições do if viraram dois filter, que se leem melhor do que uma condição com && e podem ser reordenados ou removidos separadamente. (Um único filter com && também estaria correto.) O acumulador total desapareceu: mapToDouble transforma cada produto no valor do seu estoque, e sum faz a soma sem nenhuma variável mutável. Se preco fosse BigDecimal, como recomendou "Tempo, Dinheiro e Padrões", a soma seria feita com reduce, apresentado na próxima aula.

Exercício 2

Sem rodar, diga o que o trecho imprime, e explique a diferença para o exemplo do findFirst da aula:

Stream.of("c", "a", "b")
    .peek(x -> IO.print("p" + x + " "))
    .sorted()
    .peek(x -> IO.print("s" + x + " "))
    .forEach(x -> IO.print(x + " "));
Ver resposta

✓ Resposta: Imprime pc pa pb sa a sb b sc c. Até o sorted, os elementos passam normalmente pelo primeiro peek, na ordem da fonte. Mas o sorted precisa de todos os elementos para saber a ordem, e por isso segura cada um até a fonte acabar. Só então libera os elementos, já ordenados, e cada um atravessa o restante da linha de montagem inteiro, com o segundo peek e o forEach, antes do próximo. No exemplo do findFirst, não havia nenhuma barreira, e cada elemento ia da fonte até o fim sozinho. As operações que precisam ver todos os elementos, como sorted e distinct, se chamam com estado, e são as que impedem uma stream infinita de terminar, se vierem antes de um limit.

Exercício 3

O código abaixo lança IllegalStateException. Explique por quê e mostre como calcular as duas informações sem repetir a definição da stream.

Stream<String> palavras = Stream.of("x", "yy", "zzz");
long quantidade = palavras.count();
int letras = palavras.mapToInt(String::length).sum();
Ver resposta

✓ Resposta: Uma stream só pode ser percorrida uma vez. O count() é uma operação terminal e consome a stream, e a segunda linha tenta usar uma stream já consumida. Uma solução é guardar a fonte, e não a stream, com um Supplier que cria uma stream nova a cada pedido:

Supplier<Stream<String>> palavras = () -> Stream.of("x", "yy", "zzz");
long quantidade = palavras.get().count();                      // 3
int letras = palavras.get().mapToInt(String::length).sum();    // 6

Quando os dados estão numa coleção, basta chamar lista.stream() duas vezes, porque cada chamada cria uma stream nova. O que não se deve fazer é passar uma Stream como parâmetro ou devolvê-la de um método esperando que quem recebe a percorra mais de uma vez. Por isso, métodos públicos costumam devolver uma List, e não uma Stream.

Exercício 4

Usando IntStream, calcule a soma dos quadrados dos números ímpares de 1 a 100.

Ver resposta

✓ Resposta:

int soma = IntStream.rangeClosed(1, 100)
    .filter(n -> n % 2 == 1)
    .map(n -> n * n)
    .sum();

O resultado é 166650. rangeClosed inclui o 100. A conta toda acontece com int primitivo, sem nenhum objeto Integer. Uma alternativa gera só os ímpares, sem filtro: IntStream.iterate(1, n -> n <= 99, n -> n + 2).map(n -> n * n).sum(). A primeira versão é mais legível, porque diz exatamente o que o enunciado pede. A segunda faz metade do trabalho. Para cem números a diferença é irrelevante, e a clareza vence.

Exercício 5

Encontre o primeiro nome com mais de cinco letras numa lista, em maiúsculas, ou "nenhum" se não houver. Quantos elementos da lista ["Ana", "Bruno", "Eduarda", "Caio"] são processados?

Ver resposta

✓ Resposta:

String resultado = nomes.stream()
    .filter(n -> n.length() > 5)
    .map(String::toUpperCase)
    .findFirst()
    .orElse("nenhum");

O resultado é "EDUARDA". Três elementos são processados: Ana e Bruno são lidos e barrados no filtro, Eduarda passa, é convertida e encontrada, e o findFirst encerra a stream antes de Caio ser lido. Só Eduarda é convertida para maiúsculas, porque o map só recebe o que passou no filtro. Num laço equivalente, seria preciso lembrar de colocar um break. Na stream, a interrupção é consequência natural de pedir só o primeiro resultado. Se a lista não tivesse nenhum nome longo, o findFirst devolveria um Optional vazio, e o orElse forneceria "nenhum".

Comentários

Mais em Java

Do JAR ao Executável — jlink, jpackage e GraalVM Native Image
Do JAR ao Executável — jlink, jpackage e GraalVM Native Image

Como entregar um programa Java a quem não tem Java instalado: descobrir de…

Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam
Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam

As Trilhas da Ilha no MySQL 9.7, com ou sem Docker: colação que ignora acentos…

O Destino e o Novo Começo — Encerrando a Jornada Completa
O Destino e o Novo Começo — Encerrando a Jornada Completa

Nesta última aula você olha para o caminho inteiro, do primeiro "Olá, mundo"…