Stream API: map, filter, reduce e collect através de exemplos
1. Visão geral
A Stream API surgiu no Java 8 e se tornou uma ferramenta indispensável ao trabalhar com collections. Este artigo ajuda você a dominar as 4 operações mais importantes: map, filter, reduce e collect através de exemplos práticos com Order e Customer. Ao final da leitura, você saberá quando usar cada operação, como combiná-las e evitar alguns erros comuns ao trabalhar com streams.
2. Preparando dados de amostra
Antes de explorar cada operação, você precisa de um conjunto de dados para manipular ao longo do artigo.
public record Order(String id, String product, int quantity, double price, String status) {
public double total() {
return quantity * price;
}
}
import java.util.List;
public class OrderData {
public static List<Order> sample() {
return List.of(
new Order("O1", "Laptop", 1, 15_000_000, "COMPLETED"),
new Order("O2", "Mouse", 3, 150_000, "COMPLETED"),
new Order("O3", "Keyboard", 2, 400_000, "CANCELLED"),
new Order("O4", "Monitor", 1, 3_500_000, "COMPLETED"),
new Order("O5", "Laptop", 1, 14_000_000, "PENDING")
);
}
}
Order é um record – conciso, imutável, perfeito para demonstração de stream. O método total() calcula o valor total de cada order e será reutilizado muitas vezes abaixo.
3. filter: filtrando elementos por condição
filter recebe um Predicate<T> e mantém apenas os elementos que satisfazem a condição.
List<Order> completedOrders = OrderData.sample().stream()
.filter(order -> order.status().equals("COMPLETED"))
.toList();
completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
stream()converteList<Order>em umStream<Order>.filtermantém apenas os orders comstatusigual aCOMPLETED.toList()(Java 16+) agrupa o resultado em umaListimutável, substituindocollect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor
Observação:
filternão altera os dados originais. Stream é lazy – se você não tiver uma operação terminal (comotoList(),forEach()), osfilter/mapanteriores não serão executados.
3.1. Combinando múltiplas condições de filter
List<Order> expensiveCompleted = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.toList();
Dois filter consecutivos são equivalentes a uma condição AND. Você pode combiná-los em um único filter com &&, mas separá-los deixa o código mais legível quando as condições são complexas.
4. map: transformando elementos
map recebe uma Function<T, R> e transforma cada elemento do tipo T em um elemento do tipo R.
List<String> productNames = OrderData.sample().stream()
.map(Order::product)
.distinct()
.toList();
System.out.println(productNames);
Order::producté uma method reference, equivalente aorder -> order.product().distinct()remove elementos duplicados (baseado emequals()).
[Laptop, Mouse, Keyboard, Monitor]
4.1. map retornando um tipo completamente diferente
O ponto forte do map é transformar tipos de dados, não apenas extrair um campo.
List<Double> totals = OrderData.sample().stream()
.map(Order::total)
.toList();
System.out.println(totals);
[15000000.0, 450000.0, 800000.0, 3500000.0, 14000000.0]
Observação: Não confunda
mapcomforEach.mapretorna um novo stream sem side effects;forEaché usado para executar ações (imprimir, salvar em BD…) e não retorna nada.
5. reduce: agregando múltiplos elementos em um valor
reduce agrega todo o stream em um único valor, baseado em identity (valor inicial) e um BinaryOperator.
double totalRevenue = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.map(Order::total)
.reduce(0.0, Double::sum);
System.out.println(totalRevenue);
0.0é a identity – o valor inicial quando o stream está vazio.Double::sumé o accumulator, acumulando cada valor no resultado.
1.8950000E7
5.1. reduce sem identity
Se você não quiser fornecer uma identity, reduce retorna Optional<T> porque o stream pode estar vazio.
Optional<Order> mostExpensive = OrderData.sample().stream()
.reduce((o1, o2) -> o1.total() > o2.total() ? o1 : o2);
mostExpensive.ifPresent(o -> System.out.println(o.id() + ": " + o.total()));
O1: 1.5E7
Observação: Para encontrar max/min, Java já fornece
Stream.max(Comparator)eStream.min(Comparator)– mais concisas e claras quereduce. Usereduceapenas quando a lógica de agregação for mais complexa que uma simples comparação.
6. collect: agrupando resultados em estruturas de dados
collect é mais flexível que toList(), permitindo agrupar resultados em Map, Set ou realizar groupBy/partition.
6.1. Agrupando em Map
import java.util.Map;
import java.util.stream.Collectors;
Map<String, Double> totalByOrderId = OrderData.sample().stream()
.collect(Collectors.toMap(Order::id, Order::total));
System.out.println(totalByOrderId);
Collectors.toMaprecebe duas funções: key extractor e value extractor.- Se houver keys duplicadas,
toMaplançaráIllegalStateException– você precisa passar uma merge function adicional se os dados puderem ter keys duplicadas.
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}
6.2. groupingBy – agrupando por condição
Map<String, List<Order>> ordersByStatus = OrderData.sample().stream()
.collect(Collectors.groupingBy(Order::status));
ordersByStatus.forEach((status, orders) ->
System.out.println(status + ": " + orders.size() + " order(s)"));
COMPLETED: 3 order(s)
CANCELLED: 1 order(s)
PENDING: 1 order(s)
6.3. groupingBy combinado com downstream collector
Você pode calcular a receita total por cada status de agrupamento sem precisar agrupar primeiro e depois fazer um loop novamente.
Map<String, Double> revenueByStatus = OrderData.sample().stream()
.collect(Collectors.groupingBy(
Order::status,
Collectors.summingDouble(Order::total)
));
System.out.println(revenueByStatus);
{COMPLETED=18950000.0, CANCELLED=800000.0, PENDING=14000000.0}
groupingBy com downstream collector (summingDouble, counting, averagingDouble…) ajuda você a evitar ter que escrever reduce manualmente para cada grupo.
7. Combinando as quatro operações em um pipeline
Este é um exemplo prático: obter uma lista de nomes de produtos de orders completadas, com valor > 1 milhão, ordenada em ordem decrescente pelo total.
List<String> topProducts = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.sorted(Comparator.comparingDouble(Order::total).reversed())
.map(Order::product)
.toList();
System.out.println(topProducts);
[Laptop, Monitor]
A ordem das operações aqui é importante: filter primeiro para reduzir o número de elementos a processar, sorted após filtragem para evitar ordenação desnecessária, map no final para extrair apenas o campo necessário.
8. Escrevendo testes para o pipeline
Sempre escreva testes para a lógica de negócio que está em um stream pipeline, não deixe “oculta” em uma longa linha de código sem verificação.
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderAnalyticsTest {
@Test
void shouldCalculateTotalRevenueForCompletedOrders() {
double totalRevenue = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.map(Order::total)
.reduce(0.0, Double::sum);
assertThat(totalRevenue).isEqualTo(18_950_000.0);
}
@Test
void shouldGroupOrdersByStatus() {
var grouped = OrderData.sample().stream()
.collect(java.util.stream.Collectors.groupingBy(Order::status));
assertThat(grouped)
.containsKey("COMPLETED")
.extractingByKey("COMPLETED", org.assertj.core.api.InstanceOfAssertFactories.list(Order.class))
.hasSize(3);
}
}
O primeiro teste verifica se reduce calcula corretamente o total de receita. O segundo teste verifica se groupingBy cria o número correto de elementos no grupo COMPLETED.
9. Erros comuns
- Usar stream quando um loop tradicional é mais claro: se a lógica tem muitos side effects (logging, atualizar variáveis externas, lançar exceções com condições complexas), um loop
fortradicional é mais legível que stream. - Reutilizar um Stream já consumido: um stream pode ser executado apenas uma vez. Chamar
collect()ouforEach()duas vezes no mesmo stream lançaráIllegalStateException: stream has already been operated upon or closed. - Esquecer a identity apropriada em reduce: usar a identity errada (por exemplo,
1em vez de0ao calcular uma soma) resultará em resultado incorreto mas sem nenhum erro reportado. - toMap com keys duplicadas: como mencionado na seção 6.1, você precisa adicionar uma merge function:
Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1). - Side effects em map: evite alterar estado externo dentro da função passada para
map/filter. Streams podem ser executados em paralelo e side effects causarão race conditions difíceis de debugar.
Observação: Se seu pipeline tiver mais de 4-5 operações encadeadas e for difícil de ler, divida-o em métodos separados com nomes significativos (
filterCompleted,calculateRevenue…) em vez de colocar tudo em uma chain longa.
10. Conclusão
filter, map, reduce e collect são as ferramentas fundamentais para processar dados de forma funcional em Java. filter ajuda você a selecionar, map transforma, reduce agrega em um único valor, e collect flexivelmente agrupa resultados em estruturas mais complexas como Map ou agrupa por condição. Dominando a ordem de operações e evitando os erros comuns acima, você será capaz de escrever pipelines de processamento de dados concisos, claros e fáceis de testar.