Streams y lambdas

Las lambdas y los streams son lo que más ha cambiado la cara del código Java en la última década. Esta lección enseña a leerlos y a escribirlos, y también —porque hace falta— cuándo un bucle de toda la vida es mejor idea.

Una lambda es un objeto

En la lección 6 apareció de pasada: cuando una interfaz tiene un solo método abstracto, se puede implementar con una flecha en vez de con una clase. Java trae ya escritas las cuatro formas que hacen falta casi siempre:

Predicate<String>       esLargo = s -> s.length() > 5;   // devuelve boolean
Function<String,Integer> largo  = String::length;         // transforma
Supplier<String>        saludo  = () -> "hola";           // produce, sin entrada
Consumer<String>        imprime = s -> IO.println(s);     // consume, sin salida
salidaesLargo("mochila") = true
  largo("uva")       = 3
  saludo()           = hola
    consumido

Ese String::length es una referencia a método: la forma corta de s -> s.length(). Hay cuatro variantes que conviene reconocer: Clase::metodoEstatico, objeto::metodo, Clase::metodoDeInstancia (el objeto es el argumento) y Clase::new.

Y se combinan entre sí:

esLargo.negate().test("mochila");        // false
largo.andThen(n -> n * 2).apply("uva");  // 6
salidacomponer: false  6

Un stream es una tubería

Tres partes, siempre en este orden: de dónde salen los datos, qué se les hace, y en qué acaban.

List<String> hay = ps.stream()
    .filter(p -> p.stock() > 0)                            // intermedias: devuelven otro stream
    .sorted(Comparator.comparingDouble(Producto::precio))
    .map(Producto::nombre)
    .toList();                                             // terminal: produce el resultado
salida[bufanda, gorra, camiseta, mochila]
Nada se ejecuta hasta la operación terminal. Las intermedias solo montan la tubería. Si escribes un filter y no llamas a toList, count o similar, no pasa absolutamente nada: es el error más común al empezar, y no da ningún aviso.

Acabar en un número

ps.stream().filter(p -> p.stock() > 0).count();
ps.stream().mapToDouble(Producto::precio).sum();
ps.stream().mapToDouble(Producto::precio).average().orElse(0);
ps.stream().max(Comparator.comparingDouble(Producto::precio));
ps.stream().mapToDouble(Producto::precio).summaryStatistics();
salidacuántos    : 4
  total      : 91.15
  media      : 18.23
  el más caro: mochila
  resumen    : DoubleSummaryStatistics{count=5, sum=91.150000, min=9.000000, average=18.230000, max=34.000000}

mapToDouble no es decorativo: convierte a un DoubleStream, que tiene sum, average y summaryStatistics y además evita empaquetar cada número en un objeto. Fíjate en que average() devuelve un Optional, porque la media de cero elementos no existe.

Agrupar

Map<String,List<String>> porCat = ps.stream()
    .collect(Collectors.groupingBy(Producto::categoria,
             Collectors.mapping(Producto::nombre, Collectors.toList())));

Map<String,Double> gasto = ps.stream()
    .collect(Collectors.groupingBy(Producto::categoria,
             Collectors.summingDouble(Producto::precio)));
salida{viaje=[mochila, neceser], ropa=[gorra, bufanda, camiseta]}
  {viaje=49.75, ropa=41.4}
  unidos: [gorra, bufanda, mochila, neceser, camiseta]

groupingBy es, con diferencia, lo que más justifica aprender streams: eso con bucles son quince líneas y un computeIfAbsent. El segundo argumento —el «colector de abajo»— decide qué se hace con cada grupo: juntarlos en una lista, sumarlos, contarlos, quedarse con el máximo.

Y joining con tres argumentos pega las piezas con separador, prefijo y sufijo de una vez.

Quiero… Colector
Una lista .toList()
Un conjunto Collectors.toSet()
Un mapa Collectors.toMap(clave, valor)
Agrupar Collectors.groupingBy(...)
Partir en dos por una condición Collectors.partitioningBy(...)
Pegar textos Collectors.joining(", ")
Contar por grupo groupingBy(x, Collectors.counting())

Un stream se gasta

var s = ps.stream();
s.count();
s.count();   // <- aquí revienta
salidastream has already been operated upon or closed

No es una colección: es un recorrido de un solo uso. Si necesitas dos resultados, o guardas la lista y creas dos streams, o lo resuelves en una pasada con summaryStatistics o un teeing.

Cuándo no usarlos

Los streams no son más rápidos que un bucle; para colecciones pequeñas suelen ser algo más lentos, porque montan objetos. Lo que aportan es que el código diga qué se quiere en vez de cómo recorrerlo.

Dicho eso, hay tres casos en los que el bucle gana claro:

  • Cuando hay que salir a la mitad. No hay break; lo más parecido es takeWhile, y no siempre encaja.
  • Cuando el cuerpo lanza excepciones comprobadas. Las lambdas no las admiten, y acabas envolviendo todo en try dentro del map, que se lee fatal.
  • Cuando la tubería pasa de cinco o seis pasos. En ese punto un bucle con buenos nombres de variable suele ser más legible, y se puede depurar poniendo un punto de ruptura.
Y una advertencia sobre parallelStream(). Cambiar stream() por parallelStream() parece gratis y casi nunca lo es: reparte el trabajo en el grupo de hilos común de la JVM, que es compartido. Con pocos elementos sale más caro que la ganancia, y si la operación toca estado compartido, introduce una carrera. Antes de usarlo, mídelo.

Lo esencial

Para… Se escribe
Quedarse con algunos .filter(p -> ...)
Transformar cada uno .map(T::campo)
Ordenar .sorted(Comparator.comparing(...))
Quitar repetidos .distinct()
Los primeros n .limit(n)
Acabar en lista .toList()
Sumar o promediar .mapToDouble(...).sum()
Agrupar .collect(Collectors.groupingBy(...))

Todo el código de esta lección se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicarla; las salidas y los errores del compilador están copiados de esa ejecución.