Fundamentos de programación funcional en Java


Introducción

La programación funcional tiene décadas existiendo y ofrece beneficios en ciertas áreas del desarrollo de software. En fechas recientes, su incorporación en lenguajes de programación modernos ha potenciado su popularidad al combinarse en entornos basados en el paradigma de programación orientado a objetos.

Los lenguajes como Java que son fundamentalmente orientados a objetos toman conceptos distintivos de la programación funcional y los incorporan de manera sinérgica permitiendo al programador extender las posibilidades en desarrollo, así, es posible sacar provecho del uso de funciones, lambdas y otros conceptos en una solución donde los datos tienen una estructura basada en clases, sacando así lo mejor de ambos paradigmas y combinándolos en una aplicación, siguiendo estándares y tendencias modernas de desarrollo de software.


Explicación

Interfaces funcionales 

De acuerdo con Lecessi (2019) las interfaces funcionales son aquellas que declaran un único método abstracto y se diferencian de las interfaces regulares que pueden tener más de uno.

Previo a la versión 8 de Java, las interfaces solo podían tener métodos abstractos, pero a partir de esta versión es posible tener, además, métodos default (no cubiertos en esta lección) y métodos estáticos.

@FunctionalInterface
  public interface Aritmetica {
    float aplicar(float operando1, float operando2);
}

Tabla 1. Ejemplo de interfaz funcional.

Las interfaces funcionales se marcan con la anotación

@FunctionalInterface y describen interfaces que contendrán únicamente un método abstracto y, por lo tanto, pueden servir para referenciar objetos con una función. Es mediante esta estrategia que se puede incorporar el concepto de programación funcional dentro de Java.

La anotación @FunctionalInterface sirve como indicador tanto para el programador como para el compilador, ya que en el momento en que se agregue un método abstracto más la compilación comenzará a fallar, ayudando así a mantener la integridad del código.

También es importante mencionar que, si bien, solo se puede agregar un método abstracto, es posible agregar múltiples métodos static y default sin que esto afecte la definición de la interfaz funcional.

Expresiones lambda 

En el modelo de programación orientada a objetos seguido por Java, previo a su versión 8 (cuando se incluyeron los conceptos de programación funcional al lenguaje), era necesario crear múltiples clases que implementaran las interfaces funcionales cuando se requirieran distintas implementaciones de estas, lo que podía volver al código difícil de mantener y de leer. Para evitar esto se incorporaron las expresiones lambda (Lecessi, 2019).

Aritmetica suma = (x, y) -> x + y;
Aritmetica resta = (x, y) -> x - y;
Aritmetica mult = (x, y) -> x * y;
Aritmetica div = (x, y) -> x / y;

System.out.println(suma.aplicar(4,6));
System.out.println(resta.aplicar(7,6));
System.out.println(mult.aplicar(4,6));
System.out.println(div.aplicar(4,6));

Tabla 2. Ejemplos de expresiones lambda que hacen uso de la interfaz funcional “Aritmetica”.

Según Schildt (2021), una expresión lambda es, en esencia, un método anónimo que no se puede ejecutar por sí solo, sino que requiere de una interfaz funcional para poderlo referenciar.

La expresión lambda introdujo un nuevo elemento de sintaxis al lenguaje Java, el operador lambda o el operador flecha ->, este divide la expresión lambda en dos partes, la parte izquierda define los parámetros necesarios en el método y el lado derecho describe el cuerpo del método, o lo que es igual, la parte ejecutable del método ubicada dentro de llaves { y }.

En la tabla 2 puedes ver varios ejemplos de expresiones lambda de una sola línea. En estos casos no es necesario agregar llaves para delimitar el bloque de código que representa dicha expresión ni especificar la sentencia return, pues la misma línea define el valor que se va a regresar, producto de la ejecución de ese código. Por otro lado, al tener dos o más líneas de código es necesario especificar el bloque de código entre llaves, además de la sentencia return para indicar el valor que habrá de obtenerse.

Con respecto a los parámetros, hay varias reglas que te conviene aprender.

Cuando el método no reciba ningún parámetro se especifica una lista vacía de parámetros mediante un par de paréntesis con nada dentro.

