Funciones de orden superior y funciones Lambda en Java

0
151
Portátil MacBook en un escritorio con código relacionado con programación funcional en pantalla, junto con símbolos de expresiones lambda y funciones de orden superior.
Portada para un artículo sobre programación funcional en Java, explorando expresiones lambda y funciones de orden superior.

Índice

  1. 1. Introducción a las Funciones de Orden Superior
  2. 2. Expresiones Lambda y Funciones de Orden Superior en Java
    1. 2.1 Definición de una Expresión Lambda
    2. 2.2 Uso de Funciones Lambda en Operaciones Comunes
    3. 2.3 Preservación de Funciones Lambda
    4. 2.4 Usos de las Funciones de Orden Superior
      1. 2.4.1 Ejemplo en una Lista de Empleados
      2. 2.4.2 Enfoque Tradicional vs. Funciones de Orden Superior
    5. 2.5 Manejo de Parámetros en Funciones Lambda
      1. 2.5.1 Creación de Interfaces Funcionales Personalizadas
      2. 2.5.2 Parámetros Fijos en Funciones Lambda
    6. 2.6 Composición de Funciones
  3. 3 Conclusiones
    1. 3.1 Alternativas a Java para Programación Funcional
      1. 3.1.1 Kotlin
      2. 3.1.2 Groovy
      3. 3.1.3 Clojure
  4. 4 Referencias

 

1. Introducción a las Funciones de Orden Superior

En este artículo, quiero hacer una introducción a las funciones de orden superior en Java y, por lo tanto, a la programación funcional.

Para quienes no sepan qué son las funciones de orden superior, son funciones que toman al menos una función como argumento o devuelven otras funciones. Aunque estas «nuevas» funciones no se definen de la misma manera, entraremos en detalle más adelante. Además, nos permiten ahorrar mucho código en ciertas ocasiones.

 

2. Expresiones Lambda y Funciones de Orden Superior en Java

Estas funciones permiten escribir código más genérico y flexible. Esta funcionalidad se incluyó en Java en su versión 8, junto con las expresiones lambda, que están muy relacionadas con las funciones de orden superior. Empezaremos por las expresiones lambda.

 

2.1. Definición de una Expresión Lambda

Una función lambda tiene la siguiente forma: (...) -> {...}. Entre los paréntesis, definimos los parámetros que va a tomar la función; aunque si solo tiene un argumento, no son necesarios. La flecha -> indica que se trata de una función lambda y su comportamiento se define entre las llaves. Podemos incluir tantas sentencias de código como necesitemos, pero si solo hay una sentencia, no hace falta emplear las llaves.

Un pequeño ejemplo podría ser el siguiente:

 e -> e.toString() + "." 

Esta función lambda recibe un elemento, lo convierte a texto y le añade un punto al final. ¿Dónde podríamos usar esto?

 

2.2. Uso de Funciones Lambda en Operaciones Comunes

Quizá hayas utilizado alguna de estas funciones en algún momento sin tener muy claro lo que eran. Tal vez las has visto en operaciones como map, filter, forEach o reduce. Todas estas funciones de orden superior trabajan sobre estructuras de datos, a las cuales les pasamos una función lambda, y modifican o nos devuelven una nueva estructura de datos alterada por la función lambda.

Un ejemplo sería el siguiente:

 List numbers = Arrays.asList(1, 2, 3, 4); List numbersToText = numbers.stream() .map(e -> e.toString() + ".") .collect(Collectors.toList()); System.out.println(numbersToText); 

Como resultado de ejecutar este código, obtenemos: [1., 2., 3., 4.].

 

2.3. Preservación de Funciones Lambda

Ahora que sabemos cómo se define una función lambda y cómo la podemos usar, si queremos preservarla para utilizarla en más ocasiones, ¿cómo lo hacemos? Aquí tenemos un ejemplo:

 Function<Integer, Boolean> isEven = e -> e % 2 == 0; 

Tenemos varias partes:

  • Function<Integer, Boolean>: Aquí definimos el tipo de la función que estamos creando; en este caso, una función que recibe un Integer y devuelve un Boolean.
  • isEven: Es simplemente el nombre que le damos a esta función lambda.
  • e -> e % 2 == 0: Este es el cuerpo de nuestra función lambda, que en este caso devuelve true si el número es par.

 

2.4. Usos de las Funciones de Orden Superior

Java nos ofrece múltiples funciones de orden superior, muchas de ellas nos sirven para trabajar sobre estructuras de datos. Entre ellas tenemos:

map:

Recorre una estructura de datos y, sobre cada elemento, podemos realizar modificaciones, extraer una propiedad o construir un objeto en función de esos elementos.

 data.stream() .map(e -> e / 2) .collect(Collectors.toList()); 

filter:

Sobre una estructura de datos, le das un criterio y te devuelve solo los elementos que cumplen esa condición.

 data.stream() .filter(e -> e > 10) .collect(Collectors.toList()); 

