Blog

Lambdas y streams en Java: evaluación perezosa, collectors y cuándo usar un bucle

Las lambdas de Java convierten un pequeño comportamiento en un valor, y los streams encadenan esos valores en pipelines perezosos. Mira cómo se ejecuta de verdad un pipeline, qué collectors conviene usar y cuándo un bucle simple se lee mejor.

Una lambda es un pedazo corto de código que puedes pasar de un lado a otro como si fuera un valor. Un stream es un pipeline que lleva una secuencia de valores a través de pasos hechos con esas lambdas. Juntos reemplazan muchos de los bucles que llenaban el código Java más antiguo.

Este post cubre las lambdas y las interfaces funcionales que hay detrás, las referencias a métodos, qué puede capturar una lambda, los pipelines de streams, la evaluación perezosa, los collectors, los streams primitivos, reduce, los gatherers y los casos en que un bucle sigue siendo la mejor herramienta. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.

De clase anónima a lambda

Una lambda es una forma más corta de escribir un objeto que implementa una interfaz con un solo método abstracto. Ordenar palabras por longitud muestra el camino. Aquí está el mismo Comparator escrito de tres formas:

List<String> sortedBy(List<String> words, Comparator<String> order) {
    var copy = new ArrayList<>(words);
    copy.sort(order);
    return copy;
}

void main() {
    var words = List.of("banana", "fig", "cherry", "kiwi");

    var anonymous = new Comparator<String>() {
        @Override
        public int compare(String a, String b) {
            return Integer.compare(a.length(), b.length());
        }
    };
    Comparator<String> lambda = (a, b) -> Integer.compare(a.length(), b.length());
    Comparator<String> byKey = Comparator.comparing(String::length);

    IO.println(sortedBy(words, anonymous));
    IO.println(sortedBy(words, lambda));
    IO.println(sortedBy(words, byKey));
}

Imprime:

[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]
[fig, kiwi, banana, cherry]

La clase anónima escribe todo: la interfaz, el nombre del método y los tipos de los parámetros. La lambda se queda solo con los parámetros y el cuerpo, porque el compilador ya sabe que el destino es un Comparator<String>, y un Comparator tiene un solo método que completar. La tercera línea no escribe ninguna comparación: Comparator.comparing arma una a partir de una clave, la longitud.

banana queda antes de cherry en todas las líneas, aunque las dos tienen seis letras. List.sort es estable, así que los elementos iguales mantienen su orden original.

Interfaces funcionales

Una interfaz funcional es una interfaz con exactamente un método abstracto, y una lambda puede ocupar el lugar de cualquiera de ellas. El JDK trae un conjunto de interfaces generales en java.util.function, así que rara vez necesitas una propia:

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

void main() {
    Function<String, Integer> length = s -> s.length();
    Predicate<String> isLong = s -> s.length() > 4;
    Supplier<List<String>> fresh = () -> new ArrayList<>();
    Consumer<String> shout = s -> IO.println(s.toUpperCase() + "!");
    BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
    UnaryOperator<String> tidy = s -> s.strip();
    PriceRule discount = cents -> cents * 90 / 100;

    IO.println(length.apply("coffee"));
    IO.println(isLong.test("tea"));
    var list = fresh.get();
    list.add("new");
    IO.println(list);
    shout.accept("hello");
    IO.println(add.apply(2, 3));
    IO.println("[" + tidy.apply("  milk ") + "]");
    IO.println(discount.apply(500));
}

Imprime:

6
false
[new]
HELLO!
5
[milk]
450

Cada una tiene el nombre de su forma:

  • Function<T, R> recibe un T y devuelve un R. La llamas con apply.
  • Predicate<T> recibe un T y devuelve un boolean, con test.
  • Supplier<T> no recibe nada y devuelve un T, con get.
  • Consumer<T> recibe un T y no devuelve nada, con accept.
  • BiFunction<T, U, R> recibe dos argumentos y devuelve un R.
  • UnaryOperator<T> es una Function<T, T>: el mismo tipo entra y sale.

