Saltar al contenido
lambdas-y-streams.java · devschool

Lambdas y streams en Java

Lección 23 de 26 · 10 min de lectura · Actualizado el

En esta lección
  1. Interfaces funcionales
  2. Sintaxis de las lambdas
  3. Las interfaces funcionales de Java
  4. Referencias a métodos
  5. Qué es un stream
  6. Operaciones intermedias
  7. Operaciones finales
  8. ¿Stream o bucle normal?
  9. Errores frecuentes
  10. Resumen

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:

CasoEjemplo
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íneasx -> { ...; 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:

InterfazMétodoRecibe → devuelvePara qué
Predicate<T>testT → booleanComprobar una condición
Function<T, R>applyT → RTransformar un valor en otro
Consumer<T>acceptT → nadaHacer algo con un valor
Supplier<T>getnada → TProducir un valor
BinaryOperator<T>applyT, T → TCombinar dos valores
Comparator<T>compareT, T → intComparar 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:

LambdaReferencia a métodoTipo
s -> s.toUpperCase()String::toUpperCaseMétodo de instancia del parámetro
s -> System.out.println(s)System.out::printlnMétodo de un objeto concreto
s -> Integer.parseInt(s)Integer::parseIntMétodo estático
() -> new ArrayList<>()ArrayList::newConstructor
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()
  1. Fuente: una lista, un conjunto, un array (Arrays.stream(array))…
  2. Operaciones intermedias: devuelven otro stream, así que se encadenan. No hacen nada hasta que llega la final (son perezosas).
  3. 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 OptionalQué 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(...) sin toList(), forEach, count… no hace nada.
  • Reutilizar un stream. Crea uno nuevo con otro .stream().
  • Intentar modificar la lista de toList(). Es inmutable: lanza UnsupportedOperationException.
  • Llamar a get() en un Optional vacío. Usa orElse, ifPresent o map.
  • 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

ConceptoEjemplo
Lambda(a, b) -> a + b
Referencia a métodoProducto::nombre, System.out::println
Interfaces funcionalesPredicate, Function, Consumer, Supplier, Comparator
Filtrar / transformarfilter(p -> ...), map(Producto::nombre)
Ordenar / recortarsorted(...), limit(n), distinct()
RecogertoList(), collect(Collectors.joining(", "))
Contar / sumarcount(), mapToInt(...).sum(), average()
Máximo / mínimomax(comparador) → Optional
Agruparcollect(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

[Java] ¿Qué imprime este código?
List<Integer> nums = List.of(3, 8, 5, 12, 7, 10);
List<Integer> r = nums.stream()
        .filter(n -> n > 5)
        .map(n -> n * 2)
        .sorted()
        .limit(2)
        .toList();
System.out.println(r);

[Java] ¿Qué interfaz funcional representa una función que recibe un valor y devuelve true o false?

[Java] ¿Qué imprime este código?
List<String> nombres = List.of("Ana", "Bruno", "Eva", "Carla");
long c = nombres.stream().filter(n -> n.length() == 3).count();
String j = nombres.stream()
        .filter(n -> n.length() > 3)
        .collect(Collectors.joining("-"));
System.out.println(c + " " + j);

[Java] ¿Por qué max(Comparator) devuelve un Optional en lugar del objeto directamente?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.