reduce o fold:

Nos dan la posibilidad de tener una lista y «sumar» o componer todos los elementos en uno. La diferencia entre fold y reduce es que fold permite establecer un valor inicial.

find:

Devuelve el primer objeto que cumple cierto predicado.

Estos son algunos de los ejemplos que existen, pero hay muchos más.

 

2.4.1. Ejemplo en una Lista de Empleados

Ahora, ¿qué usos podemos darle a esto? Ya hemos visto las operaciones que Java nos ofrece sobre las estructuras de datos, las cuales nos permiten ahorrarnos un montón de bucles en el código y hacerlo más legible. Pero vamos a ir un paso más allá.

Imaginemos un pequeño ejemplo en el que tenemos una lista de empleados sobre los que queremos realizar operaciones y filtrados de forma recurrente. Por ejemplo, ver qué salarios tienen de media, la antigüedad media, cuál es el trabajador más joven…

 

2.4.2. Enfoque Tradicional vs. Funciones de Orden Superior

Un posible enfoque sin usar funciones de orden superior podría ser crear funciones específicas que realicen el conjunto del cálculo, lo cual las hace poco reutilizables. Otra alternativa sería crear distintos módulos que calculen la media, obtengan los salarios, etc., e ir llamándolos en tu código.


public void doSomething() { 
    setup(); 
    List result = moreThan25000(getSalaries(employees)); 
    System.out.println(result); 
} 

List getSalaries(List employees) { 
    List salaries = new ArrayList<>(); 
    for (Employee employee : employees) { 
        salaries.add(employee.getSalary()); 
    } 
    return salaries; 
} 

List moreThan25000(List salaries) { 
    List moreThan = new ArrayList<>(); 
    for (Integer salary : salaries) { 
        if (salary > 25000) {
            moreThan.add(salary); 
        } 
    } 
    return moreThan; 
}

Este es un ejemplo de ello, donde tenemos una función que obtiene todos los salarios y otra distinta que devuelve los que están por encima de 25,000.

Otra opción, usando funciones de orden superior, podría ser crear cada uno de estos módulos por separado y tener una función que reciba como parámetro las operaciones a realizar y te devuelva el resultado.


public void doSomething() { 
    setup(); 
    List result = applyFilter(employees, getSalaries, moreThan(25000)); 
    System.out.println(result); 
} 

Function<List, List> getSalaries = 
    list -> list.stream()
                .map(Employee::getSalary)
                .collect(Collectors.toList()); 

Function<List, List> moreThan(int min) { 
    return salaries -> salaries.stream()
                               .filter(salary -> salary > min)
                               .collect(Collectors.toList()); 
} 

<E, R, T> T applyFilter(E origin, Function<E, R> extractionFunction, Function<R, T> processingFunction) { 
    R data = extractionFunction.apply(origin); 
    return processingFunction.apply(data); 
}

En este código tenemos tres funciones de orden superior:

  • getSalaries: Haciendo uso de la función de orden superior map, obtiene los salarios.
  • moreThan: Utiliza la función de orden superior filter para obtener aquellos salarios que son mayores de 25,000.
  • applyFilter: Recibe estas dos funciones y las aplica sin necesidad de conocer su implementación.

Como se puede ver, para ejecutar las funciones de orden superior que hemos creado con las lambdas, tenemos que hacer uso del método abstracto apply(), al cual le pasamos los valores de entrada de la lambda.

Esto nos ha permitido ahorrar varios bucles y hacer un código más legible, ya que es fácil ver lo que hacen las distintas partes al utilizar operaciones como filter o map, que son muy concisas.

 

2.5. Manejo de Parámetros en Funciones Lambda

 

2.5.1. Creación de Interfaces Funcionales Personalizadas

Sin embargo, puede que alguien ya se haya dado cuenta de que sería mejor pasar ese 25,000 como parámetro. El problema es que la interfaz Function de Java no nos permite hacer esto, ya que solo define una entrada y una salida. La solución sería crear nuestra propia interfaz funcional, así:


@FunctionalInterface
public interface BiFunction<T, U, R> {
    R apply(T t, U u);
}

BiFunction<List, Integer, List> moreThan = 
    (list, min) -> list.stream()
                       .filter(salary -> salary > min)
                       .collect(Collectors.toList());

¿Qué vemos aquí?

Tenemos la anotación @FunctionalInterface, que obliga a cumplir los requisitos para que la interfaz se considere funcional. Estos requisitos son tener un solo método abstracto, aunque podría contener algunos métodos ya implementados usando la notación default.

A continuación, tenemos el método apply(), que es el método abstracto al que le damos comportamiento al crear la lambda más abajo. No es necesario que se llame apply, pero ayuda a mantener coherencia con las interfaces funcionales de Java.

 

