Stream API: map, filter, reduce y collect a través de ejemplos
1. Tổng quan
Stream API apareció desde Java 8 y se ha convertido en una herramienta indispensable al procesar colecciones. Este artículo te ayudará a dominar las 4 operaciones más importantes: map, filter, reduce y collect a través de ejemplos concretos con Order y Customer. Después de leerlo, sabrás cuándo usar cada operación, cómo combinarlas, y evitarás errores comunes al trabajar con streams.
2. Preparar datos de ejemplo
Antes de adentrarnos en cada operación, necesitas un conjunto de datos con los que trabajar a lo largo del artículo.
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 es un record – conciso, inmutable, perfecto para demostrar streams. El método total() calcula el total de dinero de cada order, que se reutilizará varias veces a continuación.
3. filter: filtrar elementos según una condición
filter recibe un Predicate<T> y retiene solo los elementos que cumplen la condición.
List<Order> completedOrders = OrderData.sample().stream()
.filter(order -> order.status().equals("COMPLETED"))
.toList();
completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
stream()convierteList<Order>en unStream<Order>.filtersolo retiene los órdenes cuyostatusesCOMPLETED.toList()(Java 16+) agrupa el resultado en unaListinmutable, en lugar de usarcollect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor
Nota:
filterno modifica los datos originales. Stream es perezoso (lazy) – si no tienes una operación terminal (comotoList(),forEach()), losfilter/mapanteriores no se ejecutarán.
3.1. Combinar múltiples condiciones de filter
List<Order> expensiveCompleted = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.toList();
Dos filter consecutivos son equivalentes a una condición AND. Puedes escribirlo en un solo filter con &&, pero separarlos hace que el código sea más legible cuando las condiciones son complejas.
4. map: transformar elementos
map recibe una Function<T, R> y transforma cada elemento de tipo T en un elemento de tipo R.
List<String> productNames = OrderData.sample().stream()
.map(Order::product)
.distinct()
.toList();
System.out.println(productNames);
Order::productes una referencia a método, equivalente aorder -> order.product().distinct()elimina elementos duplicados (basándose enequals()).
[Laptop, Mouse, Keyboard, Monitor]
4.1. map devolviendo un tipo completamente diferente
El punto fuerte de map es transformar el tipo de datos, no solo extraer un 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]
Nota: No confundas
mapconforEach.mapdevuelve un nuevo stream sin efectos secundarios;forEachse usa para ejecutar acciones (imprimir, guardar en DB…) y no devuelve nada.
5. reduce: combinar elementos en un único valor
reduce agrega todo el stream en un único valor, basándose en la identidad (valor inicial) y un 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.0es la identidad – el valor inicial cuando el stream está vacío.Double::sumes el acumulador, suma cada valor al resultado.
1.8950000E7
5.1. reduce sin identidad
Si no quieres proporcionar una identidad, reduce devuelve Optional<T> porque el stream puede estar vacío.
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
Nota: Para encontrar max/min, Java proporciona
Stream.max(Comparator)yStream.min(Comparator)– más corto y claro quereduce. Solo usareducecuando la lógica de agregación es más compleja que una simple comparación.
6. collect: recopilar resultados en estructuras de datos
collect es más flexible que toList(), permitiendo agrupar el resultado en Map, Set, o realizar groupBy/partición.
6.1. Recopilar en 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.toMaprecibe dos funciones: key extractor y value extractor.- Si hay claves duplicadas,
toMaplanzaráIllegalStateException– necesitas pasar una función de fusión adicional si los datos pueden tener claves duplicadas.
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}
6.2. groupingBy – agrupar según una condición
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 con downstream collector
Puedes calcular el total de ingresos por cada estado sin necesidad de agrupar primero e iterar de nuevo.
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 con downstream collector (summingDouble, counting, averagingDouble…) te ayuda a evitar escribir reduce manualmente para cada grupo.
7. Combinar las cuatro operaciones en un pipeline
Este es un ejemplo del mundo real: obtener la lista de nombres de productos de órdenes completadas con un valor > 1 millón, ordenadas en orden descendente por el 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]
El orden de operaciones aquí es crítico: filter primero para reducir el número de elementos a procesar, sorted después de filtrar para evitar ordenamiento innecesario, map al final para extraer solo el campo que necesitas.
8. Escribir pruebas para el pipeline
Siempre escribe pruebas para la lógica comercial dentro del stream pipeline, no la dejes “oculta” en una línea larga de código sin validación.
<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);
}
}
La primera prueba verifica que el resultado de reduce sea el total correcto. La segunda prueba verifica que groupingBy cree la cantidad correcta de elementos en el grupo COMPLETED.
9. Errores comunes
- Usar stream cuando un loop tradicional es más claro: si la lógica tiene muchos efectos secundarios (registrar, actualizar variables externas, lanzar excepciones con condiciones complejas), un
fortradicional es más legible que un stream. - Reutilizar un Stream ya consumido: un stream solo se puede ejecutar una vez. Llamar a
collect()oforEach()dos veces en el mismo stream lanzaráIllegalStateException: stream has already been operated upon or closed. - Olvidar una identidad apropiada en reduce: usar una identidad incorrecta (por ejemplo,
1en lugar de0al calcular la suma) dará un resultado incorrecto sin advertencia. toMapcon claves duplicadas: como se menciona en la sección 6.1, necesitas agregar una función de fusión:Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1).- Efectos secundarios dentro de map: evita cambiar el estado externo dentro de la función pasada a
map/filter. Los streams pueden ejecutarse en paralelo, los efectos secundarios causarán condiciones de carrera difíciles de depurar.
Nota: Si tu pipeline tiene más de 4-5 operaciones consecutivas y es difícil de leer, divídelo en métodos separados con nombres descriptivos (
filterCompleted,calculateRevenue…) en lugar de meter todo en una cadena larga.
10. Conclusión
filter, map, reduce y collect son herramientas fundamentales para procesar datos en el estilo funcional en Java. filter te ayuda a filtrar, map transforma, reduce agrega en un valor único, mientras que collect agrupa flexiblemente el resultado en estructuras más complejas como Map o agrupa según una condición. Domina el orden de las operaciones y evita los errores comunes anteriores, y podrás escribir pipelines de procesamiento de datos concisos, claros y fáciles de probar.