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.

Made with