Apuntes · Capítulo 12Programar con tipos

Lambdas y streams

Lambdas y streams

La semana pasada ordenamos canciones con comparadores escritos como clases: ByDuration y ByArtistThenTitle, cada una en su archivo, cada una con su implements Comparator<Song>, su @Override y su método compare. Funciona, pero cuenten las líneas: cinco o seis para decir una sola idea, “compara por duración”. Y si en un programa hacen falta diez órdenes distintos, son diez archivos.

Esta semana aprendemos a escribir ese mismo comparador en una línea. Una lambda es una forma corta de crear un objeto que implementa una interfaz con un solo método abstracto. Con lambdas y referencias a métodos (method references), pasar comportamiento como argumento se vuelve tan natural como pasar un número. Y eso abre la puerta a la segunda parte del capítulo: los streams, una forma de procesar colecciones describiendo qué queremos (filtrar, transformar, agrupar, resumir) en lugar de escribir el bucle que lo hace paso a paso.

No hay nada mágico en todo esto. Una lambda es un objeto, como cualquier otro; un stream es un objeto que recibe otros objetos (las lambdas) y los llama. Lo nuevo es la sintaxis, que deja ver la idea sin la ceremonia.

Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9), sección 4.5 Lambda Expressions (4.5.1 a 4.5.4), subsecciones 5.8.3 y 5.8.4 (clases anónimas y locales), 10.4.3 Using a Comparator, ahora completa, y la sección 10.6 Introduction the Stream API. El código del capítulo está en code/week-12 y se prueba con make test dentro del contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, 15–20 min): Cuatro implementaciones, el mismo criterio de orden y La sintaxis de una lambda.
  • En clase: en la sesión A, Ahora sí: la flecha de assertThrows, Interfaces funcionales y Comparadores compuestos y colecciones que reciben lambdas; en la sesión B, Streams, Recolectar con Collectors y Optional: un resultado que puede no existir.
  • Después de clase: Ejemplo resuelto: los artistas con más canciones, Cuándo no usar streams, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.

Del comparador como clase a la lambda

Cuatro implementaciones, el mismo criterio de orden

En la semana 11, ByDuration era una clase con nombre, en su propio archivo (ByDuration.java):

public class ByDuration implements Comparator<Song> {

    @Override
    public int compare(Song a, Song b) {
        return Integer.compare(a.seconds(), b.seconds());
    }
}

Para usarla escribíamos songs.sort(new ByDuration()). El primer paso para acortarla es la clase anónima (anonymous class): una clase sin nombre que se declara y se instancia en el mismo lugar donde se usa (ThreeWaysDemo.java):

Comparator<Song> byAnonymous = new Comparator<Song>() {
    @Override
    public int compare(Song a, Song b) {
        return Integer.compare(a.seconds(), b.seconds());
    }
};

Ya no hace falta otro archivo, pero casi todo el texto sigue siendo ceremonia. Miren qué partes son realmente información: el tipo Comparator<Song> ya lo dice la variable; que el método se llama compare lo sabe el compilador, porque Comparator tiene un solo método abstracto; y los tipos de a y b salen de Comparator<Song>. Lo único que el compilador no puede adivinar son los nombres de los parámetros y el cuerpo. Eso es una lambda:

Comparator<Song> byLambda = (a, b) -> Integer.compare(a.seconds(), b.seconds());

Y como comparar por un campo numérico es tan común, Comparator trae un static factory method, que arma el comparador a partir de la función que extrae la clave, escrita con una referencia a método:

Comparator<Song> byKey = Comparator.comparingInt(Song::seconds);
Cuatro recuadros de código, uno debajo de otro: la clase ByDuration, la clase anónima, la lambda (a, b) -> Integer.compare(a.seconds(), b.seconds()) y Comparator.comparingInt(Song::seconds). En los dos primeros, casi todo está en gris y solo los parámetros Song a, Song b y el cuerpo Integer.compare(...) están en color. Las cuatro flechas llegan a un recuadro que dice mismo criterio, Comparator<Song>, compare(a, b)Cuatro recuadros de código, uno debajo de otro: la clase ByDuration, la clase anónima, la lambda (a, b) -> Integer.compare(a.seconds(), b.seconds()) y Comparator.comparingInt(Song::seconds). En los dos primeros, casi todo está en gris y solo los parámetros Song a, Song b y el cuerpo Integer.compare(...) están en color. Las cuatro flechas llegan a un recuadro que dice mismo criterio, Comparator<Song>, compare(a, b)
Cuatro maneras de escribir el mismo criterio de orden. En color, lo esencial; en gris, la ceremonia que la lambda ya no pide. Cada una produce un objeto que implementa Comparator<Song>.

Las cuatro versiones ordenan exactamente igual. ThreeWaysDemo ordena Songs.sample() con cada una (el asterisco marca las dos canciones que duran 190 s; el orden entre ellas no cambia porque sort es estable):

$ java -cp build ThreeWaysDemo
clase:        Arena  Malecón*  Cumbre*  Brisa  Garúa  Puente de piedra
anónima:      Arena  Malecón*  Cumbre*  Brisa  Garúa  Puente de piedra
lambda:       Arena  Malecón*  Cumbre*  Brisa  Garúa  Puente de piedra
comparingInt: Arena  Malecón*  Cumbre*  Brisa  Garúa  Puente de piedra
...

Y la prueba ThreeWaysTest.java lo fija: ordena con new ByDuration() y comprueba que la lambda y comparingInt dan la misma lista.