020502 Parámetros Fijos en Funciones Lambda

El problema de esta opción es que requeriría cambiar el método applyFilter para que invoque a moreThan con algún valor adicional.

Otra opción podría ser definir ese parámetro como “fijo” al crear la lambda, ya que no tiene que ver con los datos:


Function<List, List> moreThan(int min) {
    return list -> list.stream()
                       .filter(e -> e > min)
                       .collect(Collectors.toList());
}

De esta forma, no tendríamos que cambiar applyFilter, ya que le asignamos el valor mínimo al crear la lambda.

 

2.6. Composición de Funciones

Otra cosa interesante de las funciones de orden superior es que podemos componer varias funciones para crear una sola:


Function<List, List> compose = 
    moreThan(25000).compose(getSalaries);

Entonces, al ejecutar compose, va a ejecutar primero getSalaries y luego ejecutará moreThan con los datos que getSalaries le haya devuelto.

 

3. Conclusiones

Este es un caso muy simple, ya que la función applyFilter solo ejecuta dos funciones. Pero si lo llevamos a un caso más grande en el cual tenemos, por ejemplo, 20 líneas de código y unas pocas llamadas a funciones que cambian, en vez de repetir todo ese código cambiando esas funciones, la solución podría ser tan simple como que estas sean funciones lambda que se pasen por argumento. Estas funciones tampoco tienen que ser un filter o un map de Java, como en el ejemplo anterior. Podría ser algo así:


id -> deleteItem(id);
id -> markItemAsSold(id);
id -> restoreItemStock(id);

Y si el resto del código es común, no tengo que duplicar todo para cambiar la operación que realizo sobre el objeto.

Como se puede ver, las funciones de orden superior nos pueden ayudar a deshacernos de múltiples bucles y a hacer el código más modular y legible.

Java, al ser un lenguaje multiparadigma, no está plenamente enfocado a la programación funcional, lo que trae consigo algunas limitaciones. Para más de un parámetro, nos obliga a crear interfaces. Luego, la composición de funciones se complica si queremos usar nuestras propias interfaces funcionales.

 

3.1. Alternativas a Java para Programación Funcional

Si tienes curiosidad por profundizar en la programación funcional, hay lenguajes muy potentes como Scala, que están mucho más orientados a esta forma de programar y, por lo tanto, son más cómodos en este aspecto. Por ejemplo, lo que en Java requiere crear una interfaz, en Scala es tan fácil como poner: f: (Int, Int) => Int, y con esto, como atributo, ya hemos definido que la función recibe otra función que, como argumentos, toma dos enteros y devuelve otro.

 

3.1.1. Kotlin

Una opción muy interesante es Kotlin. Es un lenguaje muy parecido a Java, hasta el punto de que puedes mezclar ambos lenguajes en el mismo proyecto e invocar funciones de Java desde Kotlin y viceversa.

Kotlin nos facilita enormemente muchas cosas en la programación funcional que en Java son bastante enrevesadas.

Para hacer una función que reciba otra función es tan fácil como esto:


fun doThings(number: Int, predicate: (Int) -> Boolean) {
    if (predicate(number)) {
        println("El número cumple el predicado")
    } else {
        println("El número NO cumple el predicado")
    }
}

Declarar expresiones lambda se vuelve tan cómodo como declarar una variable, sin importar si reciben uno o dos parámetros de entrada:


val isEven = { e: Int -> e % 2 == 0 }
val isNotEven = { e: Int -> e % 2 != 0 }
val isMultipleOf7 = { e: Int -> e % 7 == 0 }
val divide = { a: Int, b: Int -> a / b }

Y, por último, invocar una función con una lambda es tan simple como llamar a cualquier otra función:

 doThings(3, isEven) 

 

30102. Groovy

Otra alternativa es Groovy, un lenguaje orientado a objetos y dinámico para la plataforma Java que también soporta programación funcional. Groovy es compatible con Java, lo que significa que puedes usar bibliotecas y código Java dentro de Groovy sin problemas.

En Groovy, puedes definir y utilizar closures (clausuras), que son similares a las lambdas:


def isEven = { int e -> e % 2 == 0 }
def isNotEven = { int e -> e % 2 != 0 }

def doThings(number, predicate) {
    if (predicate(number)) {
        println "El número cumple el predicado"
    } else {
        println "El número NO cumple el predicado"
    }
}

doThings(3, isEven)

 

3.1.3. Clojure

Clojure es un lenguaje de programación funcional moderno y un dialecto de Lisp que se ejecuta en la JVM. Está diseñado para ser inmutable por defecto y tiene un fuerte enfoque en la concurrencia.

(defn is-even [e]
  (= (mod e 2) 0))

(defn do-things [number predicate]
  (if (predicate number)
    (println "El número cumple el predicado")
    (println "El número NO cumple el predicado")))

(do-things 3 is-even)

 

4. Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad