Skip to content

Programación Funcional y Streams en Java: El Arte de la Receta Perfecta

¡Bienvenidos, futuros desarrolladores!

Como vimos en el Tema 7: Estructuras Dinámicas de Datos, manejar grandes colecciones de información es el pan de cada día en el desarrollo de aplicaciones. Pero, ¿qué pasa cuando tienes que procesar esa información de forma eficiente, clara y sin que tu código parezca un laberinto de bucles for anidados?

Imaginad que vuestro código es un chef en una cocina. Antes, con la Programación Imperativa, le dabas instrucciones paso a paso: "Coge la lista de ingredientes (colección), pon un for para recorrerlos, si el ingrediente es 'vegetariano' (condición if), añádelo a la olla (nueva lista), y luego, cuando termines, cuéntame cuántos hay."

Con la Programación Funcional y la Stream API de Java, le dices al chef qué quieres, no cómo hacerlo. Le dices: "Quiero la cuenta de todos los ingredientes vegetarianos". El chef (Java) se encarga de la logística interna de la forma más optimizada posible. Es como pasar de escribir una receta a mano a usar un robot de cocina de última generación. ¡Es más rápido, más limpio y menos propenso a errores!

Este tema es el salto de calidad que vuestro código necesita. Es la llave para entender cómo se trabaja en el mundo profesional con Big Data, microservicios y aplicaciones modernas.

Conceptos Fundamentales

Antes de meternos en la cocina, definamos los ingredientes principales:

Definición: Programación Funcional (PF)

Es un paradigma de programación que trata la computación como la evaluación de funciones matemáticas y evita el cambio de estado y los datos mutables. En Java, se implementa principalmente a través de Expresiones Lambda y la Stream API.

Definición: Expresión Lambda

Es una forma concisa de representar una función anónima (una función sin nombre). Permite tratar la funcionalidad como un argumento de método o código como datos. Su introducción en Java 8 fue la puerta de entrada a la PF.

Definición: Interfaz Funcional

Cualquier interfaz de Java que contenga exactamente un método abstracto. Esto permite que se pueda usar una expresión lambda para implementar ese método, actuando como un "contrato" para la funcionalidad.

Definición: Stream (Flujo)

Una secuencia de elementos que soporta operaciones de agregación secuenciales y paralelas. No es una estructura de datos, sino una forma de procesar datos de una fuente (como una colección o un array) de manera declarativa y funcional.

Desarrollo y Ejemplos Prácticos

1. Introducción a la Programación Funcional

1.1. ¿Qué es la programación funcional?

La Programación Funcional (PF) se basa en dos pilares: las funciones puras y la inmutabilidad.

Una función pura es aquella que:
1. Siempre devuelve el mismo resultado para los mismos argumentos (determinismo).
2. No tiene efectos secundarios (no modifica variables externas, no hace I/O, etc.).

La inmutabilidad significa que, una vez que se crea un objeto o dato, no se puede cambiar. Si necesitas modificarlo, creas una nueva versión. Esto elimina la fuente principal de errores en la programación concurrente: el estado compartido.

1.2. Programación imperativa vs funcional

La diferencia es de mentalidad: el cómo frente al qué.

Característica Programación Imperativa (Tradicional) Programación Funcional (Moderna)
Enfoque Describe cómo se debe hacer la tarea (paso a paso). Describe qué se debe lograr (declarativo).
Estado Usa estado mutable (variables que cambian). Usa estado inmutable (los datos no cambian).
Estructuras Bucles (for, while), condicionales (if/else). Funciones, Lambdas, Streams.
Ejemplo Recorrer una lista con un for y un if. Usar lista.stream().filter().collect().
Concurrencia Difícil de manejar, propensa a race conditions. Más fácil y segura, debido a la inmutabilidad.

1.3. Ventajas de la programación funcional en Java

  1. Código más conciso y legible: Reducimos el boilerplate (código repetitivo) y nos centramos en la lógica de negocio.
  2. Mejor soporte para el paralelismo: Como las funciones son puras y los datos inmutables, Java puede ejecutar operaciones en paralelo de forma segura y automática (usando parallelStream()).
  3. Fácil de probar: Las funciones puras son triviales de testear, ya que no dependen de ningún estado externo.

1.4. Cuándo usar programación funcional y cuándo no

El Chef y el Pinche

La PF es excelente para el procesamiento de datos (filtrar, mapear, reducir). Es el chef que prepara la receta. La PI es necesaria para el control de flujo (lanzar excepciones, I/O, bucles de juego). Es el pinche que enciende el horno y friega los platos.

