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-12y se prueba conmake testdentro 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 conCollectorsyOptional: 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);
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 escribirnew 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 demake, la clase anónima deThreeWaysDemogeneró un archivoThreeWaysDemo$1.class, pero las lambdas no generaron ninguno. En su lugar,javap -cmuestra una instruccióninvokedynamic:$ 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, einvokedynamicentrega un objeto que implementaComparator. Para nosotros el resultado es el mismo: un objeto con un métodocompare. 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() > 200no 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}ycount[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 devuelvef(comparingIntsi la clave es unint).c.thenComparing(g): sicdice que empatan, desempata con la clave deg.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 unIteratoryit.remove(), sin el riesgo de olvidarse del iterador.forEach(consumidor)aplica la acción a cada elemento. En unMap, el consumidor recibe la clave y el valor (es unBiConsumer).merge(clave, valor, función): si la clave no está, guarda el valor; si está, guardafunción(viejo, valor). ConInteger::sumes el contador de palabras de la semana 11 en una línea: ya no hace faltagetOrDefault(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 elif (where == null) { where = new TreeSet<>(); index.put(...); }del índice de palabras, sin elif.
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:
- Una fuente: de dónde salen los elementos.
list.stream(),Stream.of(a, b, c),IntStream.range(0, 10). - Cero o más operaciones intermedias: cada una recibe un stream y
devuelve otro.
filterdeja pasar solo los que cumplen un predicado;maptransforma cada elemento con una función;sorted,distinctylimithacen lo que dicen. - 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. filterymapse 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.


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 enStream(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, lanzaIllegalStateException, 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()));
}


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:
songsPerArtist(songs).entrySet().stream(): un stream de pares artista–cantidad.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().limit(n): los primerosn.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)
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 queorElseThrow()) lanzaNoSuchElementExceptionsi la caja está vacía: es elNullPointerExceptionde siempre con otro nombre. Si están seguros de que hay valor, escribanorElseThrow(), que dice lo mismo pero deja claro que pensaron en el caso vacío; si no lo están, usenorElseomap. Tampoco usenOptionalpara 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,filteroforEach.List<String> titles = new ArrayList<>(); songs.stream().filter(s -> s.seconds() > 200).forEach(s -> titles.add(s.title())); // noFunciona, 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 defilterymap, 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
breaksegún un estado que va cambiando, o un algoritmo con índices (comparara[i]cona[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.comparingIntproducen el mismo tipo de objeto. - La flecha de
assertThrows(..., () -> ...)es una lambda que implementaExecutable; por eso el código se ejecuta dentro deassertThrowsy no antes. java.util.functiontrae 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,thenComparingyreversedarman comparadores compuestos;removeIf,forEach,mergeycomputeIfAbsentreemplazan 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,IntStreamy compañía evitan el boxing en cálculos numéricos.collectconCollectorsjunta el resultado:toList,joining,toMap, ygroupingByconcountingosummingIntpara reportes. ConTreeMap::newel orden de las claves es fijo.Optionalrepresenta un resultado que puede no existir; se abre conorElse,orElseThrowomap, nunca conget()a ciegas.- Sin efectos secundarios dentro del pipeline, y sin reemplazar por streams los bucles que se leen mejor como bucles.
Ejercicios
-
¿Qué interfaz funcional de
java.util.functionimplementa cada una de estas lambdas?s -> s.length() > 3,String::length,() -> "hola",s -> IO.println(s),(a, b) -> a * b(conaybenteros). ¿Alguna podría implementar más de una? ¿Qué decide cuál? -
Escriban
ByArtistThenTitlede la semana 11 primero como clase anónima y después como lambda, sin usarComparator.comparing. Luego escríbanlo concomparingythenComparing. Verifiquen conComparatorsTestque las tres versiones dan el mismo orden. -
Sin ejecutarlo, digan qué imprime este fragmento y en qué orden. Después compruébenlo en
jshell(con/open Song.javay/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(); -
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()); -
Escriban con un stream un
Map<String, List<String>>de artista a la lista de sus títulos, ordenado por artista. (Pista:groupingBycon un tercer argumentomapping(Song::title, toList()).) Comparen con la versión decomputeIfAbsentdeCollectionLambdasDemo. -
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 devolvernullen ese caso? -
Collectors.toMap(Song::artist, Song::title)sobreSongs.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 congroupingBy. -
Reescriban con streams el contador de palabras de la semana 11 (
WordCount.count). ¿Qué versión les parece más clara: el bucle congetOrDefault, el bucle conmergeo el stream congroupingByycounting? Justifiquen. -
Expliquen la diferencia entre
comparing(Song::artist).thenComparing(Song::seconds).reversed()ycomparing(Song::artist).thenComparing(Song::seconds, Comparator.reverseOrder()). Escriban una prueba conSongs.sample()que distinga los dos. -
Den un ejemplo de un bucle que, en su opinión, se lee peor escrito con streams. Escriban las dos versiones y justifiquen.
-
IntStream.range(0, 5).map(i -> i * i).sum()yStream.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? -
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?