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() convierte List<Order> en un Stream<Order>.
  • filter solo retiene los órdenes cuyo status es COMPLETED.
  • toList() (Java 16+) agrupa el resultado en una List inmutable, en lugar de usar collect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor

Nota: filter no modifica los datos originales. Stream es perezoso (lazy) – si no tienes una operación terminal (como toList(), forEach()), los filter/map anteriores 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::product es una referencia a método, equivalente a order -> order.product().
  • distinct() elimina elementos duplicados (basándose en equals()).
[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 map con forEach. map devuelve un nuevo stream sin efectos secundarios; forEach se 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.0 es la identidad – el valor inicial cuando el stream está vacío.
  • Double::sum es 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) y Stream.min(Comparator) – más corto y claro que reduce. Solo usa reduce cuando 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.toMap recibe dos funciones: key extractor y value extractor.
  • Si hay claves duplicadas, toMap lanzará 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 for tradicional es más legible que un stream.
  • Reutilizar un Stream ya consumido: un stream solo se puede ejecutar una vez. Llamar a collect() o forEach() 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, 1 en lugar de 0 al calcular la suma) dará un resultado incorrecto sin advertencia.
  • toMap con 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.

javastream-apiprogramación-funcionaljava21colecciones