====== 15 - Programación funcional ====== La **programación funcional** es un paradigma de programación 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 de instrucciones que modifican el estado del programa, en la programación funcional se hace hincapié en la evaluación de expresiones y en la ausencia de efectos secundarios. En la programación funcional, las funciones se tratan como objetos que pueden ser asignados a variables, pasadas como argumentos a otras funciones y devueltas como resultados de otras funciones. Además, se promueve el uso de funciones puras, que son aquellas que, dadas las mismas entradas, siempre producen los mismos resultados sin causar efectos secundarios observables. Este enfoque ofrece ventajas como la capacidad de escribir código más conciso y legible, facilitando la comprensión y el mantenimiento del mismo. Además, fomenta la escritura de programas más robustos y menos propensos a errores, al minimizar los efectos secundarios y mejorar la modularidad del código. La programación funcional se ha vuelto cada vez más popular en los últimos años debido a estas ventajas y a la creciente demanda de sistemas más escalables y fiables. ===== Principios fundamentales ===== Existen unos principios fundamentales que son la base de la programación funcional y proporcionan una guía para escribir código más claro, modular y libre de efectos secundarios. Integrar estos principios en el diseño y la implementación de programas en Java puede conducir a sistemas más robustos y mantenibles: * Funciones como **Ciudadanos de Primera Clase**: En la programación funcional, las funciones se consideran ciudadanos de primera clase, lo que significa que pueden tratarse como cualquier otro tipo de dato. Esto implica que las funciones pueden ser asignadas a variables, pasadas como argumentos a otras funciones y devueltas como resultados de otras funciones. Esta flexibilidad permite construir programas de manera más modular y expresiva. * **Inmutabilidad**: La inmutabilidad es un principio central en la programación funcional, que establece que una vez que se ha creado un objeto (como una variable o una estructura de datos), su estado no puede ser modificado. En lugar de realizar cambios en el estado de los objetos, se crean nuevos objetos con los valores actualizados. Esto ayuda a evitar efectos secundarios no deseados y hace que el código sea más predecible y fácil de razonar. * **Pureza de las Funciones**: Las funciones puras son aquellas que, dadas las mismas entradas, siempre producen los mismos resultados y no tienen efectos secundarios observables fuera de la función. Esto significa que una función pura no modifica el estado de variables externas ni realiza operaciones que dependan de datos externos mutables. Las funciones puras facilitan la comprensión del código y mejoran su testabilidad y modularidad. * **Recursión y Funciones de Orden Superior**: La programación funcional fomenta el uso de la recursión y las funciones de orden superior. La recursión se utiliza para realizar iteraciones en lugar de bucles, lo que puede llevar a una implementación más clara y concisa de algoritmos. Las funciones de orden superior son aquellas que pueden aceptar otras funciones como argumentos o devolver funciones como resultados, lo que permite la composición de funciones y la construcción de abstracciones más potentes. Una de las herramientas más poderosas para implementar estos principios son las **funciones lambda**. Las funciones lambda permiten definir bloques de código de manera concisa y expresiva, lo que facilita la creación de funciones de orden superior y la composición de operaciones. En el siguiente apartado, exploraremos en detalle qué son las funciones lambda y cómo se utilizan en Java para escribir código más claro y funcional. ===== Funciones lambda ===== Las **funciones lambda** son una característica fundamental de la programación funcional que permite tratar a las funciones como objetos de primera clase. En esencia, una función lambda es una expresión anónima que representa una función. Estas funciones pueden ser pasadas como argumentos a otras funciones, retornadas como resultados de otras funciones y almacenadas en variables. Su sintaxis concisa y su flexibilidad las hacen especialmente útiles para operaciones de alto nivel, como la manipulación de colecciones de datos y la implementación de patrones de diseño funcional. Las funciones lambda en Java se definen utilizando la siguiente sintaxis: (parámetros) -> {implementación} * Los parámetros se colocan entre paréntesis y separados por comas.\\ \\ * La flecha (->) separa los parámetros del cuerpo de la función lambda.\\ \\ * La expresión después de la flecha representa la implementación de la función lambda.\\ \\ * Si el tipo de parámetros puede ser inferido por el compilador, puedes omitirlos. Por ejemplo, (int a, int b) se puede abreviar como (a, b).\\ \\ * Si la función lambda tiene una sola expresión, puedes omitir las llaves {} y la palabra clave return.\\ \\ * La expresión será automáticamente el valor de retorno de la función lambda. Por ejemplo, si queremos crear una función lambda para sumar dos enteros: AddOperation addOperationFunctional = (a, b) -> a + b; Las funciones lambda se utilizan comúnmente en combinación con **interfaces funcionales**, que son interfaces que contienen un solo método abstracto. ===== Interfaces funcionales ===== En Java, una **interfaz funcional** es una interfaz que contiene un solo método abstracto. Estas interfaces son la base de la programación funcional en Java y son utilizadas principalmente para definir el tipo de una función lambda. Por ejemplo, podemos definir una interfaz funcional de la siguiente manera: @FunctionalInterface public interface AddOperation { Integer add(Integer a, Integer b); } En este ejemplo, //AddOperation// es una interfaz funcional que contiene un solo método abstracto llamado //add//, el cual toma dos parámetros enteros y devuelve un entero. La anotación //@FunctionalInterface// es opcional, pero es una buena práctica utilizarla para indicar que una interfaz es funcional y así evitar que se agreguen métodos abstractos adicionales en el futuro. Las interfaces funcionales se utilizan principalmente en dos situaciones: * **Declaración de Variables**: Puedes declarar variables de tipo interfaz funcional y asignarles referencias a funciones lambda que implementen el método abstracto de la interfaz: public class LambdaFunctions { // Without functional programming AddOperation addOperation = new AddOperation() { @Override public Integer add(Integer a, Integer b) { return a + b; } }; // With functional programming AddOperation addOperationFunctional = (a, b) -> a + b; } } * **Parámetros de Métodos**: Puedes pasar funciones lambda como argumentos a métodos que tomen parámetros del tipo de la interfaz funcional correspondiente: public Integer add(Integer a, Integer b, AddOperation addOperation) { return addOperation.add(a, b); } @Test void testAdd() { LambdaFunctions lambdaFunctions = new LambdaFunctions(); assertEquals(3, lambdaFunctions.add(1, 2, lambdaFunctions.addOperation)); } Además de facilitar la implementación de funciones lambda, las interfaces funcionales en Java son fundamentales para trabajar con la clase **Stream**. Esta clase se introdujo en Java para permitir el trabajo con programación funcional, ofreciendo una forma más eficiente y concisa de procesar colecciones de datos. Los //Streams// en Java permiten operaciones intermedias y terminales, lo que brinda flexibilidad en la manipulación de datos de manera secuencial o paralela. ==== Interfaces funcionales comunes ==== Desde Java 8, se han agregado muchas interfaces funcionales predefinidas en el paquete [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/function/package-summary.html|java.util.function]] para facilitar el desarrollo de aplicaciones utilizando programación funcional. Las interfaces funcionales se dividen en varios grupos principales según el propósito que cumplen. Los cuatro grupos principales son: * [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/function/Consumer.html|Consumer accept(T)]]: Acepta un único argumento y no devuelve resultado. Se utiliza típicamente para realizar operaciones con efectos secundarios: Consumer print = s -> System.out.println(s); print.accept("Hello, World!"); // Imprime: Hello, World! * [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/function/Predicate.html|Predicate test(T): Boolean]]: Recibe un argumento y devuelve un booleano. Se utiliza para evaluar condiciones: Predicate isPositive = i -> i > 0; System.out.println(isPositive.test(1)); // Imprime: true * [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/function/Function.html|Function apply: R]]: Acepta un argumento y devuelve un resultado. Se utiliza para transformar datos. Function square = i -> i * i; System.out.println(square.apply(3)); // Imprime: 9 * [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/function/Supplier.html|Supplier get(): T]]: No recibe ningún argumento y devuelve un resultado de tipo T. Se utiliza principalmente para obtener o generar valores cuando son requeridos. Supplier hello = () -> "Hello, World!"; System.out.println(hello.get()); //Imprime: "Hello, World!" Además de las cuatro interfaces funcionales principales (Consumer, Predicate, Function y Supplier), la librería //java.util.function// define muchas más interfaces para cubrir diferentes necesidades. Entre ellas se encuentran las variantes binarias como **BiConsumer**, **BiPredicate** y **BiFunction**, que aceptan dos argumentos. También hay interfaces especializadas para tipos primitivos, como **IntConsumer**, **DoublePredicate**, **LongSupplier**.... Estas interfaces adicionales permiten una mayor flexibilidad y eficiencia en la programación funcional en Java. ===== La clase Stream ===== En Java, un [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/stream/package-summary.html|Stream]] representa una secuencia de elementos que pueden ser procesados de manera secuencial o paralela. La clase Stream fue introducida específicamente para habilitar el uso de programación funcional en Java, proporcionando una forma más concisa y expresiva de trabajar con colecciones de datos. Por lo tanto, los Streams desempeñan un papel crucial en la adopción de paradigmas de programación funcional en Java. Los //Streams// tienen una serie de características básicas: * **Secuencia de Operaciones**: Los //Streams// permiten encadenar una secuencia de operaciones en una única expresión. Esto facilita la escritura de código más limpio y legible.\\ \\ * **Operaciones Intermedias y Terminales**: Los //Streams// admiten dos tipos de operaciones: intermedias y terminales. Las operaciones intermedias transforman o filtran los elementos del //Stream// y devuelven un nuevo //Stream//. Las operaciones terminales realizan una acción final en el //Stream// y producen un resultado final o un efecto secundario.\\ \\ * **Lazy Evaluation**: Los //Streams// utilizan evaluación perezosa (//lazy evaluation//), lo que significa que no realizan ninguna acción hasta que se invoque una operación terminal. Esto permite optimizar el rendimiento al evitar el procesamiento innecesario de elementos. Pueden ser creados a partir de una variedad de fuentes de datos, lo que los hace extremadamente flexibles y poderosos para el procesamiento de datos en programación funcional. ==== Creación y colección ==== Los //Streams// pueden ser creados a partir de una variedad de fuentes de datos, lo que los hace extremadamente flexibles y poderosos para el procesamiento de datos en programación funcional. * A partir de colecciones: Se pueden crear a partir de colecciones utilizando el método [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collection.html#stream()|stream()]] de la interfaz [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collection.html|Collection]] (o el método [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Collection.html#parallelStream()|parallelStream()]] para obtener un //Stream// paralelo): List numberList = List.of(1, 2, 3, 4, 5); Stream numberStream = numberList.stream(); * A partir de //arrays//: Los //Streams// se pueden crear a partir de arrays utilizando el método [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Arrays.html#stream(T%5B%5D)|Arrays.Stream()]]: Integer[] numberArray = {1, 2, 3, 4, 5}; Stream numberStream = Arrays.stream(numberArray); * A partir de valores individuales: Los //Streams// se pueden crear a partir de valores individuales utilizando el método //Stream.of()//: Stream numberStream = 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. ==== Métodos comunes ==== Los //Streams// en Java ofrecen una amplia variedad de métodos que permiten realizar operaciones sobre secuencias de datos. Estos métodos se dividen en dos tipos principales: * **operaciones intermedias**: transforman un //Stream// en otro //Stream// y son perezosas, es decir, no se ejecutan hasta que se realiza una operación terminal. * **operaciones terminales**: producen un resultado o un efecto secundario y finalizan el procesamiento del //Stream//. Esta combinación de operaciones ofrece flexibilidad y poder para manipular y procesar datos de manera eficiente en Java. === Operaciones intermedias más comunes=== == Filter== Filtra los elementos de un //Stream// según una función //Predicate//: List names = Arrays.asList("John", "Jane", "Jack", "Doe"); List filteredNames = names.stream() .filter(name -> name.startsWith("J")) .toList(); == Map== Transforma los elementos del //Stream// aplicando una función. List lengths = names.stream() .map(String::length) .toList(); **String::length** es equivalente a la función lambda **s -> s.length()**, pero de una forma más concisa y legible. Esta optimización es posible en Java cuando la función lambda consiste en invocar un único método en el argumento de la función. == Sorted== Ordena los elementos del //Stream// de acuerdo a un comparador. List sortedNames = names.stream() .sorted() .toList(); Cuando se aplica el método //sorted()// a un //Stream// de objetos que implementan la interfaz [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/lang/Comparable.html|Comparable]] (como String), el orden por defecto es el orden natural, que en el caso de las cadenas de texto (String) es el orden lexicográfico o alfabético. == distinct== Elimina los elementos duplicados del //Stream//. List numbers = Arrays.asList(1, 2, 2, 3, 4, 4, 5); List distinctNumbers = numbers.stream() .distinct() .toList(); === Operaciones terminales más comunes === == toList == Convierte los elementos del //Stream// en un List. List collectedNames = names.stream() .toList(); == toArray == Convierte los elementos del Stream en un array. String[] namesArray = names.stream() .toArray(String[]::new); == reduce == Reduce los elementos del Stream a un único valor usando una operación de acumulación. Integer sum = numbers.stream() .reduce(0, Integer::sum); También es posible calcular la suma de elementos en un //Stream// de enteros de manera más concisa usando el método //reduce// junto con una referencia de método. Por ejemplo, en lugar de especificar un valor inicial explícito como en en el ejemplo anterior, se puede usar //reduce// sin un valor inicial. Esto devuelve un **Optional** que contiene el resultado de la operación de suma: return integers .stream() .reduce(Integer::sum) .orElse(0); En el siguiente punto veremos la clase //Optional// con más detalle. == foreach == Realiza una acción para cada elemento del //Stream//. names.stream() .forEach(System.out::println); == count == Devuelve el número de elementos en el //Stream//. long count = names.stream() .filter(name -> name.startsWith("J")) .count(); == findFirst y findAny == //findFirst// devuelve el primer elemento del Stream. //findAny// devuelve algún elemento del Stream, útil en Streams paralelos. Optional firstName = names.stream() .findFirst(); ===== Optional ===== Los [[https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/Optional.html|Optionals]] en Java son un contenedor que puede o no contener un valor. 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: * **Evitar NullPointerExceptions**: Al encapsular un valor, //Optional// permite realizar operaciones de manera segura sin preocuparse por valores nulos no deseados. * **Claridad y Documentación del Código**: Al usar //Optional//, se hace explícito en la firma del método que un valor puede estar presente o ausente, mejorando la claridad y la documentación del código. * **Mejor Práctica de Diseño**: Fomenta el diseño de métodos que devuelven o manipulan valores opcionales, promoviendo un código más robusto y fácil de mantener. * **API más Expressiva**: Proporciona métodos útiles para trabajar con valores opcionales, como **orElse**, **orElseGet**, **orElseThrow**, **ifPresent**, entre otros, que facilitan manejar casos de valores nulos de manera más elegante. 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. ==== Creación ==== Para crear instancias de //Optional// en Java, existen varias formas dependiendo de si el valor que queremos encapsular puede ser nulo o no. A continuación, veremos las principales formas de crear //Optionals// en Java. === Optional.of(value) === Crea un //Optional// que contiene el valor especificado. Si el valor pasado como argumento es //null//, este método lanzará una //NullPointerException//. String name = "John"; Optional optionalName = Optional.of(name); === Optional.ofNullable(value) === Crea un //Optional// que contiene el valor especificado si no es nulo. Si el valor es //null//, devuelve un //Optional// vacío. String nullableName = null; Optional optionalNullableName = Optional.ofNullable(nullableName); === Optional.empty() === Devuelve un //Optional// vacío, es decir, un //Optional// que no contiene ningún valor. Optional emptyOptional = Optional.empty(); 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// 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. ==== Métodos más comunes ==== A continuación, exploraremos algunos de los métodos más comunes proporcionados por la clase //Optional// en Java. Estos métodos permiten realizar operaciones seguras y convenientes sobre los valores encapsulados dentro de un //Optional//, facilitando la gestión de casos donde un valor puede estar presente o ausente de manera explícita y sin riesgo de //NullPointerExceptions//. === isPresent() === Verifica si el Optional contiene un valor. Optional optionalName = Optional.of("John"); if (optionalName.isPresent()) { System.out.println("El nombre es: " + optionalName.get()); } === orElse(T other) === Devuelve el valor encapsulado si está presente; de lo contrario, devuelve el valor proporcionado como argumento. Optional optionalName = Optional.empty(); String name = optionalName.orElse("Valor Predeterminado"); === orElseGet(Supplier supplier) === Devuelve el valor encapsulado si está presente; de lo contrario, devuelve el resultado obtenido del //Supplier// proporcionado. Optional optionalName = Optional.empty(); String name = optionalName.orElseGet(() -> "Valor Generado"); === orElseThrow(Supplier exceptionSupplier) === Devuelve el valor encapsulado si está presente; de lo contrario, lanza una excepción proporcionada por el //Supplier//. Optional optionalName = Optional.empty(); String name = optionalName.orElseThrow(() -> new NoSuchElementException("No se encontró el nombre")); === get() === Devuelve el valor encapsulado si está presente; de lo contrario, lanza una excepción //NoSuchElementException//. Optional optionalName = Optional.of("Jane"); String name = optionalName.get(); === ifPresent(Consumer consumer) === Ejecuta la acción proporcionada si el valor está presente. Optional optionalName = Optional.of("Jack"); optionalName.ifPresent(name -> System.out.println("El nombre es: " + name)); ===== Ejercicios ===== ** Ejercicio 1 ** Dada una lista de enteros, usa programación funcional para calcular la suma de todos los elementos. ** Ejercicio 2 ** Dada una lista de enteros, devuelve una lista con solo los números pares. ** Ejercicio 3 ** Dada una lista de cadenas, convierte todas a mayúsculas usando programación funcional. ** Ejercicio 4 ** Dada una lista de enteros, usa programación funcional para encontrar el número máximo. ** Ejercicio 5 ** Dada una lista de enteros y un valor, cuenta cuántos elementos son mayores a ese valor. ** Ejercicio 6 ** Dada una lista de cadenas, usa programación funcional para concatenar todas las cadenas en una sola. ** Ejercicio 7 ** Dada una lista de enteros, devuelve una lista con los cuadrados de cada número. ** Ejercicio 8 ** Dada una lista de enteros, elimina los duplicados usando programación funcional. ** Ejercicio 9 ** Dada una lista de enteros, devuelve el primer número que sea mayor a un valor dado. ** Ejercicio 10 ** Dada una lista de cadenas, cuenta cuántas empiezan con una letra específica.