Una operación terminal de Stream permite materializar, agrupar o reducir los elementos procesados.
En el artículo anterior vimos cómo montar la tubería: .filter() para quitar lo que no queremos y .map() para transformar los datos.
Pero terminamos usando .forEach(), que solo sirve para imprimir o ejecutar acciones.
En el mundo real, tú no quieres imprimir los datos. Tú quieres obtener una nueva lista con los datos procesados para seguir trabajando con ella. O quieres agruparlos. O quieres sumarlos todos.
Hoy vamos a ver cómo materializar los resultados del Stream.
Recolectando resultados (collect)
collect es la operación terminal más versátil. Toma todos los elementos que han sobrevivido al stream y los “empaqueta” en una estructura de datos.
Para usarlo, necesitamos la clase de utilidad Collectors, que trae las recetas de empaquetado.
A lista (toList)
Es el uso más común (90% de los casos).
// Java 8 a 15:
List<String> nombres = usuarios.stream()
.map(Usuario::getNombre)
.collect(Collectors.toList());
// Java 16+ (Mucho mejor):
// Se puede llamar directamente a .toList() sin usar Collectors
List<String> nombres = usuarios.stream()
.map(Usuario::getNombre)
.toList(); // Ojo: Devuelve una lista INMUTABLE
A conjunto (toSet)
Útil si quieres eliminar duplicados automáticamente al terminar el proceso.
Set<String> paisesUnicos = usuarios.stream()
.map(Usuario::getPais)
.collect(Collectors.toSet());
Unir textos (joining)
¿Quieres convertir una lista de nombres en un solo String separado por comas?
String resultado = usuarios.stream()
.map(Usuario::getNombre)
.collect(Collectors.joining(", "));
// Salida: "Ana, Pepe, Luis"
Agrupando datos (groupingBy)
Una de las operaciones más útiles es groupingBy.
Si vienes de SQL, esto es un GROUP BY.
Si vienes de Java antiguo, sabes la pesadilla que era crear un Map<String, List<Usuario>>, iterar, comprobar si la clave existe, crear la lista si no, añadir…
Con Streams es una línea.
Imagina que queremos agrupar usuarios por Ciudad.
// El resultado será un Mapa:
// Clave = Ciudad (String)
// Valor = Lista de usuarios en esa ciudad (List<Usuario>)
Map<String, List<Usuario>> porCiudad = usuarios.stream()
.collect(Collectors.groupingBy(Usuario::getCiudad));
Resultado visual del Mapa:
{
"Madrid": [Usuario(Ana), Usuario(Luis)],
"Barcelona": [Usuario(Pepe)],
"Valencia": [Usuario(Sara), Usuario(Elena)]
}
También podemos agrupar y contar (¿Cuántos usuarios hay en cada ciudad?):
Map<String, Long> conteoPorCiudad = usuarios.stream()
.collect(Collectors.groupingBy(Usuario::getCiudad, Collectors.counting()));
// { "Madrid": 2, "Barcelona": 1 ... }
Reducción (reduce)
A veces no quieres una lista, quieres un solo valor que resuma todo.
- La suma de todos los salarios.
- El número más alto.
- La concatenación de todas las letras.
reduce toma una secuencia de elementos y los combina repetidamente hasta que solo queda uno.
Requiere dos cosas:
- Valor Inicial (Identidad): El valor neutro (0 para sumar, 1 para multiplicar, "" para texto).
- Acumulador (BinaryOperator): Una función que toma dos elementos y devuelve uno.
List<Integer> numeros = List.of(1, 2, 3, 4, 5);
// Sumar todos los números
Integer suma = numeros.stream()
.reduce(0, (acumulado, actual) -> acumulado + actual);
// Proceso:
// 0 + 1 = 1
// 1 + 2 = 3
// 3 + 3 = 6 ... Resultado final: 15
Usando referencias a métodos queda precioso:
Integer suma = numeros.stream().reduce(0, Integer::sum);
Streams primitivos (IntStream)
Si vas a trabajar con muchos números, usar Stream<Integer> es ineficiente (por el boxing/unboxing de objetos).
Java tiene Streams especiales para tipos primitivos: IntStream, DoubleStream, LongStream.
// mapToInt convierte el stream de objetos a un IntStream puro
int sumaTotal = usuarios.stream()
.mapToInt(Usuario::getEdad) // Ahora fluyen 'int', no 'Integer'
.sum(); // Métodos directos: sum(), average(), max(), min()
Estadísticas de golpe:
Si usas primitivos, puedes pedir .summaryStatistics() y te da el máximo, mínimo, promedio y suma todo a la vez.
Ejemplo completo: el e-commerce
Vamos a combinar todo. Tenemos una lista de Pedido. Queremos saber cuánto dinero hemos ganado en total por cada categoría de producto.
// 1. Agrupamos por categoría
Map<String, Double> totalPorCategoria = pedidos.stream()
// Agrupar por categoría
.collect(Collectors.groupingBy(
Pedido::getCategoria,
// En vez de guardar la lista de pedidos, sumamos sus importes
Collectors.summingDouble(Pedido::getImporte)
));
// Resultado: { "Electrónica": 5000.0, "Ropa": 1200.50 }