PriceRule es nuestra. @FunctionalInterface es opcional, pero hace que el compilador verifique la regla de “exactamente uno”. Agregamos un segundo método abstracto, int undo(int cents), y la compilación falló con Unexpected @FunctionalInterface annotation, seguido de multiple non-overriding abstract methods found in interface PriceRule.

Referencias a métodos, cuatro tipos

Una referencia a método, escrita con ::, es una lambda cuyo cuerpo entero es una sola llamada a un método. Hay cuatro tipos, y la diferencia está en de dónde sale el objeto:

record Point(int x, int y) {}

void main() {
    // static method: s -> Integer.parseInt(s)
    Function<String, Integer> parse = Integer::parseInt;
    // method on one object you already have: s -> prefix.concat(s)
    String prefix = "id-";
    Function<String, String> tag = prefix::concat;
    // method on whatever object arrives: s -> s.toUpperCase()
    Function<String, String> upper = String::toUpperCase;
    // constructor: (x, y) -> new Point(x, y)
    BiFunction<Integer, Integer, Point> make = Point::new;

    IO.println(parse.apply("42") + 1);
    IO.println(tag.apply("7"));
    IO.println(upper.apply("tea"));
    IO.println(make.apply(3, 4));
}

Imprime:

43
id-7
TEA
Point[x=3, y=4]

El comentario sobre cada línea es la lambda que reemplaza. La que confunde a la gente es String::toUpperCase. Parece una llamada estática, pero toUpperCase es un método de instancia, así que el primer argumento se vuelve el objeto sobre el que corre el método.

Qué puede capturar una lambda

Una lambda puede leer variables locales del método que la rodea, pero solo si son final o efectivamente final, es decir, si nada les asigna un valor después de inicializarlas. Contar con una lambda rompe esa regla:

void main() {
    int count = 0;
    List.of("a", "b", "c").forEach(s -> count++);
    IO.println(count);
}

La compilación falla con:

Main.java:3: error: local variables referenced from a lambda expression must be final or effectively final
    List.of("a", "b", "c").forEach(s -> count++);
                                        ^

Una lambda puede vivir más que el método que la creó, por ejemplo cuando se guarda en un campo o se ejecuta en otro hilo. Por eso Java copia el valor dentro de la lambda en lugar de compartir la variable. Si la variable pudiera cambiar después, la copia y el original no coincidirían, y Java lo impide en tiempo de compilación. Los campos no tienen esta limitación, porque la lambda llega a ellos a través de un objeto.

Una diferencia más con las clases anónimas: dentro de una lambda, this significa lo mismo que en el código que la rodea.

void main() {
    Runnable lambda = () -> IO.println("lambda this: " + this.getClass().getName());
    Runnable anonymous = new Runnable() {
        @Override
        public void run() {
            IO.println("anonymous this: " + this.getClass().getName());
        }
    };
    lambda.run();
    anonymous.run();
}

Imprime:

lambda this: Main
anonymous this: Main$1

El this de la lambda es el objeto Main sobre el que corre main. La clase anónima es una clase nueva, Main$1, así que su this es ella misma.

Un pipeline de stream: origen, pasos, resultado

Un pipeline de stream tiene tres partes: un origen, cualquier cantidad de operaciones intermedias y una operación terminal que produce un resultado. Aquí hay una lista de pedidos limpiada en una sola pasada:

void main() {
    var orders = List.of("tea", "coffee", "tea", "juice", "water", "coffee", "milk");

    var result = orders.stream()
        .filter(o -> !o.equals("water"))
        .distinct()
        .map(String::toUpperCase)
        .sorted()
        .limit(3)
        .toList();

    IO.println(result);
    IO.println(orders);
}

Imprime:

[COFFEE, JUICE, MILK]
[tea, coffee, tea, juice, water, coffee, milk]
  • Origen: orders.stream(). Las colecciones, los arrays, Stream.of(...) y los archivos pueden ser orígenes.
  • Operaciones intermedias: filter se queda con los elementos que pasan un Predicate, distinct quita los repetidos, map convierte cada elemento en otra cosa, sorted los ordena y limit se queda con los primeros. Cada una devuelve un stream nuevo.
  • Operación terminal: toList() junta lo que queda.

La última línea muestra que la lista de origen no se tocó. Un stream lee su origen. No lo cambia.