Idea clave. Una lambda es un objeto que implementa una interfaz con un solo método abstracto. (a, b) -> ... no es “una función suelta” como en C: es una forma corta de escribir new Comparator<Song>() { ... }. Se puede guardar en una variable, pasar como argumento y devolver desde un método, como cualquier otro objeto.

La sintaxis de una lambda

Una lambda tiene parámetros, una flecha -> y un cuerpo:

Forma Ejemplo
Un parámetro, sin paréntesis s -> s.seconds() > 200
Varios parámetros (a, b) -> a + b
Sin parámetros () -> new ArrayList<>()
Con tipos explícitos (opcional) (Song a, Song b) -> Integer.compare(a.seconds(), b.seconds())
Cuerpo con llaves (bloque) s -> { int m = s.seconds() / 60; return m + " min"; }

Si el cuerpo es una sola expresión, su valor es lo que la lambda devuelve; no se escribe return. Si el cuerpo es un bloque entre llaves, se escribe como el cuerpo de cualquier método, con return si devuelve algo.

¿Cómo sabe el compilador qué interfaz implementa una lambda? Por el contexto. La misma lambda (a, b) -> a + b puede ser un BinaryOperator<Integer> o un Comparator<Integer> (aunque como comparador no tenga sentido): depende del tipo de la variable a la que se asigna o del parámetro al que se pasa. A ese tipo se le llama target type: el tipo que espera el lugar donde aparece la lambda. Por eso var f = (String s) -> s.length(); no compila (cannot infer type for local variable f, lambda expression needs an explicit target-type): sin target type, el compilador no sabe qué objeto crear.

Ahora sí: la flecha de assertThrows

Desde la semana 3 escribimos pruebas como esta, con una flecha que usamos como receta:

assertThrows(NoSuchElementException.class, () -> s.pop());

Ahora podemos leerla completa. El segundo parámetro de assertThrows es de tipo Executable, una interfaz funcional de JUnit con un solo método, void execute() throws Throwable. La flecha () -> s.pop() es una lambda sin parámetros que implementa Executable. assertThrows recibe ese objeto, llama a su execute() dentro de un try y verifica que salga la excepción esperada. Si escribiéramos assertThrows(..., s.pop()) sin la flecha, pop() se ejecutaría antes de llamar a assertThrows, y la excepción escaparía de la prueba sin que nadie la capture.

En LibraryReportsTest.java la lambda se guarda primero en una variable, para que se vea que es un objeto:

Executable getFromEmpty = () -> LibraryReports.longest(List.of()).orElseThrow();
assertThrows(NoSuchElementException.class, getFromEmpty);

En Java. El compilador no traduce una lambda a una clase anónima. Si miran build/ después de make, la clase anónima de ThreeWaysDemo generó un archivo ThreeWaysDemo$1.class, pero las lambdas no generaron ninguno. En su lugar, javap -c muestra una instrucción invokedynamic:

$ javap -c -p -cp build ThreeWaysDemo | grep invokedynamic
        16: invokedynamic #60,  0             // InvokeDynamic #0:compare:()Ljava/util/Comparator;
        22: invokedynamic #64,  0             // InvokeDynamic #1:applyAsInt:()Ljava/util/function/ToIntFunction;
...

El cuerpo de la lambda queda como un método privado de ThreeWaysDemo, e invokedynamic entrega un objeto que implementa Comparator. Para nosotros el resultado es el mismo: un objeto con un método compare. Lo que Java no garantiza es su identidad: si cada vez que se evalúa la lambda sale un objeto nuevo o se reutiliza uno, y en qué momento se crea, lo decide la JVM (JLS §15.27.4). Por eso no tiene sentido comparar lambdas con ==.

Interfaces funcionales

Un solo método abstracto

Una interfaz funcional (functional interface) es una interfaz con exactamente un método abstracto. Puede tener además métodos default y static (como Comparator, que tiene reversed, thenComparing, comparingInt y muchos más), pero abstracto, uno solo. Solo las interfaces funcionales se pueden implementar con una lambda: si hubiera dos métodos abstractos, la lambda no sabría cuál es.

Podemos escribir las nuestras. La anotación @FunctionalInterface es opcional, pero le pide al compilador que verifique que de verdad hay un solo método abstracto (FunctionsDemo.java):

@FunctionalInterface
interface Discount {
    int apply(int cents);
}

Discount tenPercent = cents -> cents - cents / 10;
Discount minusFive = cents -> Math.max(0, cents - 500);

Si alguien agrega un segundo método abstracto a Discount (digamos, int undo(int cents)), el compilador rechaza la interfaz misma, en lugar de dar errores sueltos en cada lambda que la usa:

error: Unexpected @FunctionalInterface annotation
    @FunctionalInterface
    ^
  Discount is not a functional interface
    multiple non-overriding abstract methods found in interface Discount

Las interfaces de java.util.function

Casi nunca hace falta escribir la interfaz. El paquete java.util.function trae las formas más comunes, y la API de colecciones y de streams las usa todas:

Interfaz Método Recibe → devuelve Para qué
Predicate<T> test T → boolean una condición: filtrar
Function<T, R> apply T → R transformar un valor en otro
Consumer<T> accept T → nada hacer algo con el valor (imprimir)
Supplier<T> get nada → T fabricar un valor
UnaryOperator<T> apply T → T transformar sin cambiar de tipo
BinaryOperator<T> apply T, T → T combinar dos valores (sumar)

