Streams y operaciones con colecciones
Introducción
La API Stream es una de las características más potentes introducidas en Java 8, que revolucionó la forma en que procesamos colecciones de datos. Los streams proporcionan una manera elegante y funcional de realizar operaciones sobre secuencias de elementos, permitiéndonos escribir código más conciso, expresivo y, en muchos casos, más eficiente. A diferencia del enfoque tradicional, donde debíamos escribir bucles explícitos y gestionar variables temporales, los streams nos permiten declarar qué operaciones queremos realizar, dejando a Java la implementación de cómo realizarlas. En este artículo, exploraremos en profundidad la API Stream y aprenderemos a utilizarla para transformar, filtrar y procesar colecciones de manera efectiva.
¿Qué son los Streams?
Un stream (flujo) en Java representa una secuencia de elementos sobre los cuales se pueden realizar diversas operaciones. Los streams no almacenan datos; simplemente transportan elementos desde una fuente (como una colección) a través de una serie de operaciones.
Características principales de los Streams
- No almacenan datos: Los streams no son estructuras de datos, sino canales que transportan elementos.
- No modifican la fuente: Las operaciones realizadas sobre un stream no modifican la colección original.
- Perezosos: Muchas operaciones de stream son perezosas (lazy), lo que significa que no se ejecutan hasta que se necesita el resultado.
- Posiblemente ilimitados: Los streams pueden ser finitos o infinitos.
- Consumibles: Un stream solo puede ser consumido una vez; después de eso, debe ser recreado.
Creación de Streams
Existen varias formas de crear streams en Java:
1. A partir de colecciones
List<String> nombres = List.of("Ana", "Carlos", "Beatriz", "David");
Stream<String> streamDeNombres = nombres.stream();
2. A partir de arrays
String[] arrayDeNombres = {"Ana", "Carlos", "Beatriz", "David"};
Stream<String> streamDeArray = Arrays.stream(arrayDeNombres);
3. Mediante Stream.of()
Stream<String> streamDirecto = Stream.of("Ana", "Carlos", "Beatriz", "David");
4. Streams infinitos
// Stream infinito de números aleatorios
Stream<Double> numerosAleatorios = Stream.generate(Math::random);
// Stream infinito de números secuenciales
Stream<Integer> numerosSecuenciales = Stream.iterate(1, n -> n + 1);
Operaciones con Streams
Las operaciones de stream se dividen en dos categorías principales:
1. Operaciones intermedias
Estas operaciones transforman un stream en otro stream. Son perezosas y no se ejecutan hasta que se invoca una operación terminal. Algunas operaciones intermedias comunes son:
Filter: filtrar elementos según un predicado
List<Integer> numeros = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> pares = numeros.stream()
.filter(n -> n % 2 == 0)
.toList();
System.out.println("Números pares: " + pares); // [2, 4, 6, 8, 10]
Map: transformar cada elemento
List<String> nombres = List.of("Ana", "Carlos", "Beatriz");
List<Integer> longitudes = nombres.stream()
.map(String::length)
.toList();
System.out.println("Longitudes: " + longitudes); // [3, 6, 7]
Sorted: ordenar elementos
List<String> nombres = List.of("Carlos", "Ana", "Beatriz", "David");
List<String> ordenados = nombres.stream()
.sorted()
.toList();
System.out.println("Nombres ordenados: " + ordenados); // [Ana, Beatriz, Carlos, David]
Distinct: eliminar duplicados
List<Integer> numerosConDuplicados = List.of(1, 2, 2, 3, 3, 3, 4, 5, 5);
List<Integer> sinDuplicados = numerosConDuplicados.stream()
.distinct()
.toList();
System.out.println("Sin duplicados: " + sinDuplicados); // [1, 2, 3, 4, 5]
Limit: limitar el número de elementos
List<Integer> primerosCinco = Stream.iterate(1, n -> n + 1)
.limit(5)
.toList();
System.out.println("Primeros cinco: " + primerosCinco); // [1, 2, 3, 4, 5]
Skip: saltar elementos
List<Integer> numeros = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> saltadosTres = numeros.stream()
.skip(3)
.toList();
System.out.println("Saltando tres: " + saltadosTres); // [4, 5, 6, 7, 8, 9, 10]
FlatMap: aplanar streams anidados
List<List<Integer>> listaDeListasDeNumeros = List.of(
List.of(1, 2),
List.of(3, 4),
List.of(5, 6)
);
List<Integer> numerosPlanosOrdenados = listaDeListasDeNumeros.stream()
.flatMap(lista -> lista.stream())
.sorted()
.toList();
System.out.println("Aplanados y ordenados: " + numerosPlanosOrdenados); // [1, 2, 3, 4, 5, 6]
2. Operaciones terminales
Estas operaciones producen un resultado o un efecto secundario. Después de ejecutar una operación terminal, el stream no se puede utilizar más. Algunas operaciones terminales comunes son:
ForEach: realizar una acción para cada elemento
List<String> nombres = List.of("Ana", "Carlos", "Beatriz");
nombres.stream()
.forEach(nombre -> System.out.println("Hola, " + nombre));
// Imprime:
// Hola, Ana
// Hola, Carlos
// Hola, Beatriz
Collect: transformar el stream en una colección
List<String> nombres = List.of("Ana", "Carlos", "Beatriz");
Set<String> conjuntoNombres = nombres.stream()
.collect(Collectors.toSet());
System.out.println("Conjunto: " + conjuntoNombres); // [Ana, Beatriz, Carlos] (sin orden garantizado)
Reduce: combinar elementos para producir un único resultado
List<Integer> numeros = List.of(1, 2, 3, 4, 5);
Optional<Integer> suma = numeros.stream()
.reduce((a, b) -> a + b);
System.out.println("Suma: " + suma.get()); // 15
// Con valor inicial
Integer producto = numeros.stream()
.reduce(1, (a, b) -> a * b);
System.out.println("Producto: " + producto); // 120
Count, anyMatch, allMatch, noneMatch
List<Integer> numeros = List.of(1, 2, 3, 4, 5);
long cantidad = numeros.stream().count();
System.out.println("Cantidad: " + cantidad); // 5
boolean hayPares = numeros.stream().anyMatch(n -> n % 2 == 0);
System.out.println("¿Hay algún número par?: " + hayPares); // true
boolean todosMayoresQueCero = numeros.stream().allMatch(n -> n > 0);
System.out.println("¿Todos son mayores que cero?: " + todosMayoresQueCero); // true
boolean ningunNegativo = numeros.stream().noneMatch(n -> n < 0);
System.out.println("¿Ninguno es negativo?: " + ningunNegativo); // true
FindFirst, FindAny
List<String> nombres = List.of("Ana", "Carlos", "Beatriz");
Optional<String> primerNombre = nombres.stream().findFirst();
System.out.println("Primer nombre: " + primerNombre.get()); // Ana
Optional<String> cualquierNombre = nombres.stream().findAny();
System.out.println("Cualquier nombre: " + cualquierNombre.get()); // Probablemente Ana, pero no garantizado
Collectors: operaciones comunes de recolección
La clase Collectors
proporciona muchos métodos útiles para recolectar elementos de un stream:
Convertir a diferentes colecciones
List<String> nombres = List.of("Ana", "Carlos", "Beatriz", "David");
// A lista
List<String> lista = nombres.stream().collect(Collectors.toList());
// A conjunto
Set<String> conjunto = nombres.stream().collect(Collectors.toSet());
// A mapa
Map<String, Integer> mapaLongitudes = nombres.stream()
.collect(Collectors.toMap(
nombre -> nombre, // clave
nombre -> nombre.length() // valor
));
System.out.println("Mapa de longitudes: " + mapaLongitudes); // {Ana=3, Beatriz=7, Carlos=6, David=5}
Agrupación de elementos
List<String> nombres = List.of("Ana", "Alberto", "Beatriz", "Carlos", "Carmen");
// Agrupar por la primera letra
Map<Character, List<String>> porPrimeraLetra = nombres.stream()
.collect(Collectors.groupingBy(nombre -> nombre.charAt(0)));
System.out.println("Agrupados por primera letra: " + porPrimeraLetra);
// {A=[Ana, Alberto], B=[Beatriz], C=[Carlos, Carmen]}
Particionamiento
List<Integer> numeros = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Particionar en pares e impares
Map<Boolean, List<Integer>> paresEImpares = numeros.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println("Pares: " + paresEImpares.get(true)); // [2, 4, 6, 8, 10]
System.out.println("Impares: " + paresEImpares.get(false)); // [1, 3, 5, 7, 9]
Estadísticas
List<Integer> numeros = List.of(1, 2, 3, 4, 5);
// Obtener estadísticas
IntSummaryStatistics estadisticas = numeros.stream()
.collect(Collectors.summarizingInt(Integer::intValue));
System.out.println("Estadísticas: " + estadisticas);
// IntSummaryStatistics{count=5, sum=15, min=1, average=3.000000, max=5}
Unir cadenas
List<String> nombres = List.of("Ana", "Carlos", "Beatriz");
// Unir cadenas con un delimitador
String nombresUnidos = nombres.stream()
.collect(Collectors.joining(", "));
System.out.println("Nombres unidos: " + nombresUnidos); // Ana, Carlos, Beatriz
Streams paralelos
Java también ofrece la posibilidad de procesar elementos en paralelo para mejorar el rendimiento en sistemas multicore:
List<Integer> numeros = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// Crear un stream paralelo
long sumaDeCuadrados = numeros.parallelStream()
.map(n -> n * n)
.reduce(0, Integer::sum);
System.out.println("Suma de cuadrados: " + sumaDeCuadrados); // 385
Ejemplo práctico: procesamiento de datos de empleados
Veamos un ejemplo práctico más completo utilizando streams:
class Empleado {
private String nombre;
private String departamento;
private double salario;
public Empleado(String nombre, String departamento, double salario) {
this.nombre = nombre;
this.departamento = departamento;
this.salario = salario;
}
public String getNombre() { return nombre; }
public String getDepartamento() { return departamento; }
public double getSalario() { return salario; }
@Override
public String toString() {
return "Empleado{" +
"nombre='" + nombre + '\'' +
", departamento='" + departamento + '\'' +
", salario=" + salario +
'}';
}
}
public class ProcesamientoEmpleados {
public static void main(String[] args) {
List<Empleado> empleados = List.of(
new Empleado("Ana", "IT", 45000),
new Empleado("Carlos", "Ventas", 38000),
new Empleado("Beatriz", "IT", 51000),
new Empleado("David", "Marketing", 42000),
new Empleado("Elena", "Ventas", 40000)
);
// 1. Encontrar el salario promedio por departamento
Map<String, Double> salarioPromedioPorDepartamento = empleados.stream()
.collect(Collectors.groupingBy(
Empleado::getDepartamento,
Collectors.averagingDouble(Empleado::getSalario)
));
System.out.println("Salario promedio por departamento:");
salarioPromedioPorDepartamento.forEach((dept, avg) ->
System.out.println(dept + ": " + avg));
// 2. Encontrar el empleado con mayor salario en cada departamento
Map<String, Optional<Empleado>> empleadoMejorPagadoPorDepartamento = empleados.stream()
.collect(Collectors.groupingBy(
Empleado::getDepartamento,
Collectors.maxBy(Comparator.comparing(Empleado::getSalario))
));
System.out.println("\nEmpleado mejor pagado por departamento:");
empleadoMejorPagadoPorDepartamento.forEach((dept, empOpt) ->
System.out.println(dept + ": " + empOpt.get().getNombre() + " - " + empOpt.get().getSalario()));
// 3. Calcular el salario total de todos los empleados
double salarioTotal = empleados.stream()
.mapToDouble(Empleado::getSalario)
.sum();
System.out.println("\nSalario total de todos los empleados: " + salarioTotal);
// 4. Obtener los nombres de los empleados ordenados alfabéticamente
List<String> nombresOrdenados = empleados.stream()
.map(Empleado::getNombre)
.sorted()
.toList();
System.out.println("\nNombres ordenados: " + nombresOrdenados);
// 5. Contar empleados por departamento
Map<String, Long> empleadosPorDepartamento = empleados.stream()
.collect(Collectors.groupingBy(
Empleado::getDepartamento,
Collectors.counting()
));
System.out.println("\nNúmero de empleados por departamento:");
empleadosPorDepartamento.forEach((dept, count) ->
System.out.println(dept + ": " + count));
}
}
La salida sería algo como:
Salario promedio por departamento:
IT: 48000.0
Ventas: 39000.0
Marketing: 42000.0
Empleado mejor pagado por departamento:
IT: Beatriz - 51000.0
Ventas: Elena - 40000.0
Marketing: David - 42000.0
Salario total de todos los empleados: 216000.0
Nombres ordenados: [Ana, Beatriz, Carlos, David, Elena]
Número de empleados por departamento:
IT: 2
Ventas: 2
Marketing: 1
Buenas prácticas al usar Streams
- Prefiere operaciones inmutables: Evita modificar las fuentes de datos originales.
- Mantén la simplicidad: No encadenes demasiadas operaciones en un único stream.
- Usa streams paralelos con cuidado: Los streams paralelos pueden mejorar el rendimiento, pero también pueden introducir complejidad y problemas de sincronización.
- Evita efectos secundarios: Las operaciones en los streams deberían ser estadísticamente puras (sin efectos secundarios).
- Considera el rendimiento: Para colecciones pequeñas, el enfoque tradicional puede ser más eficiente.
- No reutilices streams: Un stream solo puede ser consumido una vez.
Resumen
Los streams y las operaciones con colecciones en Java ofrecen una forma poderosa y expresiva de procesar datos, permitiéndonos escribir código más limpio, conciso y en muchos casos más eficiente. A lo largo de este artículo, hemos explorado cómo crear streams, las diferentes operaciones que podemos realizar (intermedias y terminales), y hemos visto ejemplos prácticos de su aplicación en diversos escenarios. Dominar esta API es esencial para cualquier desarrollador Java moderno, ya que permite abordar problemas complejos de procesamiento de datos con un código más elegante y mantenible. En futuros artículos, profundizaremos en otras características modernas de Java que complementan perfectamente a los streams, como Optional para el manejo de valores nulos y la inferencia de tipos con var.