toList() o collect(Collectors.toList())

Stream.toList() llegó en Java 16. Lo comprobamos: con javac --release 15, la llamada falla con cannot find symbol. Antes se escribía collect(Collectors.toList()), y mucho código todavía lo hace. Las dos no son exactamente iguales:

void main() {
    var viaCollect = Stream.of("b", "a").collect(Collectors.toList());
    viaCollect.add("c");
    IO.println(viaCollect);

    var viaToList = Stream.of("b", "a").toList();
    IO.println(viaToList);
    viaToList.add("c");
}

Imprime y se detiene:

[b, a, c]
[b, a]
Exception in thread "main" java.lang.UnsupportedOperationException

toList() devuelve una lista no modificable, así que add lanza una excepción. Collectors.toList() no promete nada al respecto, y hoy resulta que devuelve un ArrayList. Usa toList() salvo que de verdad necesites cambiar la lista después. Si lo necesitas, dilo explícitamente con Collectors.toCollection(ArrayList::new).

Pereza: nada se ejecuta hasta que lo pides

Las operaciones intermedias no hacen ningún trabajo cuando las llamas. Solo describen el trabajo. El trabajo empieza cuando una operación terminal pide un resultado:

void main() {
    var pipeline = Stream.of(1, 2, 3).map(n -> {
        IO.println("map saw " + n);
        return n * 10;
    });
    IO.println("pipeline built");

    var result = pipeline.toList();
    IO.println(result);
}

Imprime:

pipeline built
map saw 1
map saw 2
map saw 3
[10, 20, 30]

pipeline built sale primero. Cuando se ejecutó la línea de map, no llamó a la lambda ni una vez. Devolvió un stream que recuerda “multiplicar por diez”. La lambda se ejecutó solo cuando toList() hizo pasar los elementos.

Un elemento a la vez

Un stream no ejecuta filter sobre toda la lista y después map sobre el resultado. Cada elemento recorre el pipeline entero antes de que empiece el siguiente:

void main() {
    var result = Stream.of("apple", "fig", "cherry", "kiwi")
        .filter(w -> {
            IO.println("filter " + w);
            return w.length() > 3;
        })
        .map(w -> {
            IO.println("map    " + w);
            return w.toUpperCase();
        })
        .toList();
    IO.println(result);
}

Imprime:

filter apple
map    apple
filter fig
filter cherry
map    cherry
filter kiwi
map    kiwi
[APPLE, CHERRY, KIWI]

Léelo de arriba abajo. apple pasa el filtro y va directo a map. fig no pasa el filtro, así que map nunca lo ve. Después cherry recorre todo el camino, y luego kiwi. Un bucle con un if adentro hace exactamente lo mismo, y esa es la idea: un stream es un bucle que describes en lugar de escribirlo.

sorted es la excepción. No puede pasar nada hacia adelante hasta haber visto todos los elementos, así que primero los junta a todos:

void main() {
    var result = Stream.of("kiwi", "apple", "fig")
        .peek(w -> IO.println("before sorted " + w))
        .sorted()
        .peek(w -> IO.println("after sorted  " + w))
        .toList();
    IO.println(result);
}

Imprime:

before sorted kiwi
before sorted apple
before sorted fig
after sorted  apple
after sorted  fig
after sorted  kiwi
[apple, fig, kiwi]

peek ejecuta un Consumer sobre cada elemento a medida que pasa, lo que lo vuelve útil para ver el flujo. sorted y distinct se llaman operaciones con estado (stateful), porque necesitan recordar elementos. filter y map son sin estado.

Cortocircuito: parar antes, incluso en un stream infinito

Algunas operaciones detienen el pipeline en cuanto tienen su respuesta. findFirst se detiene en el primer elemento que le llega, y limit(n) se detiene después de n. Eso es lo que hace seguro un origen infinito:

void main() {
    var firstBig = Stream.iterate(1, n -> n * 2)
        .peek(n -> IO.println("looked at " + n))
        .filter(n -> n > 20)
        .findFirst();
    IO.println(firstBig);

    var firstFive = Stream.iterate(1, n -> n * 2).limit(5).toList();
    IO.println(firstFive);
}