Para los primitivos hay versiones sin boxing: IntPredicate, ToIntFunction<T>, IntBinaryOperator y otras. Comparator.comparingInt recibe una ToIntFunction<Song>: por eso Song::seconds, que devuelve un int, encaja sin convertir a Integer.

FunctionsDemo usa cada una con canciones:

Predicate<Song> isLong = s -> s.seconds() > 200;
Function<Song, String> label = s -> s.artist() + " - " + s.title();
Function<String, Integer> length = String::length;
Consumer<String> show = IO::println;
Supplier<List<Song>> newList = ArrayList::new;
UnaryOperator<String> shout = String::toUpperCase;
BinaryOperator<Integer> add = Integer::sum;

Song garua = Songs.sample().get(0);
show.accept("isLong(Garúa) = " + isLong.test(garua));
show.accept("negate        = " + isLong.negate().test(garua));
show.accept("label         = " + label.apply(garua));
show.accept("andThen       = " + label.andThen(length).apply(garua));
$ java -cp build FunctionsDemo
isLong(Garúa) = true
negate        = false
label         = Costa Verde - Garúa
andThen       = 19
shout         = GARÚA
add(185, 210) = 395
largas        = 3
descuentos    = 1800 1500

Fíjense en negate y andThen: son métodos default de Predicate y de Function que combinan lambdas y devuelven una nueva. isLong.negate() es el predicado contrario; label.andThen(length) es una función que primero arma la etiqueta y luego mide su largo (“Costa Verde - Garúa” tiene 19 caracteres). Con funciones como objetos, se pueden construir funciones nuevas a partir de otras.

Referencias a métodos

Muchas lambdas solo llaman a un método que ya existe: s -> s.title(), x -> IO.println(x), () -> new ArrayList<>(). Para esos casos hay una forma todavía más corta, la referencia a método (method reference), con ::. Hay cuatro tipos:

Tipo Referencia Lambda equivalente
Método estático Integer::sum (a, b) -> Integer.sum(a, b)
Método estático IO::println x -> IO.println(x)
De instancia, el objeto es el primer parámetro Song::title s -> s.title()
De instancia, el objeto es el primer parámetro String::length s -> s.length()
De instancia de un objeto concreto "Duna"::equals x -> "Duna".equals(x)
Constructor ArrayList::new () -> new ArrayList<>()

El caso que más confunde es el tercero: Song::title no nombra una canción concreta. Es una función que recibe una canción y le pide su título. Por eso encaja en una Function<Song, String>. En cambio "Duna"::equals ya tiene su objeto ("Duna") y recibe el otro como parámetro.

Idea clave. Usen la referencia a método cuando la lambda solo pasa sus parámetros a un método que ya existe; es más corta y dice lo mismo. Cuando la lambda hace algo más (combina campos, compara, calcula), escriban la lambda: s -> s.seconds() > 200 no tiene referencia a método equivalente.

Variables capturadas: effectively final

Una lambda puede usar variables locales del método donde se escribe:

int limit = 200;
Predicate<Song> isLong = s -> s.seconds() > limit;

La lambda captura el valor de limit. Pero hay una regla: la variable capturada tiene que ser final o effectively final, es decir, que nunca se le asigne otro valor después de inicializarla. Esto no compila:

List<Song> songs = Songs.sample();
int count = 0;
songs.forEach(s -> {
    if (s.seconds() > 200) {
        count++;
    }
});
$ javac CountDemo.java
CountDemo.java:9: error: local variables referenced from a lambda expression must be final or effectively final
                count++;
                ^
1 error

¿Por qué la regla? La lambda es un objeto que puede vivir más que el método que la creó (se puede guardar en un campo y llamar mañana, o desde otro hilo). Java copia el valor de count dentro de la lambda cuando la crea; si después se pudiera cambiar, la copia y la variable original dirían cosas distintas. Prohibir la reasignación evita esa confusión. (Los campos de un objeto sí se pueden modificar desde una lambda, porque lo que se captura es this, que no cambia; pero eso casi siempre es un efecto secundario que conviene evitar, como veremos con los streams.)

Error común. Buscar un truco para modificar el contador (un arreglo int[] count = {0} y count[0]++ dentro de la lambda). Compila, pero es pelear contra el lenguaje. Para contar, el stream ya tiene la operación:

long count = songs.stream().filter(s -> s.seconds() > 200).count();

O, si prefieren, un for-each común, donde count++ no tiene ningún problema.

Comparadores compuestos y colecciones que reciben lambdas

comparing, thenComparing, reversed

En la semana 11, ordenar por dos criterios obligaba a escribir un compare con un if en el medio. Comparator tiene métodos que arman esos comparadores a partir de las claves:

  • Comparator.comparing(f): compara por la clave que devuelve f (comparingInt si la clave es un int).
  • c.thenComparing(g): si c dice que empatan, desempata con la clave de g.
  • c.reversed(): el orden contrario.
  • Comparator.reverseOrder(), Comparator.naturalOrder(): los órdenes naturales al derecho y al revés.

En ThreeWaysDemo:

songs.sort(Comparator.comparing(Song::artist)
        .thenComparing(Song::seconds, Comparator.reverseOrder()));
songs.forEach(s -> IO.println("  " + s.artist() + " - " + s.title() + " (" + s.seconds() + ")"));

songs.sort(Comparator.comparing(Song::title).reversed());
IO.println("título, al revés: " + titles(songs));
artista, luego duración descendente:
  Costa Verde - Garúa (220)
  Costa Verde - Malecón (190)
  Duna - Brisa (210)
  Duna - Arena (185)
  Los Andinos - Puente de piedra (245)
  Los Andinos - Cumbre (190)