Usar PF (Streams y Lambdas):
* Transformación de colecciones de datos (ej. convertir una lista de objetos Usuario a una lista de sus nombres).
* Filtrado y búsqueda de elementos.
* Operaciones de agregación (sumas, promedios, conteos).
* Cualquier operación que pueda definirse como una pipeline de procesamiento.

No usar PF (Programación Imperativa):
* Operaciones que requieren modificar el estado de objetos externos de forma controlada (ej. un contador global).
* Operaciones de I/O complejas o que requieren manejo de excepciones específicas en cada paso.
* Bucles infinitos o lógica de control de flujo muy específica.

graph TD
    A[Inicio: Lista de Pedidos] --> B["¿Necesito transformar o filtrar datos?"];
    B -- Sí --> C["Usar Programación Funcional"];
    C --> D["Crear Stream"];
    D --> E["Aplicar Operaciones Intermedias (filter, map)"];
    E --> F["Aplicar Operación Terminal (collect, reduce)"];
    F --> G["Fin: Resultado"];
    B -- No --> H["Usar Programación Imperativa"];
    H --> I["Usar bucles for/while"];
    I --> G;

    style C fill:#ccf,stroke:#333,stroke-width:2px
    style H fill:#fcc,stroke:#333,stroke-width:2px

Question

  1. Si tienes que leer un archivo línea por línea y guardar cada línea en una base de datos, ¿qué paradigma usarías principalmente y por qué?
  2. Explica con tus propias palabras por qué la inmutabilidad facilita el trabajo con multithreading (hilos paralelos).
  3. ¿Qué significa que una función tenga un "efecto secundario"? Pon un ejemplo de código Java.
  4. ¿Podrías usar la Stream API para modificar in-place (en el mismo lugar) los elementos de una lista? ¿Por qué sí o por qué no?



2. Expresiones Lambda

2.1. ¿Qué es una expresión lambda?

Una expresión lambda es, esencialmente, una forma de pasar comportamiento como argumento a un método. Antes de Java 8, si querías pasar un bloque de código, tenías que crear una clase anónima, lo cual era verboso y tedioso. La lambda simplifica esto a su mínima expresión.

2.2. Sintaxis de las lambdas

La sintaxis básica es: (parámetros) -> { cuerpo }.

Tip

Si solo hay un parámetro, los paréntesis son opcionales. Si el cuerpo es una sola expresión, las llaves y la palabra return son opcionales. ¡Máxima concisión!

Ejemplo 1: Sin parámetros y una sola línea

// Antes: Clase anónima
Runnable tareaAntigua = new Runnable() {
    @Override
    public void run() {
        System.out.println("Tarea ejecutada.");
    }
};

// Ahora: Expresión Lambda
Runnable tareaLambda = () -> System.out.println("Tarea ejecutada con lambda.");

// Ejecución
new Thread(tareaLambda).start();

Ejemplo 2: Con un parámetro y una sola línea

// Interfaz funcional: Consumer<T> (acepta T, no devuelve nada)
Consumer<String> imprimir = mensaje -> System.out.println("Mensaje: " + mensaje);

imprimir.accept("Hola DAW"); // Salida: Mensaje: Hola DAW

2.3. Lambdas con una y varias líneas

Ejemplo 3: Lambda con dos parámetros y una sola línea (retorno implícito)

// Interfaz funcional: BinaryOperator<T> (acepta 2 T, devuelve 1 T)
BinaryOperator<Integer> sumar = (a, b) -> a + b;

int resultado = sumar.apply(10, 5); // 15
System.out.println("Suma: " + resultado);

Ejemplo 4: Lambda con varias líneas (retorno explícito)

Cuando el cuerpo de la lambda tiene más de una instrucción, o si necesitas lógica compleja (como un if/else), debes usar llaves {} y la palabra clave return.

// Interfaz funcional: Function<T, R> (acepta T, devuelve R)
Function<Integer, String> clasificarEdad = edad -> {
    if (edad < 18) {
        return "Menor de edad";
    } else if (edad < 65) {
        return "Adulto trabajador";
    } else {
        return "Jubilado de oro";
    }
};

System.out.println(clasificarEdad.apply(25)); // Salida: Adulto trabajador

2.4. Uso de lambdas con colecciones

Las lambdas brillan al trabajar con colecciones, especialmente con el método forEach (introducido en Java 8 en la interfaz Iterable).

Ejemplo 5: Iterar con forEach

List<String> lenguajes = Arrays.asList("Java", "Python", "JavaScript", "Kotlin");

// Antes: Bucle for-each
System.out.println("--- Imperativo ---");
for (String lang : lenguajes) {
    System.out.println(lang);
}