Imprime:

looked at 1
looked at 2
looked at 4
looked at 8
looked at 16
looked at 32
Optional[32]
[1, 2, 4, 8, 16]

Stream.iterate(1, n -> n * 2) describe 1, 2, 4, 8 y así para siempre. El pipeline miró seis valores, encontró 32 y se detuvo. findFirst devuelve un Optional, porque un stream puede no tener primer elemento. La parte sobre Optional lo explica. Cambia findFirst() por toList() y el programa nunca termina.

La pereza puede saltarse tus lambdas

La pereza trae una sorpresa que no esperábamos. Una operación terminal puede decidir que no necesita ejecutar el pipeline en absoluto:

void main() {
    long quick = Stream.of(1, 2, 3)
        .peek(n -> IO.println("peek " + n))
        .count();
    IO.println("count " + quick);

    long slow = Stream.of(1, 2, 3)
        .filter(n -> n > 1)
        .peek(n -> IO.println("peek " + n))
        .count();
    IO.println("count " + slow);
}

Imprime:

count 3
peek 2
peek 3
count 2

El primer peek nunca se ejecutó. Stream.of(1, 2, 3) sabe que tiene tres elementos, peek no puede cambiar eso, así que count responde 3 sin hacer pasar nada. Cuando hay un filter en el medio, el tamaño no se conoce, y cada elemento tiene que fluir. El JDK hace esto desde Java 9, y la documentación de Stream lo permite. Así que no pongas trabajo del que dependes dentro de peek o map. Una lambda en un stream debe calcular un valor, no provocar un efecto.

Explicado como si tuvieras diez años

Un stream es una línea de montaje en una fábrica de juguetes. A lo largo de la línea hay trabajadores: uno tira los juguetes rotos, otro pinta los buenos, otro los mete en cajas. Al final de todo hay un cliente.

Armar la línea no fabrica ningún juguete. Nada se mueve hasta que el cliente del final dice “quiero un juguete terminado”.

Entonces el primer juguete recorre toda la línea: revisado, pintado, empacado, entregado. Solo después arranca el segundo juguete. Si el cliente quería un solo juguete, la línea se detiene, y los demás juguetes nunca salen de la pila.

La versión precisa

Un pipeline de stream es una cadena de objetos de etapa. Cada operación intermedia, como filter o map, agrega una etapa y regresa de inmediato. La operación terminal pone en marcha la evaluación. Le pide elementos al origen uno por uno, y cada elemento pasa por la lambda de cada etapa, en orden, antes de pedirle el siguiente al origen.

Las etapas sin estado, como filter y map, procesan un elemento y lo pasan enseguida. Las etapas con estado, como sorted, retienen los elementos hasta que el origen se vacía. Las operaciones de cortocircuito, findFirst, anyMatch, limit y compañía, avisan que terminaron, y el origen deja de producir. Como la operación terminal ve el pipeline completo antes de empezar, también puede saltarse etapas cuyo resultado no necesita, como hizo count.

Dónde falla la analogía: una línea de montaje real con un pintor lento acumularía juguetes en fila entre los trabajadores. Un stream secuencial nunca lo hace. No hay fila ni un segundo juguete en camino, salvo donde una etapa con estado como sorted detiene la línea para juntar todo. Y con parallel(), varias líneas corren a la vez y los elementos no llegan en orden.

Ver cómo findFirst detiene un stream

La animación sigue Stream.of(1, 2, 3, 4, 5).filter(n -> n % 2 == 1).map(n -> n * 10).findFirst(), que devuelve Optional[10]:

Stream.of(1, 2, 3, 4, 5) 1 2 3 4 5 2 a 5: nunca se leen filter(n % 2 == 1) map(n * 10) findFirst() 1 10 1 es impar: pasa 1 se hace 10 hay uno: fin valor: Optional[10] el pipeline está armado, pero nada se mueve findFirst() pide un elemento 1 entra a filter: es impar, así que pasa 1 entra a map y sale como 10 10 llega a findFirst(): ya tiene respuesta el stream se detiene: 2 a 5 nunca se leen

