Tabla de Contenidos

06 - Programación Funcional

La programación funcional es un paradigma que se centra en tratar a las funciones como elementos de primer orden. En este enfoque, los programas se construyen principalmente a través de la composición y aplicación de funciones. A diferencia del paradigma imperativo, donde se enfatiza la ejecución secuencial de instrucciones que modifican el estado de la aplicación, en la programación funcional se prima la evaluación de expresiones y la ausencia de efectos secundarios.

1. Principios Fundamentales

Los principios que sustentan la programación funcional proporcionan una guía para escribir código modular, expresivo y menos propenso a errores:

2. Funciones Lambda e Interfaces Funcionales

Una función lambda es una expresión anónima que representa una implementación concisa de una interfaz funcional.

2.1 Sintaxis de las Expresiones Lambda

La sintaxis básica de una función lambda en Java consta de tres partes:

        //Consumer<String> printer = (phrase) -> IO.println(phrase);
        Consumer<String> printer = IO::println;

        Supplier<Integer> randomValue = () -> (int) (Math.random() * 100);

        Function<Integer, String> printNumber = (number) -> {
            if (number % 2 == 0) {
                return number + " es par";
            } else {
                return number + " es impar";
            }
        };

Cuando el cuerpo de una expresión lambda se limita únicamente a invocar un método ya existente pasando directamente los mismos parámetros recibidos, Java permite simplificar la sintaxis utilizando el operador de doble dos puntos (::).

En lugar de escribir la estructura completa de la función lambda (mensaje) → IO.println(mensaje), podemos hacer referencia directa al método mediante IO::println (o IO::println a partir de Java 23+).

Esta sintaxis no ejecuta el método inmediatamente, sino que le indica al compilador que reutilice el método existente para implementar la interfaz funcional correspondiente, logrando un código más limpio y legible.

2.2 Interfaces Funcionales

En Java, una interfaz funcional es aquella que contiene un único método abstracto. Constituye el tipo de dato subyacente para cualquier expresión lambda.

Para garantizar que una interfaz mantenga este contrato, se utiliza la anotación @FunctionalInterface:

@FunctionalInterface
public interface IntegerOperation {

    int operation(int a, int b);
}

public class IntegerOperationExample {

    static void main() {
        IntegerOperation integerOperation = Integer::sum;
        int result = integerOperation.operation(5, 10);
        IO.println("Resultado de la suma: " + result); // Imprime "Resultado de la suma: 15"

        integerOperation = Integer::compare;
        result = integerOperation.operation(5, 10);
        IO.println("Resultado de la comparación: " + result); // Imprime "Resultado de la comparación: -1"

        integerOperation = Integer::max;
        result = integerOperation.operation(5, 10);
        IO.println("Resultado de la maximización: " + result); // Imprime "Resultado de la maximización: 10"
    }
}

2.3 Interfaces Funcionales Estándar (java.util.function)

Java proporciona un conjunto de interfaces funcionales predefinidas en el paquete java.util.function para cubrir los casos de uso habituales:

Interfaz Firma del Método Propósito Ejemplo
Consumer<T> void accept(T t) Recibe un valor y realiza una acción sin devolver nada (efecto secundario). s → IO.println(s)
Predicate<T> boolean test(T t) Evalúa una condición sobre el argumento y devuelve un booleano. i → i > 0
Function<T, R> R apply(T t) Transforma un argumento de tipo T en un resultado de tipo R. s → s.length()
Supplier<T> T get() No recibe argumentos y genera/devuelve un valor de tipo T. () → “Hello World”

public class FunctionalStandard {

    static void main() {
        Consumer<String> printer = IO::println;
        printer.accept("Hello, World!"); // Imprime Hello, World!

        Predicate<Integer> isPositive = (number) -> number > 0;
        IO.println("¿Es 5 positivo? " + isPositive.test(5)); // Devuelve true
        IO.println("¿Es -3 positivo? " + isPositive.test(-3)); // Devuelve false

        Function<Integer, String> printNumber = (number) -> {
            if (number % 2 == 0) {
                return number + " es par";
            } else {
                return number + " es impar";
            }
        };
        IO.println(printNumber.apply(42)); // Devuelve "42 es par"
        IO.println(printNumber.apply(43)); // Devuelve "43 es impar"

        Supplier<String> greeting = () -> "¡Hola, Mundo!";
        IO.println(greeting.get()); // Imprime "¡Hola, Mundo!"
    }
}

Existen además variantes binarias como BiConsumer<T, U>, BiPredicate<T, U> o BiFunction<T, R U,> para operar sobre dos argumentos simultáneamente.

3. La API Stream (java.util.stream)

Un Stream representa una secuencia de elementos que permite la ejecución de operaciones de procesamiento de datos de forma declarativa, secuencial o paralela.

3.1 Ciclo de Vida y Características

3.2 Creación de Streams