título, al revés: Puente de piedra  Malecón*  Garúa  Cumbre*  Brisa  Arena

La primera línea se lee casi en castellano: “comparando por artista y, luego, por duración en orden inverso”. Cada artista sale en orden alfabético, y dentro de cada artista, de la canción más larga a la más corta.

Error común. reversed() invierte todo el comparador al que se aplica, no solo el último criterio. comparing(Song::artist) .thenComparing(Song::seconds).reversed() ordena los artistas de la Z a la A y las duraciones de mayor a menor. Si solo quieren invertir el segundo criterio, pásenle el orden inverso a ese criterio, como en el ejemplo: thenComparing(Song::seconds, Comparator.reverseOrder()).

Métodos de las colecciones que reciben lambdas

Las colecciones también aprendieron a recibir comportamiento. Cuatro métodos que reemplazan bucles enteros de la semana 11 (CollectionLambdasDemo.java):

List<Song> songs = Songs.sample();
songs.removeIf(s -> s.seconds() < 200);    // sin iterador ni ConcurrentModificationException
songs.forEach(s -> IO.println("queda: " + s.title()));

Map<String, Integer> counts = new TreeMap<>();
for (String w : "la garúa cae en lima en lima la garúa no moja".split(" ")) {
    counts.merge(w, 1, Integer::sum);
}
IO.println(counts);

Map<String, List<String>> byArtist = new TreeMap<>();
for (Song s : Songs.sample()) {
    byArtist.computeIfAbsent(s.artist(), k -> new ArrayList<>()).add(s.title());
}
byArtist.forEach((artist, titles) -> IO.println(artist + " -> " + titles));
$ java -cp build CollectionLambdasDemo
queda: Garúa
queda: Puente de piedra
queda: Brisa
{cae=1, en=2, garúa=2, la=2, lima=2, moja=1, no=1}
Costa Verde -> [Garúa, Malecón]
Duna -> [Arena, Brisa]
Los Andinos -> [Puente de piedra, Cumbre]
  • removeIf(predicado) borra los elementos que cumplen la condición. Es lo que en la semana 11 hacíamos con un Iterator y it.remove(), sin el riesgo de olvidarse del iterador.
  • forEach(consumidor) aplica la acción a cada elemento. En un Map, el consumidor recibe la clave y el valor (es un BiConsumer).
  • merge(clave, valor, función): si la clave no está, guarda el valor; si está, guarda función(viejo, valor). Con Integer::sum es el contador de palabras de la semana 11 en una línea: ya no hace falta getOrDefault(w, 0) + 1.
  • computeIfAbsent(clave, función): si la clave no está, calcula el valor con la función, lo guarda y lo devuelve; si está, devuelve el que había. Es el if (where == null) { where = new TreeSet<>(); index.put(...); } del índice de palabras, sin el if.

Streams

Un pipeline, no una colección

Supongan que queremos los títulos de las canciones de más de 200 segundos, en orden alfabético. Con un bucle:

List<String> longTitles = new ArrayList<>();
for (Song s : songs) {
    if (s.seconds() > 200) {
        longTitles.add(s.title());
    }
}
Collections.sort(longTitles);

Con un stream (StreamDemo.java):

List<String> longTitles = songs.stream()       // fuente
        .filter(s -> s.seconds() > 200)       // intermedia
        .map(Song::title)                      // intermedia
        .sorted()                              // intermedia
        .toList();                             // terminal
largas: [Brisa, Garúa, Puente de piedra]

Un stream es una secuencia de elementos que pasa por un pipeline de operaciones, una cadena de etapas. Todo pipeline tiene tres partes:

  1. Una fuente: de dónde salen los elementos. list.stream(), Stream.of(a, b, c), IntStream.range(0, 10).
  2. Cero o más operaciones intermedias: cada una recibe un stream y devuelve otro. filter deja pasar solo los que cumplen un predicado; map transforma cada elemento con una función; sorted, distinct y limit hacen lo que dicen.
  3. Una operación terminal: consume el stream y produce un resultado (una lista, un número, un Optional) o un efecto (forEach).

Un stream no es una colección. No guarda los elementos: los toma de la fuente, los hace pasar por las operaciones y los entrega al final. No se puede pedir el elemento 3 de un stream, ni recorrerlo dos veces.

Tipo Operaciones Devuelve
Fuente list.stream(), Stream.of(...), IntStream.range(a, b), IntStream.rangeClosed(a, b) un stream
Intermedia filter, map, mapToInt, sorted, distinct, limit, skip otro stream
Terminal toList, collect, count, sum, min, max, findFirst, anyMatch, allMatch, reduce, forEach un resultado

Algunos ejemplos más de StreamDemo:

long dunas = songs.stream().filter(s -> s.artist().equals("Duna")).count();
int total = songs.stream().mapToInt(Song::seconds).sum();
double average = songs.stream().mapToInt(Song::seconds).average().orElse(0);
boolean anyShort = songs.stream().anyMatch(s -> s.seconds() < 180);
List<String> artists = songs.stream().map(Song::artist).distinct().sorted().toList();
Duna: 2, total: 1240 s, promedio: 206.66666666666666 s
¿alguna de menos de 3 min? false; artistas: [Costa Verde, Duna, Los Andinos]

Idea clave. Con un bucle se escribe cómo se hace: crear la lista, recorrer, preguntar, agregar, ordenar. Con un stream se escribe qué se quiere: de las canciones, las largas, sus títulos, ordenados, en una lista. Cada operación hace una sola cosa y tiene un nombre.