Además, en una expresión lambda no es necesario especificar el tipo de dato de los parámetros pues estos se inferirán en tiempo de compilación del contexto.

Interfaces funcionales básicas del API de Java (Function, Predicate, Consumer y Supplier) 

Como se mencionó previamente, las interfaces funcionales son las que permiten introducir los conceptos de programación funcional en el lenguaje Java, y para dar un mejor soporte a este paradigma, Java ofrece un grupo de interfaces funcionales predefinidas dentro del paquete java.util.function que podrás utilizar.

Para comprender la siguiente tabla, debes saber que el uso de los símbolos de < y > sirven para indicar tipos de datos genéricos, es decir, un método definido con genéricos permite indicar que el tipo que no se conoce al momento de ser diseño y que se reemplazará una vez que el método sea utilizado en tiempo de compilación, así, dentro de los signos de < y > (conocido como notación de diamante) se especifica con una letra mayúscula este tipo de dato genérico mencionado.

En la siguiente tabla puedes observar los tipos T y R que son solo letras para representar, por un lado la T, el tipo de dato de entrada, y por otro lado la R, que indica el tipo de dato de retorno.

Tabla 3. Interfaces funcionales ofrecidas por Java.

A continuación, podrás aprender cuál es la utilidad de las interfaces funcionales previas. Es importante que sepas que estas son solo las más relevantes para tu inicio en la programación funcional en Java, pero podrás encontrar muchas más dentro del paquete java.util.function, por lo que se te recomienda estudiarlas cuando hayas dominado las mostradas aquí.

La interfaz Supplier se utiliza cuando se quiere generar valores sin tener parámetros de entrada, por ejemplo, el método now() de la clase LocalDate que genera una fecha tomando la información directamente del sistema y no se necesita indicarle ninguna información de entrada (Boyarsky, 2020).

La interfaz Consumer es útil cuando necesitas “consumir” datos, pero no es necesario dar un valor de retorno, por ejemplo, cuando necesitas solamente imprimir los datos de una lista.

La interfaz Predicate se utiliza mayoritariamente en operaciones de filtrado y búsqueda, ya que su método test() devuelve un valor booleano resultado de la evaluación de la operación de entrada.

La interfaz Function se utiliza para transformar su parámetro de entrada y devolver otro valor derivado de este que puede o no ser de otro tipo de dato, por ejemplo, obtener un campo de un objeto y devolver solamente ese campo en lugar del objeto original.

Streams

Las clases del API de Streams fueron diseñadas teniendo las expresiones lambda en mente, por lo que su compatibilidad es tal que es posible realizar operaciones de búsqueda, filtrado, mapeo o manipulación de datos de una forma similar a como se haría en una consulta a una base de datos utilizando SQL (Schildt, 2021).

El término stream es utilizado en dos ocasiones dentro del lenguaje Java en contextos diferentes, el primero es dentro del ámbito de la lectura y la escritura de datos en archivos, donde la palabra stream hace referencia al “flujo de datos”, ya sea de entrada o de salida. El segundo es dentro del API de Streams en el contexto de la programación funcional y las expresiones lambda, donde el concepto de stream tiene otra denotación que aprenderás en esta lección.

Dentro del contexto de la programación funcional, un stream representa un conducto de datos, o lo que es igual, representa una secuencia de objetos (Schildt, 2021). Una de sus características principales es que un stream no almacena información, solo la mueve de un lado a otro, además, un stream nunca altera la fuente de datos que típicamente proviene de una colección o un arreglo, lo que significa que la manipulación de los elementos de un stream, por ejemplo, durante un ordenamiento, producen un nuevo stream con los datos ordenados, pero la fuente original mantendrá su orden previo.

Para generar streams, la interfaz Collection de la que derivan los ArrayList incorporó el método stream() con el cual es posible obtener un stream generado directamente de los elementos contenidos en dicho ArrayList.

En caso de querer generar un stream a partir de un arreglo, es necesario usar el método Arrays.stream() que recibe como parámetro el arreglo del cual se producirá dicho stream.

Para comprender mejor los streams, debes conocer los conceptos de operaciones intermedias y operaciones terminales que moldean la forma en que los streams se comportan.