//1. A partir de de colecciones
List<Integer> numberList = List.of(1, 2, 3, 4, 5);
Stream<Integer> numberStream = numberList.stream();

// 2. A partir de un Array
String[] nameArray = {"Alice", "Bob", "Charlie"};
Stream streamFromArray = Arrays.stream(nameArray);

// 3. A partir de valores directos
Stream streamFromValues = Stream.of(1, 2, 3, 4, 5);

Estos son solo algunos ejemplos de cómo crear Streams en Java a partir de diferentes fuentes de datos. La versatilidad de los Streams permite procesar una amplia variedad de datos de manera funcional y eficiente.

Además, la clase Stream proporciona métodos como toList() y toArray() que facilitan la conversión de un Stream en colecciones tradicionales de Java. Estas operaciones de recopilación son útiles para interactuar con APIs que esperan tipos de colección estándar, proporcionando una forma fluida y eficiente de manipular y transformar datos en Java.

public class StreamExample {

    static void main() {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        Stream<Integer> evenNumbers = numbers.stream()
                .filter(n -> n % 2 == 0);

        /* Error: La clase Stream no es iterable
        for (Integer evenNumber : evenNumbers) {
            IO.println("Even number: " + evenNumber);
        }*/

        // 
        List<Integer> evenNumbersList = numbers.stream()
                .filter(n -> n % 2 == 0)
                .toList();
        for (Integer evenNumber : evenNumbersList) {
            IO.println("Even number: " + evenNumber);
        }
    }
}

3.3 Operaciones Intermedias y Terminales

Operaciones Intermedias

Transforman el Stream en otro Stream diferente y se pueden encadenar indefinidamente. Al ser perezosas, no procesan la información de inmediato. Algunas de las más comunes:

        List<String>names = List.of("John", "Jane", "Bob", "Jack", "Doe");

        List<String> filteredNames = names.stream()
                .filter(name -> name.startsWith("J"))
                .peek(name -> IO.println("Nombre filtrado: " + name)) // Muestra los nombres filtrados
                .toList(); // Filtra los nombres que empiezan por J

        List<Integer> lenghts = names.stream()
                .map(String::length)
                .peek(length -> IO.println("Longitud del nombre: " + length)) // Muestra la longitud de cada nombre
                .toList(); // Mapea los nombres a su longitud

        List<String> sortedNames = names.stream()
                .sorted()
                .peek(name -> IO.println("Nombre ordenado: " + name)) // Muestra los nombres ordenados
                .toList(); // Ordena los nombres alfabéticamente

        List<Integer> numbers = List.of(1, 2, 2, 3, 4, 4, 5);
        List<Integer> uniqueNumbers = numbers.stream()
                .distinct()
                .peek(number -> IO.println("Número único: " + number)) // Muestra los números únicos
                .toList(); // Obtiene los números únicos

Operaciones Terminales

Inician el procesamiento de los datos y producen un resultado final o un efecto secundario. Algunas de las más comunes:

        List<Integer> numbers = List.of(1, 19, 45, 19, 34, 99, 86);

        // Crea una lista con los números pares
        List<Integer> evenNumbers = numbers.stream()
                .filter(n -> n % 2 == 0)
                .toList();


        // Imprimir cada número incrementado en 5
        numbers.forEach(n -> IO.println(n + 5));

        // Sumar todos los números usando reduce
        int sum = numbers.stream()
                .reduce(0, Integer::sum);

        // Filtrar números pares y contarlos
        long countEven = numbers.stream()
                .filter(n -> n % 2 == 0)
                .count();

        // Obtener el número máximo o lanza una excepción si la lista está vacía
        int max = numbers.stream()
                .max(Integer::compare)
                .orElseThrow(() -> new RuntimeException("Lista vacía"));


        List<Integer> emptyList = List.of();
        int max2 = emptyList.stream()
                .max(Integer::compare)
                .orElseThrow(() -> new RuntimeException("Lista vacía"));

        // Obtiene el primer elemento que es mayor que 20
        Optional<Integer> firstGreaterThan20 = numbers.stream()
                .filter(n -> n > 20)
                .findFirst();

        // Obtiene cualquier elemento que es mayor que 20
        Optional<Integer> anyGreaterThan20 = numbers.stream()
                .filter(n -> n > 20)
                .findAny();

        // Comprueba si AL MENOS existe un elemento mayor que 20
        boolean hasGreaterThan20 = numbers.stream()
                .anyMatch(n -> n > 20);

        // Comprueba si TODOS los elementos son mayor que 20
        boolean allGreaterThan20 = numbers.stream()
                .allMatch(n -> n > 20);

        // Comprueba si NINGÚN elemento es mayor que 20
        boolean noneGreaterThan20 = numbers.stream()
                .noneMatch(n -> n > 20);

4. La Clase Optional<T>