Lazy: nada corre hasta la terminal

Los streams son lazy: las operaciones intermedias no hacen nada cuando se las llama. Solo arman el pipeline. El trabajo empieza cuando llega la operación terminal, y entonces los elementos pasan de uno en uno por todas las etapas: el primero llega hasta el final (o hasta que un filter lo descarta) antes de que salga el segundo. LazyDemo.java lo hace visible con lambdas que imprimen algo cada vez que se ejecutan:

Stream<String> pipeline = songs.stream()
        .filter(s -> {
            IO.println("  filter " + s.title());
            return s.seconds() < 215;
        })
        .map(s -> {
            IO.println("  map    " + s.title());
            return s.title().toUpperCase();
        });
IO.println("pipeline armado, sin operación terminal todavía");

List<String> firstTwo = pipeline.limit(2).toList();
IO.println("resultado: " + firstTwo);
$ java -cp build LazyDemo
pipeline armado, sin operación terminal todavía
  filter Garúa
  filter Puente de piedra
  filter Malecón
  map    Malecón
  filter Arena
  map    Arena
resultado: [MALECÓN, ARENA]

Tres cosas para mirar en esta salida:

  • La primera línea aparece antes que cualquier filter: armar el pipeline no ejecutó ninguna lambda.
  • filter y map se intercalan: Malecón pasa el filtro e inmediatamente se transforma, antes de que se lea Arena. No hay una “lista intermedia” con todas las canciones filtradas.
  • Cumbre y Brisa nunca se leen. Cuando limit(2) ya tiene dos elementos, el pipeline se detiene. Con un millón de canciones, el trabajo sería el mismo.
Animación: arriba, el pipeline songs.stream().filter(s -> s.seconds() < 215).map(...toUpperCase()).limit(2).toList(). A la izquierda, las seis canciones de la fuente; en el centro, tres etapas: filter, map y limit(2) con un contador; a la derecha, la lista del resultado. Garúa (220) llega a filter y cae descartada; lo mismo Puente de piedra (245). Malecón (190) pasa filter, se convierte en MALECÓN en map, pasa limit (1 de 2) y entra a la lista; lo mismo Arena (185), que deja limit en 2 de 2. El pipeline se detiene y Cumbre y Brisa quedan marcadas como nunca leídas. Abajo se va escribiendo lo que imprime LazyDemoAnimación: arriba, el pipeline songs.stream().filter(s -> s.seconds() < 215).map(...toUpperCase()).limit(2).toList(). A la izquierda, las seis canciones de la fuente; en el centro, tres etapas: filter, map y limit(2) con un contador; a la derecha, la lista del resultado. Garúa (220) llega a filter y cae descartada; lo mismo Puente de piedra (245). Malecón (190) pasa filter, se convierte en MALECÓN en map, pasa limit (1 de 2) y entra a la lista; lo mismo Arena (185), que deja limit en 2 de 2. El pipeline se detiene y Cumbre y Brisa quedan marcadas como nunca leídas. Abajo se va escribiendo lo que imprime LazyDemo
Los elementos recorren el pipeline de uno en uno. Cuando limit se llena, el pipeline se detiene: Cumbre y Brisa no se leen nunca.

Error común. Escribir un pipeline sin operación terminal y esperar que haga algo. songs.stream().map(s -> { IO.println(s.title()); return s; }); no imprime nada: sin terminal, ninguna lambda se ejecuta.

Un stream se recorre una sola vez

Como no guarda los elementos, un stream que ya llegó a su terminal está agotado:

Stream<Song> once = songs.stream();
IO.println("primera vez: " + once.count());
IO.println("segunda vez: " + once.count());
primera vez: 6
java.lang.IllegalStateException: stream has already been operated upon or closed

Por eso casi nunca se guarda un stream en una variable: se crea desde la fuente, se encadena y se consume en la misma expresión. Si hace falta recorrer dos veces, se vuelve a llamar a songs.stream().

Streams de primitivos

Stream<Integer> guarda objetos Integer: cada número pasa por boxing. Para sumas y promedios, los streams de primitivos (IntStream, LongStream, DoubleStream) trabajan con int directamente y traen operaciones numéricas que Stream<T> no tiene: sum, average, max numérico, range.

int total = songs.stream().mapToInt(Song::seconds).sum();
int sumOfSquares = IntStream.rangeClosed(1, 4).map(n -> n * n).sum();
cuadrados de 1 a 4, sumados = 30; reduce = 585

mapToInt convierte un Stream<Song> en un IntStream. average() devuelve un OptionalDouble, porque el promedio de una lista vacía no existe; por eso en StreamDemo va seguido de .orElse(0).

reduce: combinar todos los elementos en uno

sum, count y max son casos particulares de una operación general, reduce, que combina los elementos de dos en dos con una función:

Integer reduced = Stream.of(185, 210, 190).reduce(0, Integer::sum);

El 0 es el valor inicial (el resultado si el stream está vacío) y Integer::sum la forma de combinar: ((0 + 185) + 210) + 190 = 585. En la práctica se usa poco: para sumar está sum, para contar count y para juntar en una colección, collect.

Recolectar con Collectors

toList, joining, toMap

La operación terminal más flexible es collect, que recibe un collector: una receta para juntar los elementos en un resultado. La clase Collectors trae los más usados:

  • toList() ya existe directamente en Stream (desde Java 16) y devuelve una lista no modificable. Collectors.toList() todavía existe y devuelve una que sí se puede modificar, pero sin garantías.
  • Collectors.joining(", ") une cadenas con un separador.
  • Collectors.toMap(clave, valor) arma un mapa con una entrada por elemento. Si dos elementos dan la misma clave, lanza IllegalStateException, salvo que se le pase una función para combinar los valores repetidos.