// Ahora: forEach con lambda
System.out.println("--- Funcional ---");
lenguajes.forEach(lang -> System.out.println(lang));

2.5. Variables efectivamente finales

Una lambda puede acceder a variables locales de su ámbito circundante (el método donde se define), pero con una restricción crucial: deben ser efectivamente finales.

Danger

Una variable es efectivamente final si no se modifica después de su inicialización. Si intentas modificar una variable local dentro de una lambda, el compilador te dará un error. Esto garantiza la inmutabilidad y previene efectos secundarios.

Ejemplo 6: Variable efectivamente final

String prefijo = "Lenguaje: "; // Esta variable es efectivamente final

Consumer<String> imprimirConPrefijo = lang -> {
    // prefijo = "Otro prefijo"; // ERROR: Variable local referenciada desde una expresión lambda debe ser final o efectivamente final
    System.out.println(prefijo + lang);
};

imprimirConPrefijo.accept("C++"); // Salida: Lenguaje: C++

Question

  1. ¿Cuál es la principal ventaja de usar una expresión lambda en lugar de una clase anónima para implementar un Comparator?
  2. Si tienes una lambda con un cuerpo de varias líneas, ¿qué debes recordar incluir obligatoriamente?
  3. ¿Qué significa que una variable sea "efectivamente final"? ¿Por qué Java impone esta restricción en las lambdas?
  4. Escribe una expresión lambda que tome un número entero y devuelva true si es par, y false si es impar.



3. Interfaces Funcionales

3.1. ¿Qué es una interfaz funcional?

Como se definió, es una interfaz con un único método abstracto. Java proporciona un conjunto de interfaces funcionales predefinidas en el paquete java.util.function que cubren los casos de uso más comunes.

3.2. Anotación @FunctionalInterface

Esta anotación es opcional, pero altamente recomendada. Si la añades a tu interfaz, el compilador de Java se asegurará de que solo tenga un método abstracto. Si intentas añadir un segundo, te avisará con un error.

Ejemplo 7: Creando una interfaz funcional propia

@FunctionalInterface
public interface Calculadora {
    int operar(int a, int b);
    // void otroMetodo(); // Si descomentas, el compilador da error por @FunctionalInterface
}

// Uso de la interfaz con una lambda
Calculadora multiplicacion = (x, y) -> x * y;
System.out.println("Resultado: " + multiplicacion.operar(6, 7)); // 42

3.3. Interfaces funcionales más usadas en Java

Java nos da cuatro interfaces funcionales básicas, que son la base de casi todas las operaciones de Streams:

Interfaz Método Abstracto Descripción Uso Típico
Predicate boolean test(T t) Acepta un argumento y devuelve un booleano. Filtrado (filter).
Function R apply(T t) Acepta un argumento de tipo T y devuelve un resultado de tipo R. Transformación (map).
Consumer void accept(T t) Acepta un argumento y no devuelve ningún resultado. Consumo de datos (forEach).
Supplier T get() No acepta argumentos y devuelve un resultado de tipo T. Generación de datos (creación de Streams).

3.4. Ejemplos prácticos de cada interfaz

Ejemplo 8: Predicate (Filtrado)

// Predicate: ¿Es mayor de 18?
Predicate<Integer> esMayorDeEdad = edad -> edad >= 18;

System.out.println("¿20 es mayor de edad? " + esMayorDeEdad.test(20)); // true
System.out.println("¿16 es mayor de edad? " + esMayorDeEdad.test(16)); // false

Ejemplo 9: Function (Mapeo/Transformación)

// Function: Convierte un String a su longitud (String -> Integer)
Function<String, Integer> longitudCadena = s -> s.length();

System.out.println("Longitud de 'DAW': " + longitudCadena.apply("DAW")); // 3

Ejemplo 10: Consumer (Consumo)

// Consumer: Imprime un objeto de tipo Usuario
class Usuario {
    String nombre;
    public Usuario(String n) { this.nombre = n; }
    public String toString() { return "Usuario: " + nombre; }
}

Consumer<Usuario> notificar = u -> System.out.println("Notificación enviada a " + u.nombre);

notificar.accept(new Usuario("Neo")); // Salida: Notificación enviada a Neo

Ejemplo 11: Supplier (Suministro/Generación)

// Supplier: Genera un número aleatorio
Supplier<Double> generadorAleatorio = () -> Math.random();

System.out.println("Número aleatorio: " + generadorAleatorio.get());

