Índice
- 1. Introducción a las Funciones de Orden Superior
- 2. Expresiones Lambda y Funciones de Orden Superior en Java
- 3 Conclusiones
- 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 unInteger
y devuelve unBoolean
.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 devuelvetrue
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 superiormap
, obtiene los salarios.moreThan
: Utiliza la función de orden superiorfilter
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
Funciones de Orden Superior – Wikipedia
: Un artículo que ofrece una visión general sobre las funciones de orden superior, incluyendo definiciones, ejemplos y aplicaciones en diversos lenguajes de programación.
Funciones de Orden Superior – Documentación de Scala
: Un recurso oficial que explica el concepto de funciones de orden superior en el contexto del lenguaje de programación Scala, con ejemplos prácticos y su uso en la programación funcional.