En ReportDemo.java:

Map<String, Integer> secondsByTitle = Songs.sample().stream()
        .collect(Collectors.toMap(Song::title, Song::seconds, Integer::sum, TreeMap::new));
toMap:            {Arena=185, Brisa=210, Cumbre=190, Garúa=220, Malecón=190, Puente de piedra=245}

Los dos últimos argumentos son opcionales: Integer::sum dice qué hacer si un título se repite (sumar las duraciones) y TreeMap::new, una referencia a constructor, dice qué clase de mapa crear. Sin ella, toMap usa un HashMap y el orden al imprimir no está especificado.

groupingBy: agrupar y resumir

El collector más útil para reportes es groupingBy. Recibe una función que calcula una clave para cada elemento y reparte los elementos en grupos, uno por clave. Sin más argumentos, cada grupo es una lista: groupingBy(Song::artist) da un Map<String, List<Song>>. Con un segundo collector, cada grupo se resume: counting() cuenta los elementos del grupo, summingInt(f) suma una clave numérica, mapping(f, toList()) guarda solo una parte de cada elemento.

LibraryReports.java usa los dos:

public static Map<String, Integer> totalDurationByArtist(List<Song> songs) {
    return songs.stream()
            .collect(Collectors.groupingBy(Song::artist, TreeMap::new,
                    Collectors.summingInt(Song::seconds)));
}

public static Map<String, Long> songsPerArtist(List<Song> songs) {
    return songs.stream()
            .collect(Collectors.groupingBy(Song::artist, TreeMap::new, Collectors.counting()));
}
Animación: arriba, la expresión songs.stream().collect(groupingBy(Song::artist, TreeMap::new, summingInt(Song::seconds))). A la izquierda, las seis canciones de la lista con su duración y su artista. Una por una viajan al bucket de su artista: Garúa y Malecón a Costa Verde, Arena y Brisa a Duna, Puente de piedra y Cumbre a Los Andinos. Al final, cada bucket muestra sus duraciones sumadas: 410, 395 y 435, y abajo el mapa resultante {Costa Verde=410, Duna=395, Los Andinos=435}Animación: arriba, la expresión songs.stream().collect(groupingBy(Song::artist, TreeMap::new, summingInt(Song::seconds))). A la izquierda, las seis canciones de la lista con su duración y su artista. Una por una viajan al bucket de su artista: Garúa y Malecón a Costa Verde, Arena y Brisa a Duna, Puente de piedra y Cumbre a Los Andinos. Al final, cada bucket muestra sus duraciones sumadas: 410, 395 y 435, y abajo el mapa resultante {Costa Verde=410, Duna=395, Los Andinos=435}
groupingBy reparte los elementos en buckets según la clave; el segundo collector resume cada bucket. Con TreeMap::new, las claves salen en orden alfabético.

El TreeMap::new del medio no es decoración. Sin él, groupingBy usa un HashMap, y el orden de las claves al imprimir no está especificado: puede cambiar con los datos o con la versión de Java. Para un reporte que se imprime, o para una prueba que compara texto, conviene un orden fijo.

Ejemplo resuelto: los artistas con más canciones

Queremos los n artistas con más canciones de la biblioteca, y si dos empatan, en orden alfabético. Lo armamos en dos pasos: primero contamos con songsPerArtist, y después ordenamos las entradas de ese mapa:

public static List<String> topArtists(List<Song> songs, int n) {
    return songsPerArtist(songs).entrySet().stream()
            .sorted(Map.Entry.<String, Long>comparingByValue().reversed()
                    .thenComparing(Map.Entry.comparingByKey()))
            .limit(n)
            .map(Map.Entry::getKey)
            .toList();
}

Paso a paso:

  1. songsPerArtist(songs).entrySet().stream(): un stream de pares artista–cantidad.
  2. sorted(...): por cantidad de mayor a menor (comparingByValue().reversed()) y, si empatan, por artista (thenComparing(comparingByKey())). El <String, Long> explícito le dice al compilador los tipos de la entrada, que no puede deducir antes de llegar a .reversed().
  3. limit(n): los primeros n.
  4. map(Map.Entry::getKey): de cada entrada, solo el artista.

ReportDemo corre todos los reportes sobre Songs.library(), una biblioteca de doce canciones de cinco artistas:

$ java -cp build ReportDemo
canciones:        12
duración total:   {Costa Verde=785, Duna=625, Lago Azul=205, Los Andinos=695, Río Verde=240}
por artista:      {Costa Verde=4, Duna=3, Lago Azul=1, Los Andinos=3, Río Verde=1}
top 3 artistas:   [Costa Verde, Duna, Los Andinos]
la más larga:     Quebrada
de Duna:          Arena, Brisa, Oasis
lista vacía:      Optional.empty
toMap:            {Arena=185, Brisa=210, Cumbre=190, Garúa=220, Malecón=190, Puente de piedra=245}

Duna y Los Andinos empatan con tres canciones; el desempate alfabético pone a Duna primero. La prueba topArtistsBreaksTiesAlphabetically de LibraryReportsTest.java fija justamente ese caso.

Optional: un resultado que puede no existir