Question

  1. ¿Por qué es fundamental que una interfaz funcional tenga solo un método abstracto?
  2. Si necesitas una interfaz funcional que tome dos enteros y devuelva un String, ¿cuál de las interfaces básicas usarías o tendrías que crear una nueva?
  3. Imagina que estás creando un log. ¿Qué interfaz funcional usarías para escribir el mensaje en el log sin devolver nada?
  4. ¿Qué ocurre si añades un método default a una interfaz funcional? ¿Sigue siendo funcional?



4. Introducción a Streams

4.1. ¿Qué es un Stream?

Un Stream es una abstracción que nos permite procesar datos de forma declarativa. No almacena datos, sino que actúa como una tubería por donde fluyen los datos de una fuente (una colección, un array, un generador) y se procesan mediante una serie de operaciones.

El Tubería de Fontanería

Piensa en un Stream como una tubería de fontanería. Los datos son el agua. Las operaciones intermedias (filter, map) son los filtros y las bombas que modifican el flujo. La operación terminal (collect) es el grifo que abre el paso y te da el resultado final.

4.2. Diferencias entre colecciones y streams

Es crucial entender que un Stream no reemplaza a una Colección. Son complementarios.

Característica Colección (List, Set, Map) Stream
Propósito Almacenar y gestionar elementos. Procesar datos de una fuente.
Estado Los elementos se almacenan físicamente. No almacena elementos; es una vista lógica.
Recorrido Se puede recorrer múltiples veces. Solo se puede recorrer una vez.
Mutabilidad Generalmente mutable (se pueden añadir/quitar elementos). Inmutable; las operaciones devuelven un nuevo Stream.
Evaluación Eager (ansiosa): los datos se cargan inmediatamente. Lazy (perezosa): las operaciones se ejecutan solo cuando se pide el resultado final.

4.3. Creación de streams

Hay varias formas de obtener un Stream:

Desde colecciones

Cualquier interfaz que extienda Collection (como List o Set) tiene el método stream().

Ejemplo 12: Stream desde una lista

List<String> nombres = Arrays.asList("Ana", "Beto", "Carlos", "Diana");
Stream<String> streamNombres = nombres.stream();

Desde arrays

Se usa la clase auxiliar Arrays.

Ejemplo 13: Stream desde un array

String[] ciudades = {"Madrid", "Barcelona", "Sevilla"};
Stream<String> streamCiudades = Arrays.stream(ciudades);

Usando Stream.of()

Para crear un Stream a partir de un conjunto fijo de valores.

Ejemplo 14: Stream.of()

Stream<Integer> streamNumeros = Stream.of(1, 2, 3, 4, 5);

4.4. Streams secuenciales y paralelos

Por defecto, todos los Streams son secuenciales, lo que significa que las operaciones se ejecutan en un solo hilo.

Si usas parallelStream(), Java puede dividir el trabajo y ejecutar las operaciones en múltiples hilos simultáneamente. Esto es útil para grandes volúmenes de datos, pero puede tener overhead (coste de gestión de hilos) en colecciones pequeñas.

Ejemplo 15: Streams paralelos

List<String> listaGigante = obtenerListaDe1MillonDeElementos();

// Secuencial (por defecto)
listaGigante.stream().filter(s -> s.length() > 5).count();

// Paralelo (más rápido para grandes datos, pero cuidado con el orden)
listaGigante.parallelStream().filter(s -> s.length() > 5).count();
sequenceDiagram
    participant Fuente as "Fuente de Datos (List)"
    participant Intermedia1 as "Operación Intermedia 1 (filter)"
    participant Intermedia2 as "Operación Intermedia 2 (map)"
    participant Terminal as "Operación Terminal (collect)"

    Note over Fuente: Llamada a .stream()
    Fuente->>Intermedia1: Pide el primer elemento
    Note right of Intermedia1: Lazy Evaluation: No se ejecuta nada hasta el final
    Intermedia1->>Intermedia2: Pasa el elemento si cumple el filtro
    Intermedia2->>Terminal: Pasa el elemento transformado
    Terminal->>Terminal: Almacena el resultado

    loop Procesamiento de Elementos
        Fuente->>Intermedia1: Siguiente elemento
        Intermedia1->>Intermedia2: Pasa/Descarta
        Intermedia2->>Terminal: Pasa/Transforma
    end

    Note over Terminal: Llamada a .collect() fuerza la ejecución
    Terminal-->>Fuente: Devuelve el resultado final

Question

  1. ¿Cuál es la principal limitación de un Stream en cuanto a su uso? ¿Cómo se soluciona si necesitas procesar los datos varias veces?
  2. Explica el concepto de evaluación lazy (perezosa) en el contexto de los Streams. ¿Qué ventaja de rendimiento ofrece?
  3. ¿Cuándo deberías considerar usar parallelStream() en lugar de stream()? ¿Qué riesgo potencial existe?
  4. Si tienes un array de enteros, ¿cómo crearías un Stream que solo contenga los números pares?



5. Operaciones Intermedias

5.1. Características de las operaciones intermedias

Las operaciones intermedias son aquellas que:
1. Devuelven un nuevo Stream<T>.
2. Son lazy (perezosas): no se ejecutan hasta que se llama a una operación terminal.
3. Permiten encadenamiento (chaining): puedes concatenar tantas como necesites para formar una pipeline.

5.2. filter()

Acepta un Predicate<T> y devuelve un Stream que contiene solo los elementos que cumplen la condición.

Ejemplo 16: Filtrar nombres que empiezan por 'A'

List<String> nombres = Arrays.asList("Ana", "Beto", "Carlos", "Alba");

List<String> filtrados = nombres.stream()
    .filter(nombre -> nombre.startsWith("A")) // Predicate
    .collect(Collectors.toList());

System.out.println(filtrados); // Salida: [Ana, Alba]

5.3. map()

Acepta una Function<T, R> y devuelve un Stream de tipo R. Transforma cada elemento del Stream de un tipo a otro.

Ejemplo 17: Mapear a la longitud de la cadena

List<String> palabras = Arrays.asList("Java", "Stream", "Lambda");

List<Integer> longitudes = palabras.stream()
    .map(String::length) // Function (Method Reference)
    .collect(Collectors.toList());

System.out.println(longitudes); // Salida: [4, 6, 6]

5.4. flatMap()

Acepta una Function que devuelve un Stream y lo "aplana" en un único Stream. Es útil cuando tienes una colección de colecciones y quieres unirlas.

Ejemplo 18: Aplanar listas

List<List<String>> listaDeListas = Arrays.asList(
    Arrays.asList("A1", "A2"),
    Arrays.asList("B1", "B2")
);

List<String> aplanada = listaDeListas.stream()
    .flatMap(Collection::stream) // Convierte cada lista interna en un Stream y los une
    .collect(Collectors.toList());

System.out.println(aplanada); // Salida: [A1, A2, B1, B2]

5.5. sorted()

Ordena los elementos del Stream. Puede usar el orden natural de los elementos o un Comparator personalizado.

Ejemplo 19: Ordenar por longitud

List<String> lenguajes = Arrays.asList("Java", "Python", "C++", "Go");

List<String> ordenados = lenguajes.stream()
    .sorted(Comparator.comparing(String::length)) // Ordena por longitud de cadena
    .collect(Collectors.toList());

System.out.println(ordenados); // Salida: [Go, C++, Java, Python]
Criterio Código
Por longitud Comparator.comparing(String::length)
Alfabético Comparator.comparing(String::toString)
Alfabético (simple) Comparator.naturalOrder()

Comparator.comparing siempre responde a esta pregunta:

👉 ¿Qué valor uso para comparar dos elementos?

Longitud → String::length Texto → String::toString Precio → Producto::getPrecio Nombre → Alumno::getNombre

Orden inverso usando reversed()

List<String> lenguajes = Arrays.asList("Java", "Python", "C++", "Go");

List<String> ordenadosInverso = lenguajes.stream()
    .sorted(Comparator.comparing(String::toString).reversed())
    .collect(Collectors.toList());

System.out.println(ordenadosInverso);

5.6. distinct()

Devuelve un Stream que contiene solo los elementos únicos, eliminando duplicados (usa el método equals() de los objetos).

Ejemplo 20: Eliminar duplicados

List<Integer> numeros = Arrays.asList(1, 2, 2, 3, 1, 4, 5, 4);

List<Integer> unicos = numeros.stream()
    .distinct()
    .collect(Collectors.toList());

System.out.println(unicos); // Salida: [1, 2, 3, 4, 5]

5.7. limit() y skip()

limit(n) trunca el Stream para que no contenga más de n elementos. skip(n) descarta los primeros n elementos.

Ejemplo 21: Paginación (limit y skip)

List<String> usuarios = Arrays.asList("u1", "u2", "u3", "u4", "u5", "u6", "u7");
int pagina = 2;
int tamanoPagina = 3;

List<String> pagina2 = usuarios.stream()
    .skip((pagina - 1) * tamanoPagina) // Saltamos los primeros 3 (página 1)
    .limit(tamanoPagina) // Tomamos los siguientes 3
    .collect(Collectors.toList());

System.out.println(pagina2); // Salida: [u4, u5, u6]

Question

  1. Si encadenas un filter() y un map(), ¿cuándo se ejecuta realmente el código de ambos?
  2. Tienes una lista de objetos Producto y quieres obtener una lista de los nombres de los productos que cuestan más de 100€. Escribe la pipeline de Streams necesaria.
  3. ¿Cuál es la diferencia clave entre map() y flatMap()? ¿En qué escenario usarías flatMap()?
  4. ¿Cómo usarías skip() y limit() para obtener el segundo elemento más pequeño de un Stream de números enteros?



6. Operaciones Terminales

6.1. Características de las operaciones terminales

Las operaciones terminales son aquellas que:
1. Devuelven un resultado no-Stream (un valor, una colección, un booleano, etc.).
2. Fuerzan la ejecución de todas las operaciones intermedias encadenadas.
3. Consumen el Stream, por lo que no se pueden volver a usar.

6.2. forEach()

Realiza una acción para cada elemento del Stream. Es una operación terminal que no devuelve nada (void).

Ejemplo 22: Imprimir elementos

Stream.of("A", "B", "C")
    .forEach(System.out::println); // Method Reference a Consumer

6.3. collect()

Es la operación terminal más común. Acumula los elementos del Stream en un contenedor (una colección, un mapa, un String, etc.). Usa la clase Collectors (ver sección 7).

Ejemplo 23: Recolectar en una lista

List<Integer> cuadrados = Stream.of(1, 2, 3)
    .map(n -> n * n)
    .collect(Collectors.toList()); // Recolecta el resultado en una List

6.4. count()

Devuelve el número de elementos en el Stream como un long.

Ejemplo 24: Contar elementos filtrados

long totalPares = Stream.of(1, 2, 3, 4, 5, 6)
    .filter(n -> n % 2 == 0)
    .count(); // Salida: 3

6.5. anyMatch(), allMatch(), noneMatch()

Devuelven un boolean y son operaciones de cortocircuito (short-circuiting):

anyMatch(Predicate): Devuelve true si al menos un elemento cumple el Predicate.
allMatch(Predicate): Devuelve true si todos los elementos cumplen el Predicate.
noneMatch(Predicate): Devuelve true si ningún* elemento cumple el Predicate.

Ejemplo 25: Comprobación de coincidencias

List<String> frutas = Arrays.asList("Manzana", "Pera", "Naranja");

boolean hayPera = frutas.stream().anyMatch(f -> f.equals("Pera")); // true
boolean todoEmpiezaConM = frutas.stream().allMatch(f -> f.startsWith("M")); // false
boolean noHayKiwi = frutas.stream().noneMatch(f -> f.equals("Kiwi")); // true

6.6. findFirst() y findAny()

Devuelven un Optional<T> (ver sección 8).

  • findFirst(): Devuelve el primer elemento del Stream (útil en Streams secuenciales).
  • findAny(): Devuelve cualquier elemento del Stream (más eficiente en Streams paralelos).

Ejemplo 26: Encontrar el primer elemento

Optional<String> primerElemento = Stream.of("a", "b", "c")
    .findFirst();

primerElemento.ifPresent(s -> System.out.println("Encontrado: " + s)); // Encontrado: a

6.7. reduce()

Combina los elementos del Stream en un único resultado, aplicando una función de acumulación. Es la base de las operaciones de agregación.

Ejemplo 27: Sumar todos los números (reducción)

List<Integer> numeros = Arrays.asList(1, 2, 3, 4);

// 1. Con valor inicial (identity): 0 + (1+2+3+4) = 10
int suma = numeros.stream()
    .reduce(0, (acumulador, elemento) -> acumulador + elemento); // o Integer::sum

System.out.println("Suma total: " + suma); // 10

// 2. Sin valor inicial (devuelve Optional)
Optional<Integer> producto = numeros.stream()
    .reduce((a, b) -> a * b);

producto.ifPresent(p -> System.out.println("Producto total: " + p)); // 24

Question

  1. ¿Por qué no puedes llamar a una operación terminal dos veces en el mismo Stream?
  2. Si necesitas saber si todos los usuarios de una lista tienen un email válido, ¿qué operación terminal usarías?
  3. ¿Cuál es la diferencia de uso entre findFirst() y findAny()? ¿Cuándo es preferible uno sobre el otro?
  4. Escribe una operación reduce() que encuentre el String más largo de una lista de cadenas.



7. Collectors

7.1. ¿Qué es un Collector?

Un Collector es una interfaz que describe cómo se acumulan los elementos de un Stream en un resultado final. La clase Collectors (en plural) proporciona implementaciones predefinidas para los casos de uso más comunes.

7.2. Collectors.toList(), toSet(), toMap()

Ejemplo 28: toList y toSet

List<Integer> duplicados = Arrays.asList(1, 2, 2, 3, 1);

