Lambdas y Streams en Java
En Java, las lambdas y streams son características introducidas en Java 8 que permiten un estilo de programación funcional.
Las lambdas se utilizan para crear funciones de forma concisa, permiten escribir código de forma más clara y compacta al definir rápidamente métodos en línea sin la necesidad de crear clases anónimas.
Los streams facilitan el procesamiento de colecciones de datos de forma declarativa y paralela.
Juntos, proporcionan una forma moderna y poderosa de manipular datos en Java.
Introducción a Lambdas
Funciones Anónimas
Las expresiones lambda son funciones sin nombre que permiten pasar comportamientos como argumentos, simplificando significativamente la implementación de interfaces funcionales.
Simplificación del Código
Reducen drásticamente la cantidad de código necesario para implementar interfaces con un único método, eliminando la necesidad de crear clases anónimas extensas.
Programación Funcional
Constituyen el fundamento de la programación funcional en Java, un paradigma que se centra en el uso de funciones puras y la inmutabilidad de los datos.
Sintaxis Básica de Lambdas
1
Estructura Básica
(parámetros) -> { cuerpo de la función }
  • Parámetros: Variables de entrada (pueden omitirse los tipos)
  • Flecha: Operador -> que separa parámetros del cuerpo
  • Cuerpo: Código que se ejecutará
2
Sintaxis Simplificada
Para un único parámetro, se pueden omitir los paréntesis
  • x -> x * x
3
Expresiones Concisas
Para una única instrucción, se pueden omitir las llaves y el return
  • (a, b) -> a + b
4
Expresiones con Bloque
Para múltiples instrucciones, se requieren llaves y return explícito
  • (a, b) -> { int resultado = a + b; return resultado; }