¿Cuál es la canción más larga de una lista vacía? No hay respuesta. En la semana 11, un método así habría devuelto null, y quien lo llamara tendría que acordarse de verificarlo. Las operaciones de stream que pueden no tener resultado (max, min, findFirst, findAny, reduce sin valor inicial) devuelven un Optional<T>: una caja que contiene un valor o está vacía.

public static Optional<Song> longest(List<Song> songs) {
    return songs.stream().max(Comparator.comparingInt(Song::seconds));
}

El tipo de retorno ya avisa: “puede que no haya canción”. Para sacar el valor, quien llama tiene que decidir qué hacer si la caja está vacía:

Método Si hay valor Si está vacío
orElse(x) el valor x
orElseThrow() el valor lanza NoSuchElementException
map(f) Optional con f(valor) Optional vacío
isPresent() / isEmpty() true / false false / true
ifPresent(acción) ejecuta la acción no hace nada

En StreamDemo:

Optional<Song> longest = songs.stream().max((a, b) -> Integer.compare(a.seconds(), b.seconds()));
IO.println("la más larga: " + longest.map(Song::title).orElse("(ninguna)"));
Optional<Song> none = songs.stream().filter(s -> s.seconds() > 600).findFirst();
IO.println("¿más de 10 min? " + none.isPresent() + " -> " + none.map(Song::title).orElse("(ninguna)"));
la más larga: Puente de piedra
¿más de 10 min? false -> (ninguna)
Dos filas. Arriba, songs.stream() pasa por max(…) y da una caja Optional<Song> llena con la canción Puente de piedra; .map(Song::title).orElse("(ninguna)") la convierte en el texto "Puente de piedra". Abajo, filter(> 600) pasa por findFirst() y da una caja Optional<Song> vacía, con borde punteado; la misma cadena .map(...).orElse(...) da "(ninguna)". Al pie: get() sobre la caja vacía lanza NoSuchElementExceptionDos filas. Arriba, songs.stream() pasa por max(…) y da una caja Optional<Song> llena con la canción Puente de piedra; .map(Song::title).orElse("(ninguna)") la convierte en el texto "Puente de piedra". Abajo, filter(> 600) pasa por findFirst() y da una caja Optional<Song> vacía, con borde punteado; la misma cadena .map(...).orElse(...) da "(ninguna)". Al pie: get() sobre la caja vacía lanza NoSuchElementException
Optional es una caja llena o vacía. map transforma lo que haya adentro, y orElse da un valor por defecto cuando no hay nada.

map sobre un Optional funciona como map sobre un stream de cero o un elemento: si hay canción, la transforma en su título; si no hay, la caja sigue vacía. Así se puede encadenar sin preguntar en cada paso si el valor existe.

Error común. Llamar a get() sin verificar. Optional.get() (igual que orElseThrow()) lanza NoSuchElementException si la caja está vacía: es el NullPointerException de siempre con otro nombre. Si están seguros de que hay valor, escriban orElseThrow(), que dice lo mismo pero deja claro que pensaron en el caso vacío; si no lo están, usen orElse o map. Tampoco usen Optional para campos ni parámetros: está pensado para valores de retorno.

Cuándo no usar streams

Los streams son una herramienta, no una obligación. Dos maneras de usarlos mal:

Error común. Efectos secundarios dentro del pipeline: modificar una variable o una colección externa desde map, filter o forEach.

List<String> titles = new ArrayList<>();
songs.stream().filter(s -> s.seconds() > 200).forEach(s -> titles.add(s.title()));  // no

Funciona, pero esconde lo que se está construyendo y se rompe si el stream se vuelve paralelo. La operación terminal tiene que producir el resultado: List<String> titles = songs.stream().filter(...).map(Song::title).toList();. Las lambdas de filter y map, en particular, solo deberían calcular, nunca modificar nada.

Error común. Reescribir con streams un bucle que era más claro como bucle. Un recorrido con dos acumuladores que se actualizan juntos, un bucle que se detiene a la mitad con break según un estado que va cambiando, o un algoritmo con índices (comparar a[i] con a[i + 1]) son casos donde el bucle se lee mejor. La pregunta no es “¿se puede con un stream?” (casi siempre se puede), sino “¿cuál versión entiende más rápido quien la lee?”.

En el laboratorio van a escribir cada reporte de las dos maneras, con la misma prueba para ambas, y a discutir cuál es más legible. En los reportes de agrupar y contar, el stream suele ganar; en otros, no.

El código de la semana y make test

code/week-12 tiene Song, ByDuration, ByArtistThenTitle, Songs y ComparatorsTest copiados de la semana 11 sin cambios (más Songs.library(), la biblioteca grande), las demostraciones de este capítulo, LibraryReports con sus pruebas, y en lab/ el código inicial del laboratorio: el record Sale, las ventas de ejemplo de una cafetería y un esqueleto de prueba.

$ make test
mkdir -p build
javac -Xlint:all -Werror -cp /opt/junit/junit.jar -d build ...
== ThreeWaysDemo
...
== ReportDemo
...
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│  ├─ SalesReportsTest ✔
│  │  └─ sampleDataHasTwelveSales() ✔
│  ├─ LibraryReportsTest ✔
│  │  ├─ longestOfEmptyListIsEmpty() ✔
│  │  ├─ titlesByUnknownArtistIsEmptyText() ✔
│  │  ├─ topArtistsBreaksTiesAlphabetically() ✔
│  │  ├─ totalDurationByArtistAddsEverySong() ✔
│  │  └─ theArrowOfAssertThrowsIsALambda() ✔
│  ├─ ThreeWaysTest ✔
│  │  ├─ reversedTurnsTheOrderAround() ✔
│  │  └─ lambdaAndComparingIntAgreeWithByDuration() ✔
│  └─ ComparatorsTest ✔
│     ├─ byDurationSortsShortestFirstAndKeepsTiesInOrder() ✔
│     ├─ treeSetWithByDurationDropsSongsWithTheSameDuration() ✔
│     └─ byArtistThenTitleBreaksTiesByTitle() ✔
└─ JUnit Vintage ✔
...
[        11 tests successful      ]
[         0 tests failed          ]