// toList: mantiene duplicados y orden
List<Integer> lista = duplicados.stream().collect(Collectors.toList()); // [1, 2, 2, 3, 1]

// toSet: elimina duplicados, sin orden garantizado
Set<Integer> conjunto = duplicados.stream().collect(Collectors.toSet()); // {1, 2, 3}

Ejemplo 29: toMap

Para crear un Map, necesitas dos funciones: una para la clave y otra para el valor.

class Persona {
    String dni;
    String nombre;
    public Persona(String d, String n) { this.dni = d; this.nombre = n; }
    public String getDni() { return dni; }
    public String getNombre() { return nombre; }
}

List<Persona> personas = Arrays.asList(new Persona("1A", "Luis"), new Persona("2B", "Marta"));

Map<String, String> mapaDniNombre = personas.stream()
    .collect(Collectors.toMap(Persona::getDni, Persona::getNombre));

// Salida: {1A=Luis, 2B=Marta}

7.3. Collectors.groupingBy()

Agrupa los elementos del Stream basándose en una función de clasificación. El resultado es un Map<K, List<T>>.

Ejemplo 30: Agrupar por longitud de palabra

List<String> palabras = Arrays.asList("sol", "luna", "mar", "estrella");

Map<Integer, List<String>> agrupadoPorLongitud = palabras.stream()
    .collect(Collectors.groupingBy(String::length));

/*
Salida:
{
    3: [sol, mar],
    4: [luna],
    8: [estrella]
}
*/

7.4. Collectors.partitioningBy()

Es un caso especial de agrupación que divide el Stream en dos grupos basándose en un Predicate: true o false. El resultado es un Map<Boolean, List<T>>.

Ejemplo 31: Particionar por par/impar

List<Integer> numeros = Arrays.asList(1, 2, 3, 4, 5);

Map<Boolean, List<Integer>> particion = numeros.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));

/*
Salida:
{
    false: [1, 3, 5], // Impares
    true: [2, 4]     // Pares
}
*/

7.5. Collectors.joining()

Concatena los elementos de un Stream de String en un único String, con opciones para separador, prefijo y sufijo.

Ejemplo 32: Unir con separador

List<String> tags = Arrays.asList("java", "streams", "lambda");

String resultado = tags.stream()
    .collect(Collectors.joining(" | ", "Tags: [", "]"));

// Salida: Tags: [java | streams | lambda]

7.6. Collectors.counting(), summing(), averaging()

Son downstream collectors que se usan a menudo dentro de groupingBy para realizar cálculos en los grupos.

Ejemplo 33: Agrupar y contar

List<String> ciudades = Arrays.asList("Madrid", "Barcelona", "Madrid", "Sevilla", "Barcelona");

Map<String, Long> conteoCiudades = ciudades.stream()
    .collect(Collectors.groupingBy(
        Function.identity(), // La clave es la ciudad misma
        Collectors.counting() // El valor es el conteo de esa ciudad
    ));

/*
Salida:
{
    Madrid: 2,
    Barcelona: 2,
    Sevilla: 1
}
*/

Question

  1. Si usas Collectors.toMap() y dos elementos tienen la misma clave, ¿qué ocurre? ¿Cómo podrías solucionarlo?
  2. ¿Cuál es la diferencia principal entre groupingBy() y partitioningBy()? ¿En qué situación usarías el segundo?
  3. Escribe la pipeline de Streams y Collectors para agrupar una lista de objetos Estudiante por su nota media (Aprobado/Suspenso) y, para cada grupo, contar cuántos estudiantes hay.
  4. ¿Cómo usarías Collectors.joining() para crear una cadena de CSV (valores separados por comas) a partir de una lista de nombres?



8. Optional

8.1. Problemas del null

El inventor de Java, Tony Hoare, llamó al null su "error de mil millones de dólares". El problema es que cualquier referencia de objeto puede ser null, lo que obliga a los desarrolladores a hacer constantes comprobaciones de if (objeto != null) para evitar la temida NullPointerException.

8.2. ¿Qué es Optional?

Optional<T> es un contenedor que puede o no contener un valor no nulo de tipo T. Su propósito es forzar al desarrollador a considerar explícitamente el caso en el que un valor podría estar ausente, eliminando la necesidad de devolver null.

Tip

Optional no está diseñado para reemplazar todas las referencias null. Su uso principal es como tipo de retorno para métodos que podrían no tener un resultado (ej. métodos de búsqueda).

8.3. Creación de Optional

Ejemplo 34: Creación de Optional

// 1. Optional vacío
Optional<String> vacio = Optional.empty();