Un stream de cinco elementos que pasa por filter, map y findFirst. Nada se mueve hasta que findFirst lo pide. El elemento 1 pasa el filtro, se convierte en 10 y llega a findFirst, que devuelve Optional[10]. El stream se detiene ahí, y 2 a 5 nunca se leen.

Aquí están esos pasos en palabras, por si la animación no se reproduce:

  1. Stream.of, filter y map regresan enseguida. Armaron un pipeline, y ningún elemento se ha movido.
  2. findFirst() es la operación terminal. Le pide un elemento al pipeline, y el pedido llega hasta el origen.
  3. El origen entrega 1. El filtro evalúa 1 % 2 == 1, que es verdadero, así que 1 pasa.
  4. 1 entra a map y sale como 10.
  5. 10 llega a findFirst(). Eso es todo lo que necesitaba, así que envuelve el valor como Optional[10].
  6. findFirst() le avisa al pipeline que terminó. El origen nunca entrega 2, 3, 4 ni 5, y ninguna lambda se ejecuta para ellos.

Un stream se usa una sola vez

Un stream es un viaje de ida sobre su origen, así que una segunda operación terminal sobre el mismo objeto stream falla:

void main() {
    var words = Stream.of("tea", "coffee", "milk");
    IO.println(words.count());
    IO.println(words.count());
}

Imprime y se detiene:

3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed

La solución es volver al origen, list.stream(), cada vez que necesites un pipeline nuevo. Para que otro código pueda crear streams nuevos, pásale la colección, no un stream.

Collectors: agrupar, particionar, unir

collect es la operación terminal que construye algo a partir de los elementos, y Collectors tiene los constructores ya hechos. Aquí hay ventas agrupadas, divididas y unidas:

record Sale(String city, String product, int amount) {}

void main() {
    var sales = List.of(
        new Sale("Lisbon", "tea", 12),
        new Sale("Porto", "coffee", 30),
        new Sale("Lisbon", "coffee", 25),
        new Sale("Braga", "tea", 8),
        new Sale("Porto", "tea", 15));

    Map<String, Long> perCity = sales.stream()
        .collect(Collectors.groupingBy(Sale::city, TreeMap::new, Collectors.counting()));
    IO.println(perCity);

    Map<Boolean, List<Integer>> bigOrSmall = sales.stream()
        .map(Sale::amount)
        .collect(Collectors.partitioningBy(a -> a >= 20));
    IO.println(bigOrSmall);

    String cities = sales.stream()
        .map(Sale::city)
        .distinct()
        .sorted()
        .collect(Collectors.joining(", ", "<", ">"));
    IO.println(cities);
}

Imprime:

{Braga=1, Lisbon=2, Porto=2}
{false=[12, 8, 15], true=[30, 25]}
<Braga, Lisbon, Porto>
  • groupingBy(key, mapFactory, downstream) reparte los elementos en grupos por clave y después ejecuta un segundo collector sobre cada grupo. counting() convierte cada grupo en su tamaño. Si omites TreeMap::new, obtienes un HashMap, que no tiene un orden útil al imprimirlo. El TreeMap mantiene las ciudades ordenadas.
  • partitioningBy(predicate) siempre crea exactamente dos grupos, false y true, incluso cuando uno está vacío.
  • joining(separator, prefix, suffix) pega strings. Solo funciona sobre un stream de strings, por eso el map va primero.

toMap y las claves duplicadas

Collectors.toMap construye un mapa a partir de una función de clave y una función de valor. Cuando dos elementos producen la misma clave, tienes que decir qué pasa, o el stream lanza una excepción:

record Sale(String city, int amount) {}

void main() {
    var sales = List.of(new Sale("Lisbon", 12), new Sale("Porto", 30), new Sale("Lisbon", 25));

    Map<String, Integer> totals = sales.stream()
        .collect(Collectors.toMap(Sale::city, Sale::amount, Integer::sum, TreeMap::new));
    IO.println(totals);

    Map<String, Integer> broken = sales.stream()
        .collect(Collectors.toMap(Sale::city, Sale::amount));
    IO.println(broken);
}

Imprime y se detiene:

{Lisbon=37, Porto=30}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key Lisbon (attempted merging values 12 and 25)

El tercer argumento, Integer::sum, es la función de combinación (merge function). Recibe el valor viejo y el nuevo, y devuelve el que se queda, así que las ventas de Lisbon sumaron 37. Sin ella, el segundo Lisbon lanza IllegalStateException. El mensaje nombra la clave y los dos valores. Para un mapa de clave a conteo o suma, groupingBy con counting() o summingInt dice lo mismo de forma más directa.

Streams primitivos

IntStream, LongStream y DoubleStream contienen valores int, long y double simples, y tienen los métodos numéricos que le faltan a Stream<Integer>:

void main() {
    IO.println(IntStream.range(0, 5).sum());
    IO.println(IntStream.rangeClosed(1, 5).boxed().toList());

    var words = List.of("tea", "coffee", "milk");
    int letters = words.stream().mapToInt(String::length).sum();
    OptionalDouble average = words.stream().mapToInt(String::length).average();
    IO.println(letters);
    IO.println(average);
    IO.println(average.orElse(0));
    IO.println(IntStream.empty().average());
}

Imprime:

10
[1, 2, 3, 4, 5]
13
OptionalDouble[4.333333333333333]
4.333333333333333
OptionalDouble.empty

range(0, 5) va de 0 a 4, y rangeClosed(1, 5) incluye el 5. boxed() vuelve a un Stream<Integer> cuando necesitas una List.

average() devuelve un OptionalDouble, no un double, porque un stream vacío no tiene promedio. La última línea muestra ese caso.

mapToInt es el importante. words.stream().map(String::length) te daría un Stream<Integer>: cada longitud envuelta en un objeto Integer, y ningún método sum() que llamar. mapToInt te da un IntStream de ints simples. No se crean objetos envoltorio, y sum, average, min y max vienen incluidos.

reduce, en breve

reduce combina todos los elementos en un solo valor aplicando una función de a pares:

void main() {
    int total = Stream.of(3, 4, 5).reduce(0, Integer::sum);
    Optional<Integer> product = Stream.of(3, 4, 5).reduce((a, b) -> a * b);
    Optional<Integer> nothing = Stream.<Integer>empty().reduce((a, b) -> a * b);

    IO.println(total);
    IO.println(product);
    IO.println(nothing);
    IO.println(IntStream.of(3, 4, 5).sum());
}

Imprime:

12
Optional[60]
Optional.empty
12

Con un valor inicial, aquí 0, obtienes un resultado simple. Sin él, obtienes un Optional, ya que puede no haber nada que combinar. reduce es general, así que quien lee tiene que descifrar qué calcula. Para sumas, conteos, mínimos, máximos y uniones, la versión con nombre (sum(), count(), max(...), Collectors.joining) lo dice directamente, como hace la última línea.

Gatherers: pasos intermedios a medida

Un gatherer es una operación intermedia a medida, que se usa con Stream.gather. Los gatherers pasaron a ser finales en Java 24. Lo comprobamos: con javac --release 23, la compilación falla con Gatherers is a preview API and is disabled by default, y en Java 25 no hace falta ningún flag. Gatherers trae algunos ya hechos, como las ventanas fijas y deslizantes:

void main() {
    var readings = List.of(3, 5, 4, 8, 9, 2, 7);

    IO.println(readings.stream().gather(Gatherers.windowFixed(3)).toList());
    IO.println(readings.stream().gather(Gatherers.windowSliding(3)).toList());

    var movingAverage = readings.stream()
        .gather(Gatherers.windowSliding(3))
        .map(w -> w.stream().mapToInt(Integer::intValue).average().orElseThrow())
        .toList();
    IO.println(movingAverage);
}

Imprime:

[[3, 5, 4], [8, 9, 2], [7]]
[[3, 5, 4], [5, 4, 8], [4, 8, 9], [8, 9, 2], [9, 2, 7]]
[4.0, 5.666666666666667, 7.0, 6.333333333333333, 6.0]

windowFixed(3) corta el stream en grupos de tres, y el último grupo se queda con lo que sobre. windowSliding(3) avanza un elemento a la vez, que es lo que necesita un promedio móvil. Antes de los gatherers, agrupar elementos vecinos significaba un bucle con índices. También puedes escribir el tuyo con Gatherer.of.