ComparatorsTest es la prueba de la semana 11, sin tocar. En el laboratorio la van a usar como red de seguridad: reescriben los comparadores con lambdas y la prueba tiene que seguir pasando con los mismos valores esperados.

Resumen

  • Una lambda es una forma corta de crear un objeto que implementa una interfaz funcional: una interfaz con un solo método abstracto. Solo se escriben los parámetros y el cuerpo; el resto lo deduce el compilador del target type.
  • Clase con nombre, clase anónima, lambda y un static factory method como Comparator.comparingInt producen el mismo tipo de objeto.
  • La flecha de assertThrows(..., () -> ...) es una lambda que implementa Executable; por eso el código se ejecuta dentro de assertThrows y no antes.
  • java.util.function trae las interfaces comunes: Predicate, Function, Consumer, Supplier, UnaryOperator, BinaryOperator, y sus versiones para primitivos.
  • Las referencias a métodos (Song::title, Integer::sum, ArrayList::new) reemplazan a las lambdas que solo llaman a un método.
  • Una lambda solo puede capturar variables locales effectively final.
  • Comparator.comparing, thenComparing y reversed arman comparadores compuestos; removeIf, forEach, merge y computeIfAbsent reemplazan bucles sobre colecciones.
  • Un stream es un pipeline: fuente, operaciones intermedias y una terminal. Es lazy (nada corre hasta la terminal, y los elementos pasan de uno en uno), no guarda los elementos y se recorre una sola vez.
  • mapToInt, IntStream y compañía evitan el boxing en cálculos numéricos.
  • collect con Collectors junta el resultado: toList, joining, toMap, y groupingBy con counting o summingInt para reportes. Con TreeMap::new el orden de las claves es fijo.
  • Optional representa un resultado que puede no existir; se abre con orElse, orElseThrow o map, nunca con get() a ciegas.
  • Sin efectos secundarios dentro del pipeline, y sin reemplazar por streams los bucles que se leen mejor como bucles.

Ejercicios

  1. ¿Qué interfaz funcional de java.util.function implementa cada una de estas lambdas? s -> s.length() > 3, String::length, () -> "hola", s -> IO.println(s), (a, b) -> a * b (con a y b enteros). ¿Alguna podría implementar más de una? ¿Qué decide cuál?

  2. Escriban ByArtistThenTitle de la semana 11 primero como clase anónima y después como lambda, sin usar Comparator.comparing. Luego escríbanlo con comparing y thenComparing. Verifiquen con ComparatorsTest que las tres versiones dan el mismo orden.

  3. Sin ejecutarlo, digan qué imprime este fragmento y en qué orden. Después compruébenlo en jshell (con /open Song.java y /open Songs.java):

    Songs.sample().stream()
        .filter(s -> { IO.println("f " + s.title()); return s.artist().equals("Duna"); })
        .map(s -> { IO.println("m " + s.title()); return s.seconds(); })
        .findFirst();
  4. Este código no compila. Expliquen por qué y escriban dos versiones que sí compilen: una con un for-each y otra con un stream.

    int total = 0;
    Songs.sample().forEach(s -> total += s.seconds());
  5. Escriban con un stream un Map<String, List<String>> de artista a la lista de sus títulos, ordenado por artista. (Pista: groupingBy con un tercer argumento mapping(Song::title, toList()).) Comparen con la versión de computeIfAbsent de CollectionLambdasDemo.

  6. Escriban un método Optional<Song> shortestBy(List<Song> songs, String artist) que devuelva la canción más corta de un artista. Escriban sus pruebas, incluido el caso de un artista sin canciones. ¿Por qué no conviene devolver null en ese caso?

  7. Collectors.toMap(Song::artist, Song::title) sobre Songs.sample() lanza una excepción. ¿Cuál y por qué? Corríjanlo de dos maneras: con una función de combinación que una los títulos con " / " y con groupingBy.

  8. Reescriban con streams el contador de palabras de la semana 11 (WordCount.count). ¿Qué versión les parece más clara: el bucle con getOrDefault, el bucle con merge o el stream con groupingBy y counting? Justifiquen.

  9. Expliquen la diferencia entre comparing(Song::artist).thenComparing(Song::seconds).reversed() y comparing(Song::artist).thenComparing(Song::seconds, Comparator.reverseOrder()). Escriban una prueba con Songs.sample() que distinga los dos.

  10. Den un ejemplo de un bucle que, en su opinión, se lee peor escrito con streams. Escriban las dos versiones y justifiquen.

  11. IntStream.range(0, 5).map(i -> i * i).sum() y Stream.of(0, 1, 2, 3, 4).map(i -> i * i).reduce(0, Integer::sum) dan lo mismo. ¿Qué diferencia hay entre los dos en lo que ocurre con cada número? ¿Cuál preferirían para sumar un millón de elementos?

  12. Lean la subsección 10.6.4 de Eck, An Experiment. ¿Qué conclusión saca sobre la velocidad de los streams frente a los bucles? ¿Cambia en algo lo que se dijo en este capítulo sobre cuándo usar cada uno?