Las operaciones intermedias son aquellas que transforman un stream en otro y debido a que usan lazy evaluation puede haber tantas como se deseen, pues estas no se ejecutarán hasta que se encuentren con una operación terminal. La evaluación lazy significa que los datos no son generados de manera exhaustiva, sino que solo se generan en el momento en que son necesarios.

Por otro lado, están las operaciones terminales, que son aquellas que producen un resultado, dado que los streams no son reutilizables, el encontrar una operación terminal es indicador de que ya no se puede encadenar más operaciones, de ahí su nombre.

Operaciones fundamentales con streams (map, filter, sorted, collect, foreach, reduce) 

El verdadero poder del API de Streams proviene de las distintas operaciones que se pueden realizar a una colección de objetos, por ejemplo, a un ArrayList o a un arreglo.

public class Estudiante {
  String nombre;
  int edad;
  Estudiante(String nombre, int edad) {
    this.nombre = nombre;
    this.edad = edad;
  }
}

Tabla 4. Definición de la clase Estudiante.

List<Estudiante> lista = new ArrayList<>();

lista.add(new Estudiante("Carlos", 18));
lista.add(new Estudiante("Ivan", 17));
lista.add(new Estudiante("Rodrigo", 20));
lista.add(new Estudiante("Fernando", 30));
lista.add(new Estudiante("Raul", 25));

Tabla 5. Ejemplo de un ArrayList siendo inicializado con varios objetos.

En la tabla anterior tienes un ejemplo de una lista siendo inicializada con diversos objetos que podrán procesarse de diferentes maneras utilizando el API de Streams.

lista.stream()
.map(e -> e.nombre)
.sorted()
.forEach(System.out::println);

Tabla 6. Operaciones de mapeo, ordenamiento y proceso de elementos aplicadas a un stream.

Una de las operaciones básicas que se pueden realizar a los elementos de un stream es la del mapeo, que como se ha explicado antes, consiste en procesar un objeto y generar otro dato derivado de este, con un tipo de dato posiblemente distinto; en la tabla 6 tienes un ejemplo de esto. Para una mejor comprensión, se explicarán los metodos map(), sorted() y forEach() utilizados dentro de la sentencia de la tabla 6.

El método map() permite realizar una operación de sustitución de datos. En este ejemplo, al crear el stream éste se compone por objetos de tipo Estudiante, luego, dentro del método map() se indica la sustitución de dicho objeto por el campo nombre que está definido dentro del mismo, así la sentencia .map(e -> e.nombre) reemplaza un objeto del tipo Estudiante por el nombre del estudiante; con esto también se cambia el tipo de dato de los elementos del stream, pues el objeto original es de tipo Estudiante y el dato resultante. Es decir, el nombre es de tipo String. Visto de otra manera, a partir de la instrucción map dejarás de tener un stream compuesto por objetos de tipo Estudiante para tener un stream compuesto por el atributo nombre que es de tipo String.

Figura 1. Muestra gráfica de la transformación que tiene un stream que pasa de tener objetos tipo Estudiante a tener Strings simples después del uso del método map().

La siguiente operación usada en la tabla es la del ordenamiento de los nombres alfabéticamente. En este caso, se logra agregando el método sorted(), lo que dará como resultado acumulado de las dos operaciones el que se obtenga el nombre de los estudiantes en orden alfabético, es decir, a partir de este momento tu stream estará compuesto por los mismos nombres de tipo String, pero esta vez ordenados alfabéticamente.

Después de eso, el método forEach que recibe una expresión lambda que represente un Consumer, realiza la operación de imprimir el resultado de la operación previa, en este caso, la operación indicada por la expresión lamda dentro del método forEach se aplicará a todos los elementos existentes en el stream.

float edadPromedio = lista.stream()
.map(e -> e.edad)
.reduce(0, (a,b) -> a+b);
edadPromedio = edadPromedio / lista.size();

  System.out.println(edadPromedio);

Tabla 7. Ejemplo de uso de la operación reduce.

Otra operación importante es la de reducción, que permite procesar toda la lista de elementos que produce el stream y generar un único elemento resultante de la combinación de todos los anteriores. En la tabla 6 puedes observar cómo primero se realiza un mapeo de los estudiantes obteniendo solo su edad y posteriormente se realiza una operación de suma de todos ellos, obteniendo así un único valor resultante. Luego se procede a usar esta suma para obtener el promedio de edad de la lista de estudiantes.