Ejemplos de Lambdas
- Ejemplo 1: (x) -> x * 2 - Ejemplo 2: (nombre) -> { return "Hola, " + nombre; } - Ejemplo 3: (numeros) -> { int suma = 0; for (int num : numeros) { suma += num; } return suma; } - Ejemplo 4: () -> System.out.println("Hola, mundo!") - Ejemplo 5: (a, b, c) -> a * b + c - Ejemplo 6: (texto) -> "¡Hola " + texto + "!" - Ejemplo 7: (array) -> { int max = array[0]; for (int num : array) { if (num > max) max = num;} return max;
Referencias a métodos
La referencia a métodos (Method References) es una característica introducida en Java 8 que proporciona una sintaxis abreviada para expresar lambdas que simplemente invocan un método existente.
Es una forma de expresión lambda más compacta y legible cuando el lambda solo llama a un método existente.
  • Relación con Expresiones Lambda:
  • Son una forma simplificada de expresiones lambda
  • Implementan interfaces funcionales de la misma manera que las lambdas
  • Se basan en el principio de "method descriptor compatibility"
  • Fundamentos del Diseño:
  • Siguen el principio DRY (Don't Repeat Yourself)
  • Promueven la reutilización de código existente
  • Se alinean con el paradigma de programación funcional
Tipos de referencias de métodos
1
Referencia a método estático (Class::staticMethod)
Function<String, Integer> parser = Integer::parseInt;
2
Referencia a método de instancia de un objeto particular (object::instanceMethod)
String str = "ejemplo"; Supplier<Integer> lengthSupplier = str::length;
3
Referencia a método de instancia de un tipo arbitrario (Class::instanceMethod)
Function<String, String> upperCase = String::toUpperCase;
4
Referencia a constructor (Class::new)
Supplier<List<String>> listSupplier = ArrayList::new;
Sintaxis de referencias de métodos
La sintaxis general sigue el patrón:
Copy
ClassOrInstance::methodName
Donde:
  • :: es el operador de referencia a método
  • El lado izquierdo especifica la clase o instancia
  • El lado derecho especifica el nombre del método (sin parámetros)
Equivalencias con Expresiones Lambda
Contextos de Uso Válidos
Las referencias a métodos son válidas cuando:
  1. El método referenciado es compatible con la interfaz funcional esperada
  1. Coinciden en:
  • Número de parámetros
  • Tipos de parámetros
  • Tipo de retorno
  • Excepciones lanzadas
Interfaces Funcionales
Una interfaz funcional es una interfaz que contiene exactamente un método abstracto (puede tener métodos default o estáticos adicionales). Se usan principalmente para expresar lambdas y referencias a métodos.
Las interfaces funcionales son una característica importante introducida en Java 8 que permite la programación funcional en Java.
Características principales
  • Deben tener exactamente un método abstracto
  • Pueden anotarse con @FunctionalInterface (opcional pero recomendado)
  • Pueden implementarse usando expresiones lambda
  • Java provee varias interfaces funcionales predefinidas en java.util.function
Interfaces funcionales predefinidas
Java 8 incluye varias interfaces funcionales comunes en el paquete java.util.function:
Function
Transforma un objeto de tipo T en otro de tipo R.
  • Tipo: Function<T, R>
  • Método: R apply(T t)
  • Ejemplo: Function<String, Integer> longitud = s -> s.length();
Predicate
Evalúa una condición sobre un objeto de tipo T y devuelve un valor booleano.
  • Tipo: Predicate<T>
  • Método: boolean test(T t)
  • Ejemplo: Predicate<String> esLargo = s -> s.length() > 10;
Consumer
Acepta un objeto de tipo T y realiza una operación sin devolver ningún resultado.
  • Tipo: Consumer<T>
  • Método: void accept(T t)
  • Ejemplo: Consumer imprimir = s -> System.out.println(s);
Supplier
No recibe parámetros pero produce un resultado de tipo T.
  • Tipo: Supplier<T>
  • Método: T get()
  • Ejemplo: Supplier<Double> numeroAleatorio = () -> Math.random();
UnaryOperator
Tipo especial de Function donde el argumento y el resultado son del mismo tipo
  • Tipo: UnaryOperator<T>
  • Método: T apply(T t)
  • Ejemplo: UnaryOperator<String> toUpper = s -> s.toUpperCase();
BinaryOperator
Acepta dos argumentos del mismo tipo y devuelve un resultado del mismo tipo
  • Tipo: BinaryOperator<T>
  • Método: T apply(T t1, T t2)
  • Ejemplo: BinaryOperator<Integer> sum = (a, b) -> a + b;
Creación de interfaces funcionales propias
@FunctionalInterface interface Calculadora { int operacion(int a, int b); } public class Test { public static void main(String[] args) { Calculadora suma = (x, y) -> x + y; Calculadora resta = (x, y) -> x - y; System.out.println(suma.operacion(5, 3)); // 8 System.out.println(resta.operacion(5, 3)); // 2 } }
Introducción a Streams
Origen
Creación del stream a partir de una colección, array u otra fuente de datos.
Operaciones Intermedias
Transformaciones que devuelven un nuevo stream (filter, map, sorted).
Operación Terminal
Produce un resultado final o efecto secundario (collect, forEach, reduce).
Los streams representan un pipeline de procesamiento de datos que permite operar sobre colecciones de manera declarativa.
A diferencia del enfoque imperativo tradicional, donde especificamos cómo realizar una tarea, con streams expresamos qué queremos lograr, delegando los detalles de implementación al framework.
Características fundamentales de los streams:
  • No almacenan datos (son una vista de la fuente)
  • No modifican la colección original (son inmutables)
  • Son perezosos (se evalúan solo cuando es necesario)
  • Pueden ser finitos o infinitos
Creación de Streams
A partir de Colecciones
Toda implementación de Collection (List, Set, Queue, etc.) puede generar un Stream:
// Desde List
List lista = Arrays.asList("a", "b", "c");
Stream streamDesdeLista = lista.stream();
// Desde Set
Set numeros = Set.of(1, 2, 3);
Stream streamDesdeSet = numeros.stream();
// Desde cualquier Collection
Collection valores = new ArrayList<>(Arrays.asList(1.1, 2.2, 3.3));
Stream streamDesdeCollection = valores.stream();
A partir de Arrays
La clase Arrays proporciona métodos para crear streams directamente desde arrays.
// Array de objetos
String[] arrayStrings = {"a", "b", "c"};
Stream streamDesdeArray = Arrays.stream(arrayStrings);
// Array primitivo (devuelve un IntStream, LongStream o DoubleStream especializado)
int[] numerosPrimitivos = {1, 2, 3};
IntStream streamDesdePrimitivos = Arrays.stream(numerosPrimitivos);
Métodos Estáticos de Stream
La interfaz Stream ofrece métodos factory para crear streams de diferentes maneras.
Stream.of() : Crea un Stream con unos elementos concretos o incluso vacío
Stream streamDirecto = Stream.of("a", "b", "c"); // Stream de elementos concretos
Stream<?> streamVacio = Stream.empty(); // Stream vacío
Stream.iterate(): Para secuencias
// Stream infinito (limitado con limit())
Stream numerosPares = Stream.iterate(0, n -> n + 2) .limit(10); // 0, 2, 4, 6, ..., 18
// Versión moderna (Java 9+) con condición de parada
Stream numerosMenores100 = Stream.iterate(0, n -> n < 100, n -> n + 5);
Stream.generate(): Para valores generados
// Stream infinito de números aleatorios
Stream aleatorios = Stream.generate(Math::random) .limit(5);
// Stream de elementos constantes
Stream holas = Stream.generate(() -> "Hola") .limit(3); // "Hola", "Hola", "Hola"
Operaciones Intermedias
Las operaciones intermedias son perezosas: no realizan ningún procesamiento hasta que se invoca una operación terminal.
Esto permite la optimización interna del pipeline, como la fusión de operaciones o la omisión de procesamiento innecesario.
filter()
Selecciona elementos que cumplen un predicado:
stream.filter(n -> n > 0)
map()
Transforma cada elemento:
stream.map(s -> s.toUpperCase())
sorted()
Ordena los elementos:
stream.sorted() o stream.sorted(Comparator.reverseOrder())
distinct()
Elimina elementos duplicados:
stream.distinct()
limit()/skip()
Limita o salta elementos:
stream.limit(10).skip(2)
Una característica clave es que cada operación intermedia devuelve un nuevo stream, lo que permite el encadenamiento de múltiples operaciones en una única expresión fluida y legible.
Operaciones Terminales
Las operaciones terminales son las que finalmente producen un resultado concreto a partir del stream. Inician el procesamiento de todos los elementos a través del pipeline y finalizan el ciclo de vida del stream. Después de ejecutar una operación terminal, el stream se considera consumido y no puede reutilizarse.
collect()
Acumula elementos en una colección
reduce() / sum() / min() / max()
Agregación de elementos
anyMatch() / allMatch() / noneMatch()
Verificación de condiciones
forEach() / forEachOrdered()
Iteración sobre elementos
count() / findFirst() / findAny()
Operaciones simples
El método collect() es particularmente potente, ya que permite recopilar los elementos del stream en estructuras de datos como listas, conjuntos o mapas, aplicando diferentes estrategias de acumulación definidas por la clase Collectors.
Ejemplos Prácticos de Filtrado
El uso de Predicate permite modularizar condiciones de filtrado para su reutilización o combinación mediante métodos como and(), or() y negate():
Predicate esCaro = p -> p.getPrecio() > 100; Predicate esElectronica = p -> p.getCategoria().equals("Electrónica"); Predicate filtroCompuesto = esCaro.and(esElectronica); List resultado = productos.stream() .filter(filtroCompuesto) .collect(Collectors.toList());
Transformación de Datos
Transformación Simple
Convertir cada elemento aplicando una función sencilla
  • String a mayúsculas: .map(s -> s.toUpperCase())
  • Obtener longitud: .map(String::length)
  • Doblar números: .map(n -> n * 2)
Transformación de Objetos
Extraer o calcular propiedades de objetos complejos
  • Extraer nombres: .map(Persona::getNombre)
  • Calcular edad: .map(p -> p.calcularEdad())
  • Formatear datos: .map(p -> p.getNombre() + " (" + p.getEdad() + ")")
Transformación de Tipos
Convertir objetos de un tipo a otro (mapeo de entidades)
  • Entidad a DTO: .map(entidad -> convertirADTO(entidad))
  • Objeto a JSON: .map(obj -> serializarAJSON(obj))
  • Cadena a entero: .map(Integer::parseInt)
Transformaciones Anidadas
Procesar colecciones dentro de objetos
  • flatMap para aplanar colecciones anidadas
  • Procesar relaciones uno a muchos
  • Combinar datos de múltiples fuentes
La operación map() es una de las herramientas más versátiles en streams, ya que permite transformar cada elemento de la secuencia aplicando una función.
La función recibe un elemento como entrada y produce un elemento de salida, que puede ser del mismo tipo o de un tipo diferente.
Reducción y Agregación
Reducción Simple
El método reduce() combina todos los elementos de un stream en un único resultado mediante una operación binaria.
// Sumar todos los números int suma = numeros.stream().reduce(0, (a, b) -> a + b); // O usando método de referencia int suma = numeros.stream().reduce(0, Integer::sum);
Reducción con Identidad
El valor de identidad es el elemento neutro de la operación (0 para suma, 1 para multiplicación).
// Multiplicar todos los números int producto = numeros.stream().reduce(1, (a, b) -> a * b);
Reducción de Objetos Complejos
Para objetos más complejos, se puede utilizar un acumulador y un combinador.
// Encontrar el precio total BigDecimal total = productos.stream().map(Producto::getPrecio) .reduce(BigDecimal.ZERO, BigDecimal::add);
La reducción es una operación fundamental en la programación funcional que permite combinar múltiples valores en uno solo. En Java, el método reduce() permite implementar operaciones como suma, multiplicación, concatenación o cualquier otra función de agregación personalizada.
Colectores Avanzados
La clase Collectors proporciona implementaciones de la interfaz Collector que ofrecen operaciones sofisticadas para acumular elementos en estructuras de datos complejas, realizar agrupaciones, particiones y cálculos estadísticos.
Estos colectores se utilizan principalmente con el método collect() de Stream.
1
Collectors.toList() / toSet() / toMap()
Recopila elementos en la estructura de datos especificada:
List lista = stream.collect(Collectors.toList()); Map mapa = personas.stream().collect(Collectors.toMap(Persona::getId, Persona::getNombre));
2
Collectors.groupingBy()
Agrupa elementos según una función clasificadora:
Map porDepartamento = empleados.stream().collect(Collectors.groupingBy( Empleado::getDepartamento));
3
Collectors.partitioningBy()
Particiona elementos según un predicado:
Map porSalario =empleados.stream().collect(Collectors.partitioningBy( e -> e.getSalario() > 30000));
4
Collectors para Resumir
Generan estadísticas descriptivas:
DoubleSummaryStatistics stats = productos.stream() .collect(Collectors.summarizingDouble(Producto::getPrecio)); // stats.getAverage(), getCount(), // getMax(), getMin(), getSum()
Manejo de Opcionales
¿Qué es Optional y para qué sirve?
Optional es un contenedor que puede contener o no un valor no nulo.
Fue introducido en Java 8 para representar explícitamente la posibilidad de ausencia de valor, evitando así las excepciones NullPointerException y haciendo el código más claro respecto al manejo de valores potencialmente nulos.
Creación de objetos Optional
  • Optional.empty(): Crea un Optional vacío
  • Optional.of(valor): Crea un Optional con un valor no nulo (lanza NullPointerException si valor es null)
  • Optional.ofNullable(valor): Crea un Optional que puede contener un valor nulo (devuelve Optional.empty() si valor es null)
Métodos para extraer valores
  • get(): Obtiene el valor si existe, de lo contrario lanza NoSuchElementException
  • orElse(otrovalor): Devuelve el valor si existe, de lo contrario devuelve otrovalor
  • orElseGet(Supplier): Devuelve el valor si existe, de lo contrario invoca al Supplier
  • orElseThrow(Supplier): Devuelve el valor si existe, de lo contrario lanza la excepción proporcionada por el Supplier
Métodos para procesar valores
  • isPresent(): Devuelve true si existe un valor
  • ifPresent(Consumer): Ejecuta el Consumer si existe un valor
  • filter(Predicate): Aplica un filtro al valor si existe
  • map(Function): Transforma el valor si existe
  • flatMap(Function): Transforma el valor si existe, donde la función devuelve otro Optional
Ejemplo práctico con streams
Optional resultado = personas.stream() .filter(p -> p.getEdad() > 30) .map(Persona::getEmail) .filter(email -> email.contains("@")) .findFirst(); String email = resultado.orElse("Sin email válido");
La clase Optional se integra perfectamente con streams, especialmente en operaciones que pueden no producir resultados, como findFirst(), findAny(), min() o max(). El uso correcto de Optional mejora la robustez del código y comunica claramente la intención del programador respecto al manejo de valores opcionales.
Mejores Prácticas
La adopción de un estilo funcional con lambdas y streams requiere un cambio de mentalidad.
Es importante recordar que el objetivo final es mejorar la legibilidad, mantenibilidad y expresividad del código, no solo reducir su longitud o maximizar su rendimiento a toda costa.
Estilo y Legibilidad
  • Preferir métodos de referencia cuando sea posible:
  • String::length vs s -> s.length()
  • Mantener lambdas cortas y enfocadas en una única operación
  • Dividir cadenas de operaciones complejas en líneas separadas
  • Usar nombres significativos para las variables de lambda
Rendimiento
  • Considerar el uso de IntStream, LongStream o DoubleStream para tipos primitivos
  • Preferir operaciones terminales cortas (findFirst() vs collect())
  • Medir el rendimiento antes de optimizar
Buenas Prácticas Funcionales
  • Evitar efectos secundarios en operaciones de stream
  • Preferir inmutabilidad cuando sea posible
  • Favorecer funciones puras que dependen solo de sus entradas
  • Extraer predicados complejos a métodos nombrados
Estructuración
  • Agrupar operaciones relacionadas
  • Extraer pipelines complejos a métodos reutilizables
  • Considerar la creación de clases utilitarias para operaciones comunes
  • Documentar las operaciones complejas
Ejemplo Completo
import java.math.BigDecimal; import java.time.LocalDate; import java.time.Period; import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class EjemploCompleto { public static void main(String[] args) { // Lista de empleados para procesar List empleados = Arrays.asList( new Empleado("Ana", "Desarrollo", LocalDate.of(1985, 5, 21), new BigDecimal("45000")), new Empleado("Carlos", "Marketing", LocalDate.of(1992, 8, 10), new BigDecimal("38000")), new Empleado("Elena", "Desarrollo", LocalDate.of(1989, 3, 15), new BigDecimal("47000")), new Empleado("David", "Ventas", LocalDate.of(1991, 7, 8), new BigDecimal("52000")), new Empleado("Sofía", "Desarrollo", LocalDate.of(1995, 11, 23), new BigDecimal("41000")), new Empleado("Miguel", "Marketing", LocalDate.of(1988, 2, 14), new BigDecimal("39500")) ); // Calcular la edad promedio por departamento Map edadPromedioPorDepartamento = empleados.stream() .collect(Collectors.groupingBy( Empleado::getDepartamento, Collectors.averagingInt(e -> Period.between(e.getFechaNacimiento(), LocalDate.now()).getYears()) )); System.out.println("Edad promedio por departamento:"); edadPromedioPorDepartamento.forEach((depto, edad) -> System.out.printf("%s: %.1f años\n", depto, edad)); // Encontrar el empleado mejor pagado por departamento Map mejorPagadoPorDepartamento = empleados.stream() .collect(Collectors.groupingBy( Empleado::getDepartamento, Collectors.collectingAndThen( Collectors.maxBy((e1, e2) -> e1.getSalario().compareTo(e2.getSalario())), opcional -> opcional.orElse(null) ) )); System.out.println("\nEmpleado mejor pagado por departamento:"); mejorPagadoPorDepartamento.forEach((depto, emp) -> System.out.printf("%s: %s (%s)\n", depto, emp.getNombre(), emp.getSalario())); // Calcular estadísticas salariales System.out.println("\nEstadísticas salariales:"); empleados.stream() .map(Empleado::getSalario) .mapToDouble(BigDecimal::doubleValue) .summaryStatistics() .forEach(stats -> { System.out.printf("Mínimo: %.2f\n", stats.getMin()); System.out.printf("Máximo: %.2f\n", stats.getMax()); System.out.printf("Promedio: %.2f\n", stats.getAverage()); System.out.printf("Total: %.2f\n", stats.getSum()); }); } } class Empleado { private final String nombre; private final String departamento; private final LocalDate fechaNacimiento; private final BigDecimal salario; // Constructor, getters y setters... }
Problemas Comunes
NullPointerException en Operaciones de Stream
Es común enfrentar NullPointerException al trabajar con colecciones que contienen elementos nulos o al acceder a propiedades de objetos que podrían ser nulos.
Solución: Utilizar filter para eliminar elementos nulos antes de procesarlos, o mapear con Optional para gestionar valores potencialmente nulos.
stream.filter(Objects::nonNull) .map(obj -> Optional.ofNullable(obj.getPropiedad())) .filter(Optional::isPresent) .map(Optional::get)
Stream ya Consumido
Error "stream has already been operated upon or closed" al intentar utilizar un stream después de una operación terminal.
Solución: Los streams no son reutilizables. Se debe crear un nuevo stream cada vez que se necesite procesar la colección.
// Incorrecto: Stream stream = lista.stream(); stream.forEach(System.out::println); long count = stream.count(); // Error! // Correcto: lista.stream().forEach(System.out::println); long count = lista.stream().count();
Integración con Bibliotecas
Spring Framework
Integración completa con lambdas y streams para operaciones de datos
JPA y Hibernate
Uso de streams para procesar resultados de consultas
Programación Reactiva
Project Reactor y RxJava aprovechan conceptos similares a streams
Servicios REST
Procesamiento eficiente de datos en APIs
El ecosistema Java moderno ha adoptado ampliamente el paradigma funcional introducido con lambdas y streams. Frameworks populares como Spring han integrado estas características en sus APIs, facilitando operaciones como:
  • Transformación de entidades a DTOs en capas de servicio
  • Filtrado y procesamiento de datos en repositorios
  • Implementación de callbacks y handlers con lambdas
  • Definición de rutas y controladores en frameworks web
La programación reactiva, una tendencia creciente en el desarrollo Java, utiliza conceptos similares a los streams para manejar flujos de datos asíncronos con operadores como map, filter y reduce. Bibliotecas como Project Reactor (usado en Spring WebFlux) y RxJava permiten crear aplicaciones altamente escalables utilizando patrones funcionales.