Cuándo un bucle es más claro

Los streams son buenos para “toma estos, quédate con algunos, cámbialos, júntalos”. Fuera de esa forma, hay cuatro casos que aparecen una y otra vez.

Excepciones comprobadas. Ninguna de las interfaces funcionales de java.util.function declara una excepción comprobada, así que una lambda no puede lanzar una:

int parsePort(String s) throws IOException {
    if (s.isBlank()) {
        throw new IOException("blank port");
    }
    return Integer.parseInt(s);
}

void main() {
    var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
    IO.println(ports);
}

La compilación falla con:

Main.java:9: error: unreported exception IOException; must be caught or declared to be thrown
    var ports = Stream.of("80", "443").map(s -> parsePort(s)).toList();
                                                     ^

Las alternativas son atrapar la excepción dentro de la lambda y envolverla en una no comprobada, o escribir un helper que lo haga por ti. Las dos esconden la excepción de la firma del método. Un bucle for simplemente puede dejar que se propague.

Modificar estado local, y salir antes. Aquí está “comprar artículos en orden hasta que el siguiente se pase del presupuesto”, una vez como stream y otra como bucle:

void main() {
    var prices = List.of(40, 25, 30, 50, 10);
    int budget = 100;

    int[] spentBox = {0};
    long boughtByStream = prices.stream()
        .takeWhile(p -> {
            if (spentBox[0] + p > budget) {
                return false;
            }
            spentBox[0] += p;
            return true;
        })
        .count();
    IO.println("stream: bought " + boughtByStream + ", spent " + spentBox[0]);

    int spent = 0;
    int bought = 0;
    for (int price : prices) {
        if (spent + price > budget) {
            break;
        }
        spent += price;
        bought++;
    }
    IO.println("loop:   bought " + bought + ", spent " + spent);
}

Imprime:

stream: bought 3, spent 95
loop:   bought 3, spent 95

Los dos dan la misma respuesta. La versión con stream necesita un array de un elemento, porque la lambda no puede asignar a una variable local, así que cambia el contenido de un array. Su predicado de takeWhile tiene un efecto secundario, que es justo lo que advertía la sorpresa de count de antes. El bucle dice qué pasa en el orden en que pasa, y break es la salida anticipada, sin trucos.

Depuración. En un bucle, puedes poner un breakpoint en una línea y mirar cada variable. En un pipeline, el código que escribiste corre dentro de la biblioteca de streams. Los stack traces se llenan de frames de java.util.stream y de nombres generados como lambda$main$0, y un breakpoint cae dentro de una lambda sin ninguna variable de bucle alrededor. Si necesitas varias llamadas a peek para entender un pipeline, probablemente debería ser un bucle.

Una regla corta: usa un stream cuando el pipeline se lee como una oración: filtra esto, mapea aquello, junta. Usa un bucle cuando necesitas break, una excepción comprobada, varias variables que cambian juntas o un depurador.

Qué recordar

  • Una lambda implementa una interfaz con un solo método abstracto. Function, Predicate, Supplier, Consumer, BiFunction y UnaryOperator cubren la mayoría de las necesidades.
  • Una referencia a método, como String::length o Point::new, es una lambda que solo llama a un método.
  • Una lambda solo puede capturar variables locales que sean efectivamente final.
  • Un stream no ejecuta nada hasta que una operación terminal lo pide, y después mueve un elemento por todos los pasos antes del siguiente. findFirst y limit lo detienen antes, incluso sobre un origen infinito.
  • No dependas de efectos secundarios en peek o map. La operación terminal puede saltárselos, como hizo count.
  • Un stream se usa una sola vez. toList() no es modificable. Dale un TreeMap a groupingBy cuando el orden importa, y dale una función de combinación a toMap cuando las claves pueden repetirse.
  • Usa mapToInt para números, y un bucle simple para excepciones comprobadas, salidas anticipadas y estado que cambia.

Un stream describe el trabajo, y la operación terminal decide cuánto de ese trabajo se ejecuta de verdad.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.