Optional<T> es un contenedor que puede o no albergar un valor no nulo. Su objetivo principal es actuar como tipo de retorno en métodos para representar la ausencia de un resultado sin lanzar excepciones NullPointerException.

Esta clase fue introducida para abordar el problema de las referencias nulas (null) que pueden llevar a NullPointerExceptions en tiempo de ejecución. Los Optional ofrecen una forma más segura y explícita de representar valores que pueden estar ausentes.

Su principal utilidad radica en:

Los Optional son una herramienta esencial en Java para mejorar la robustez y la claridad del código al manejar la posibilidad de valores nulos de manera más segura y eficiente.

4.1 Creación de Instancias

Optional<Integer> opt = Optional.of(5);

// Permite valores nulos (si es null, devuelve un Optional vacío)
Optional<Integer> optNullable = Optional.ofNullable(nullableValue);

// Instancia explícitamente vacía
Optional<Integer> optEmpty = Optional.empty();

4.2 Métodos Principales

        Optional<String> optionalString = Optional.of("Hola, mundo!");
        Optional<String> optionalStringEmpty = Optional.empty();
        
        if(optionalString.isPresent()) {
            IO.println(optionalString.get()); // Imprime "Hola, mundo!"
        }
        if (optionalStringEmpty.isPresent()) {
            IO.println(optionalStringEmpty.get());
        } else {
            IO.println("El valor no está presente"); // Imprime "El valor no está presente"
        }

        optionalString.orElse("Valor por defecto"); // Devuelve "Hola, mundo!"
        optionalStringEmpty.orElse("Valor por defecto"); // Devuelve "Valor por defecto"
        
        optionalString.orElseGet(() -> "Valor por defecto"); // Devuelve "Hola, mundo!"
        optionalStringEmpty.orElseGet(() -> "Valor por defecto"); // Devuelve "Valor por defecto"
        
        optionalString.orElseThrow(() -> new RuntimeException("Valor no presente")); // Devuelve "Hola, mundo!"
        optionalStringEmpty.orElseThrow(() -> new RuntimeException("Valor no presente")); // Lanza una excepción
        
        optionalString.ifPresent(IO::println); // Imprime "Hola, mundo!"
        optionalStringEmpty.ifPresent(IO::println); // No imprime nada

Es importante entender que Optional.empty() en Java no es equivalente a null. Mientras null indica la ausencia total de valor y puede causar NullPointerExceptions si no se maneja correctamente, Optional.empty() representa explícitamente la ausencia de un valor dentro de un contenedor Optional, sin la posibilidad de generar NullPointerExceptions.

Una ventaja clave de usar Optional es que un método que devuelve Optional<valor> siempre garantiza devolver un objeto Optional, ya sea que contenga un valor o esté vacío. Esto promueve un diseño más seguro y claro en el flujo de datos de la aplicación, ya que obliga a los desarrolladores a manejar explícitamente la posible ausencia de valores sin recurrir a valores null no controlados.
Es una buena práctica utilizar Optional únicamente como tipo de retorno para métodos que pueden o no devolver un valor, pero no debe usarse como tipo de parámetro de entrada para métodos. Entre otras razones, la clase Optional fue diseñado principalmente para mejorar la seguridad y la claridad al manejar valores de retorno potencialmente nulos. Utilizarlo como parámetro de entrada no se alinea con este propósito y puede llevar a prácticas confusas o propensas a errores.

Ejercicios

Ejercicio 1

Dada una lista de enteros List<Integer>, calcula la suma de todos sus elementos utilizando la API Stream.

Ejercicio 2

Dada una lista de enteros List<Integer>, devuelve una nueva lista que contenga únicamente los números pares.

Ejercicio 3

Dada una lista de cadenas de texto List<String>, devuelve una nueva lista con todas las cadenas convertidas a mayúsculas.

Ejercicio 4

Dada una lista de enteros List<Integer>, encuentra el número máximo utilizando Streams y Optional.

Ejercicio 5

Dada una lista de enteros List<Integer> y un valor límite int limit, cuenta cuántos elementos de la lista son estrictamente mayores que dicho valor.

Ejercicio 6

Dada una lista de cadenas List<String>, concatena todos sus elementos en una única cadena separada por comas utilizando Streams.

Ejercicio 7

Dada una lista de enteros List<Integer>, devuelve una lista con el cuadrado de cada uno de sus números.

Ejercicio 8

Dada una lista de enteros List<Integer> que contiene valores repetidos, devuelve una nueva lista sin duplicados.

Ejercicio 9

Dada una lista de enteros List<Integer> y un valor de referencia int threshold, devuelve el primer número de la lista que sea mayor que dicho valor envuelto en un Optional.

Ejercicio 10

Dada una lista de cadenas List<String> y una letra de inicio String prefix, cuenta cuántas cadenas de la lista empiezan por dicha letra (sin importar mayúsculas o minúsculas).