Lambdas y streams en Java
En esta lección
Desde Java 8 puedes escribir pequeñas funciones en una sola línea (lambdas) y procesar colecciones enteras encadenando operaciones como “filtra”, “transforma” y “suma” (streams). El resultado es un código más corto y que se lee casi como una frase: “de los productos, quédate con los baratos y dame sus nombres”. En esta lección aprenderás las interfaces funcionales más importantes, la sintaxis de las lambdas, las referencias a métodos y las operaciones de stream que vas a usar a diario.
Interfaces funcionales
En clases abstractas e interfaces viste que una interfaz funcional es una interfaz con un solo método abstracto. Por ejemplo:
interface Operacion {
int aplicar(int a, int b);
}
Antes de Java 8, para tener un objeto Operacion había que escribir una clase que la implementara. Con una lambda basta con dar el cuerpo del método:
Operacion suma = (a, b) -> a + b;
Operacion producto = (a, b) -> a * b;
System.out.println(suma.aplicar(3, 4)); // 7
System.out.println(producto.aplicar(3, 4)); // 12
Una lambda es, por tanto, una forma abreviada de implementar una interfaz funcional. Java deduce qué método implementa y de qué tipo son a y b a partir del tipo de la variable.
Sintaxis de las lambdas
La forma general es (parámetros) -> cuerpo:
| Caso | Ejemplo |
|---|---|
| Sin parámetros | () -> System.out.println("Hola") |
| Un parámetro (paréntesis opcionales) | n -> n * 2 |
| Varios parámetros | (a, b) -> a + b |
| Con tipos explícitos | (int a, int b) -> a + b |
| Con varias líneas | x -> { ...; return resultado; } |
Si el cuerpo es una sola expresión, su valor se devuelve automáticamente. Si necesitas varias sentencias, usa llaves y return:
Function<Double, Double> precioFinal = precio -> {
double conIva = precio * 1.21;
return Math.round(conIva * 100) / 100.0;
};
System.out.println(precioFinal.apply(10.0)); // 12.1
Cuidado: una lambda puede leer variables locales de fuera, pero solo si no cambian después (efectivamente finales). Intentar hacer
contador++dentro de una lambda con una variable local no compila.
Las interfaces funcionales de Java
No hace falta inventar una interfaz cada vez. El paquete java.util.function trae las más comunes:
| Interfaz | Método | Recibe → devuelve | Para qué |
|---|---|---|---|
Predicate<T> | test | T → boolean | Comprobar una condición |
Function<T, R> | apply | T → R | Transformar un valor en otro |
Consumer<T> | accept | T → nada | Hacer algo con un valor |
Supplier<T> | get | nada → T | Producir un valor |
BinaryOperator<T> | apply | T, T → T | Combinar dos valores |
Comparator<T> | compare | T, T → int | Comparar para ordenar |
Predicate<Integer> esPar = n -> n % 2 == 0;
System.out.println(esPar.test(4) + " " + esPar.test(7)); // true false
Function<String, Integer> longitud = s -> s.length();
System.out.println(longitud.apply("Java")); // 4
Consumer<String> avisar = s -> System.out.println(">> " + s);
avisar.accept("Pedido listo"); // >> Pedido listo
Supplier<List<String>> nuevaLista = () -> new ArrayList<>();
System.out.println(nuevaLista.get()); // []
BinaryOperator<Integer> mayor = (a, b) -> a > b ? a : b;
System.out.println(mayor.apply(3, 8)); // 8
Los predicados se pueden combinar con and, or y negate:
Predicate<String> larga = s -> s.length() > 5;
Predicate<String> empiezaPorA = s -> s.startsWith("a");
System.out.println(larga.and(empiezaPorA).test("aguacate")); // true
System.out.println(larga.negate().test("kiwi")); // true
Y ya usaste un Comparator en forma de lambda para ordenar listas.
Referencias a métodos
Cuando una lambda solo llama a un método que ya existe, se puede escribir aún más corta con ::, una referencia a método:
| Lambda | Referencia a método | Tipo |
|---|---|---|
s -> s.toUpperCase() | String::toUpperCase | Método de instancia del parámetro |
s -> System.out.println(s) | System.out::println | Método de un objeto concreto |
s -> Integer.parseInt(s) | Integer::parseInt | Método estático |
() -> new ArrayList<>() | ArrayList::new | Constructor |
List<String> nombres = List.of("marta", "luis", "ana");
nombres.forEach(System.out::println);
// marta
// luis
// ana
Function<String, Integer> convertir = Integer::parseInt;
System.out.println(convertir.apply("42") + 1); // 43
Úsalas cuando hagan el código más claro. Si la lambda tiene lógica propia, déjala como lambda.
Qué es un stream
Un stream es una secuencia de elementos sobre la que encadenas operaciones, como en una cadena de montaje. Se obtiene de una colección con .stream() y tiene tres partes:
fuente operaciones intermedias operación final
lista.stream() .filter(...).map(...).sorted() .toList()
- Fuente: una lista, un conjunto, un array (
Arrays.stream(array))… - Operaciones intermedias: devuelven otro stream, así que se encadenan. No hacen nada hasta que llega la final (son perezosas).
- Operación final: produce el resultado (una lista, un número, un
Optional) y cierra el stream.
Un stream no modifica la colección original y solo se puede recorrer una vez. Si intentas reutilizarlo, obtienes IllegalStateException: stream has already been operated upon or closed.
Para los ejemplos usaremos el catálogo de una tienda:
record Producto(String nombre, String categoria, double precio, int stock) {}
List<Producto> productos = List.of(
new Producto("Portátil", "Informática", 899.0, 5),
new Producto("Ratón", "Informática", 19.9, 40),
new Producto("Silla", "Muebles", 129.0, 0),
new Producto("Monitor", "Informática", 219.0, 12),
new Producto("Mesa", "Muebles", 249.0, 3),
new Producto("Lámpara", "Hogar", 35.5, 20)
);
Operaciones intermedias
filter y map
filter se queda con los elementos que cumplen un Predicate. map transforma cada elemento con una Function:
List<String> baratos = productos.stream()
.filter(p -> p.precio() < 100) // solo los de menos de 100 €
.map(Producto::nombre) // de Producto a String
.toList();
System.out.println(baratos); // [Ratón, Lámpara]
Se lee de arriba abajo: toma los productos, filtra los baratos, quédate con su nombre y haz una lista.
sorted, limit y distinct
// Los 3 productos más caros
productos.stream()
.sorted(Comparator.comparing(Producto::precio).reversed())
.limit(3)
.forEach(p -> System.out.println(p.nombre() + " " + p.precio()));
// Portátil 899.0
// Mesa 249.0
// Monitor 219.0
// Categorías sin repetir
List<String> categorias = productos.stream()
.map(Producto::categoria)
.distinct()
.toList();
System.out.println(categorias); // [Informática, Muebles, Hogar]
sorted() sin argumentos usa el orden natural; con un Comparator, el que tú indiques.
Operaciones finales
forEach, toList y count
Ya has visto forEach (hace algo con cada elemento) y toList() (recoge el resultado en una lista inmutable). count cuenta los elementos:
long agotados = productos.stream()
.filter(p -> p.stock() == 0)
.count();
System.out.println(agotados); // 1
Consejo:
toList()existe desde Java 16. En código más antiguo verás.collect(Collectors.toList()), que devuelve una lista modificable.
Sumas y medias: mapToInt y mapToDouble
Para operar con números, convierte el stream en un IntStream o DoubleStream, que tienen sum, average, max y min:
int unidades = productos.stream()
.mapToInt(Producto::stock)
.sum();
System.out.println(unidades); // 80
double valorAlmacen = productos.stream()
.mapToDouble(p -> p.precio() * p.stock())
.sum();
System.out.println(valorAlmacen); // 9376.0
double media = productos.stream()
.mapToDouble(Producto::precio)
.average()
.orElse(0);
System.out.println(media); // 258.56666666666666
average() no devuelve un double, sino un OptionalDouble, porque la media de un stream vacío no existe. orElse(0) indica qué usar en ese caso.
max, min y Optional
max y min reciben un Comparator y devuelven un Optional: una caja que puede contener un valor o estar vacía. Así Java te obliga a pensar en el caso “no hay ninguno” en lugar de devolver null:
Optional<Producto> masCaro = productos.stream()
.max(Comparator.comparing(Producto::precio));
masCaro.ifPresent(p -> System.out.println("El más caro: " + p.nombre()));
// El más caro: Portátil
String carisimo = productos.stream()
.filter(p -> p.precio() > 5000)
.findFirst()
.map(Producto::nombre)
.orElse("Ninguno");
System.out.println(carisimo); // Ninguno
Método de Optional | Qué hace |
|---|---|
isPresent() / isEmpty() | Indica si hay valor |
get() | Devuelve el valor (lanza excepción si está vacío: evítalo) |
orElse(x) | El valor o x si está vacío |
ifPresent(accion) | Ejecuta la acción solo si hay valor |
map(funcion) | Transforma el valor si existe |
También son finales anyMatch, allMatch y noneMatch, que devuelven un boolean: productos.stream().anyMatch(p -> p.stock() == 0) da true.
Agrupar con groupingBy
collect con los colectores de la clase Collectors permite resultados más elaborados. groupingBy crea un mapa agrupando por un criterio:
Map<String, Long> porCategoria = productos.stream()
.collect(Collectors.groupingBy(Producto::categoria, TreeMap::new, Collectors.counting()));
System.out.println(porCategoria); // {Hogar=1, Informática=3, Muebles=2}
Map<String, Double> importePorCategoria = productos.stream()
.collect(Collectors.groupingBy(Producto::categoria, TreeMap::new,
Collectors.summingDouble(Producto::precio)));
System.out.println(importePorCategoria); // {Hogar=35.5, Informática=1137.9, Muebles=378.0}
Con un solo argumento, groupingBy(Producto::categoria) devuelve un Map<String, List<Producto>> con los productos de cada categoría. El TreeMap::new del medio es opcional: lo usamos para que las claves salgan ordenadas.
Unir textos con joining
String catalogo = productos.stream()
.map(Producto::nombre)
.collect(Collectors.joining(", "));
System.out.println(catalogo); // Portátil, Ratón, Silla, Monitor, Mesa, Lámpara
String muebles = productos.stream()
.filter(p -> p.categoria().equals("Muebles"))
.map(Producto::nombre)
.collect(Collectors.joining(", ", "[", "]"));
System.out.println(muebles); // [Silla, Mesa]
¿Stream o bucle normal?
Compara el mismo filtro de antes con un bucle:
List<String> baratos = new ArrayList<>();
for (Producto p : productos) {
if (p.precio() < 100) {
baratos.add(p.nombre());
}
}
Los dos son correctos. Guía práctica:
- Usa streams para transformaciones de datos: filtrar, transformar, agrupar, sumar. Encadenar 3 o 4 pasos queda muy legible.
- Usa un bucle cuando necesites el índice, salir a mitad con
break, modificar variables externas o hacer varias cosas distintas en cada vuelta. - Si una cadena de stream necesita comentarios para entenderse, probablemente un bucle sea más claro.
- En colecciones pequeñas el rendimiento es prácticamente igual: elige lo que se lea mejor.
Errores frecuentes
- Olvidar la operación final.
productos.stream().filter(...)sintoList(),forEach,count… no hace nada. - Reutilizar un stream. Crea uno nuevo con otro
.stream(). - Intentar modificar la lista de
toList(). Es inmutable: lanzaUnsupportedOperationException. - Llamar a
get()en unOptionalvacío. UsaorElse,ifPresentomap. - Modificar la colección original desde dentro de un stream o de un
forEach. - Cambiar variables locales dentro de una lambda. No compila: deben ser efectivamente finales.
- Abusar de los streams con cadenas larguísimas que nadie entiende.
Resumen
| Concepto | Ejemplo |
|---|---|
| Lambda | (a, b) -> a + b |
| Referencia a método | Producto::nombre, System.out::println |
| Interfaces funcionales | Predicate, Function, Consumer, Supplier, Comparator |
| Filtrar / transformar | filter(p -> ...), map(Producto::nombre) |
| Ordenar / recortar | sorted(...), limit(n), distinct() |
| Recoger | toList(), collect(Collectors.joining(", ")) |
| Contar / sumar | count(), mapToInt(...).sum(), average() |
| Máximo / mínimo | max(comparador) → Optional |
| Agrupar | collect(Collectors.groupingBy(...)) |
En la siguiente lección aprenderás a trabajar con fechas y horas de forma correcta: fechas con java.time.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.