List mayores = lista.stream()
  .filter(e -> e.edad >= 18)
  .collect(Collectors.toList());

Tabla 8. Ejemplos de filtrado y recolección.

Otra operación sumamente útil dentro del API de Streams es el filtrado que te permitirá disminuir el campo de búsqueda o de procesamiento únicamente a aquellos elementos que cumplan con alguna condición determinada.

Para llevar a cabo esta operación, cuentas con el método filter() que recibe una expresión lambda que lleve a cabo una operación lógica y dé como resultado un valor booleano, es decir, de verdadero o falso. En otras palabras, el método filter recibe una instancia de Predicate estudiado previamente, y solo se conservan los elementos que evalúen la expresión como verdadera. En la tabla 7 puedes ver un ejemplo de esto, donde se están filtrando a los estudiantes, cuya edad sea superior a 18 años.

Posteriormente se realiza la operación de recolección, que se ve representada por el método collect, en el cual se indica el tipo de colección que contendrá los elementos resultantes de la operación. En este ejemplo puedes ver que todos los estudiantes mayores de 18 años se recolectarán en una lista para su uso posterior.


Cierre

Esto trae nuevos retos, pero al mismo tiempo abre un abanico de posibilidades tanto para la manera de ver los problemas como de diseñar soluciones en este lenguaje.

La programación funcional viene a mantener vigente a Java en un escenario sumamente competitivo con múltiples tecnologías. Seguir de cerca las tendencias te permitirá mantenerte actualizado en el mundo del desarrollo de software y dominar los paradigmas de programación orientada a objetos y programación funcional. En su conjunto te ayudarán a producir programas sólidos con prácticas modernas.


Checkpoint

Asegúrate de:

  • Comprender para qué sirven las interfaces funcionales y cómo permiten incorporar conceptos de programación en el lenguaje Java, así como combinar prácticas de los paradigmas de programación orientada a objetos y programación funcional.
  • Entender los casos prácticos de las interfaces funcionales de Java (Predicate, Consumer, Function y Supplier) y cómo es que estas simplifican el proceso de construcción de software sacando provecho de sus características para agilizar y optimizar el desarrollo de código mantenible, eficiente y eficaz.
  • Comprender cómo formar operaciones con streams y lambda expressions para simplificar el código que, de seguir el paradigma orientado a objetos, puede llegar a ser muy extenso y simplificando con esto el procesamiento de datos.

Bibliografía

  • Boyarsky, J., y Selikoff, S. (2020). OCP Oracle Certified Professional Java SE 11 Developer Complete Study Guide. Estados Unidos: Sybex.
  • Lecessi, R. (2019). Functional Interfaces in Java: Fundamentals and Examples. Estados Unidos: Apress.
  • Schildt, H. (2021). Java: The Complete Reference (12ª ed.). Estados Unidos: McGraw-Hill.

La obra presentada es propiedad de ENSEÑANZA E INVESTIGACIÓN SUPERIOR A.C. (UNIVERSIDAD TECMILENIO), protegida por la Ley Federal de Derecho de Autor; la alteración o deformación de una obra, así como su reproducción, exhibición o ejecución pública sin el consentimiento de su autor y titular de los derechos correspondientes es constitutivo de un delito tipificado en la Ley Federal de Derechos de Autor, así como en las Leyes Internacionales de Derecho de Autor.

El uso de imágenes, fragmentos de videos, fragmentos de eventos culturales, programas y demás material que sea objeto de protección de los derechos de autor, es exclusivamente para fines educativos e informativos, y cualquier uso distinto como el lucro, reproducción, edición o modificación, será perseguido y sancionado por UNIVERSIDAD TECMILENIO.

Queda prohibido copiar, reproducir, distribuir, publicar, transmitir, difundir, o en cualquier modo explotar cualquier parte de esta obra sin la autorización previa por escrito de UNIVERSIDAD TECMILENIO. Sin embargo, usted podrá bajar material a su computadora personal para uso exclusivamente personal o educacional y no comercial limitado a una copia por página. No se podrá remover o alterar de la copia ninguna leyenda de Derechos de Autor o la que manifieste la autoría del material.