// 2. Optional con valor (si el valor es null, lanza NullPointerException)
Optional<String> conValor = Optional.of("Hola");

// 3. Optional que acepta null (si es null, crea un Optional vacío)
String valorPosiblementeNulo = obtenerNombreAleatorio(); // Podría devolver null
Optional<String> seguro = Optional.ofNullable(valorPosiblementeNulo);

8.4. Métodos principales

isPresent()

Devuelve true si hay un valor presente. (Uso imperativo, menos recomendado).

ifPresent()

Acepta un Consumer y ejecuta la acción solo si el valor está presente. (Uso funcional, más recomendado).

Ejemplo 35: ifPresent

Optional<String> nombre = Optional.of("Manu");

nombre.ifPresent(n -> System.out.println("El nombre es: " + n)); // Se ejecuta
Optional.empty().ifPresent(n -> System.out.println("No se ejecuta")); // No se ejecuta

orElse()

Devuelve el valor si está presente; si no, devuelve el valor por defecto que se le pasa.

Ejemplo 36: orElse

String resultado1 = Optional.of("Valor").orElse("Por defecto"); // Valor
String resultado2 = Optional.empty().orElse("Por defecto"); // Por defecto

orElseGet()

Devuelve el valor si está presente; si no, llama a un Supplier para obtener el valor por defecto. Es más eficiente que orElse() si la creación del valor por defecto es costosa, ya que el Supplier solo se ejecuta si es necesario.

Ejemplo 37: orElseGet (Lazy)

// La función solo se llama si el Optional está vacío
String resultado3 = Optional.empty().orElseGet(() -> {
    System.out.println("Calculando valor por defecto...");
    return "Valor calculado";
}); // Salida: Calculando valor por defecto...

// orElseGet no se llama si hay valor
String resultado4 = Optional.of("Presente").orElseGet(() -> "Calculando..."); // No hay salida

orElseThrow()

Devuelve el valor si está presente; si no, lanza una excepción.

Ejemplo 38: orElseThrow

// Obtiene el valor o lanza una excepción personalizada
String valor = Optional.empty()
    .orElseThrow(() -> new NoSuchElementException("El usuario no existe en la base de datos."));

Question

  1. ¿Por qué se considera que Optional es una mejora sobre la comprobación de null?
  2. ¿Cuál es la diferencia de rendimiento entre orElse("defecto") y orElseGet(() -> "defecto")?
  3. Si necesitas ejecutar una acción (ej. guardar en base de datos) solo si el Optional contiene un valor, ¿qué método usarías?
  4. Escribe el código para obtener un valor de un Optional<Double> o lanzar una IllegalArgumentException si está vacío.



Aplicación en el Mundo Real

La Programación Funcional y los Streams no son solo un ejercicio académico; son el estándar de la industria para el procesamiento de datos a gran escala y la programación concurrente.

  1. Big Data y Procesamiento de Datos: Frameworks como Apache Spark o Hadoop utilizan un modelo de programación funcional (MapReduce) para distribuir el procesamiento de datos en clústeres. Al usar Streams en Java, estáis practicando la misma mentalidad que se usa para manejar petabytes de información.
  2. Microservicios y Reactividad: En arquitecturas de microservicios, la inmutabilidad y la ausencia de efectos secundarios son vitales para la estabilidad. Frameworks reactivos como Spring WebFlux se basan en el concepto de flujos de datos (Streams) para manejar miles de peticiones concurrentes sin bloquear hilos, algo imposible sin la PF.
  3. Limpieza de Código: En empresas como Google o Amazon, el código debe ser mantenible. Una pipeline de Streams de 5 líneas que filtra y mapea es infinitamente más fácil de leer y depurar que un bucle for de 20 líneas con múltiples if anidados. La legibilidad es un factor de calidad clave.

Al dominar los Streams, no solo estáis escribiendo código Java, sino que estáis aprendiendo a pensar como un ingeniero de software moderno, capaz de diseñar sistemas que escalan y que son inherentemente más seguros frente a los errores de concurrencia.

Para Saber Más

Para profundizar en estos temas y dominar la Stream API, os recomiendo consultar las siguientes fuentes de alta calidad:

  1. Documentación Oficial de Oracle sobre la Stream API: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html
  2. Guía de Estilo de Google para Java (Google Java Style Guide): https://google.github.io/styleguide/javaguide.html (Imprescindible para escribir código limpio y profesional).
  3. Baeldung - Java 8 Streams Tutorial: https://www.baeldung.com/java-8-streams (Uno de los mejores recursos con ejemplos prácticos y detallados).



EJERCICIOS


=> Ejercicios de Programación Funcional y Streams