Hablamos sobre un nuevo paradigma de programación, la programación funcional que, si bien aún estamos poco familiarizados con ella, nos ofrece numerosas ventajas que agradecerás en cuanto comiences a usarla en tus desarrollos Java.
Pero… ¿Qué es eso de la programación funcional?
Es básicamente un paradigma de programación, como lo es el Orientado a Objetos, actualmente el más popular y con el que seguramente nos encontremos más familiarizados.
Este paradigma, en el que se basa la programación funcional, es un paradigma declarativo donde lo que importa es qué se hace y no cómo se hace. Esto implica que nuestra lógica no describe controles de flujo; no se usarán bucles o condicionales.
Aunque Java es un lenguaje de programación Orientado a Objetos, a partir de Java 8 empezaron a estar disponibles una serie de entidades que nos permiten definir este tipo de funciones, principalmente contenidas en el paquete java.util.function.
Y para sacarle todo su jugo a este tipo de programación tenemos las funciones Lambdas.
Funciones Lambda en Java
La sintaxis de este tipo de funciones varía un poco respecto al Java tradicional, en la medida de lo posible no se definirán los tipos de las variables siempre y cuando no tenga lugar ningún tipo de ambigüedad.
En el siguiente ejemplo se define una función que devuelve el tamaño de una cadena de texto dada:
Function<String,Integer> sizeOf = (String s) -> {
return s.length();
};
Esta función tiene una equivalente aún más compacta:
Function<String,Integer> sizeOf = s -> s.length();
Y por muy compacta que sea la sintaxis, lo que realmente genera el compilador es un código que seguro nos resulta mucho más familiar:
public class SizeOf implements Function<String,Integer>{
public Integer apply(String s){
return s.length();
}
}
A continuación vemos otro ejemplo en que a partir de una lista de colores, recorriéndola apoyándonos en el método forEach, añadimos un coche con el color leído en una lista:
List<String> colors = Arrays.asList("Red","White","Black","Blue","Yellow");
colors.forEach(color -> {
cars.add(Car.builder().brand("Volvo").model("XC90").color(color).date(LocalDate.now())
.id(RandomStringUtils.random(7, true, true)).build());
});
Para usar el forEach directamente vale con generar la siguiente estructura lambda:
colors.forEach (<variable> -> <actions>);
Ventajas e inconvenientes del uso de expresiones Lambda
Ventajas
Salta a la vista que una de las ventajas que ofrece este tipo de programación es la creación de código más claro y conciso, ya que permite referenciar métodos sin nombre (anónimos) sin recurrir al uso de clases anónimas.
Las funciones Lambda abren el camino a pasar funciones, en tiempo de ejecución, como valores de variables, valores de retorno o parámetros de otras funciones. Este es un concepto muy interesante que se puede entender como la posibilidad de pasar comportamiento como valor.
Cuando una expresión Lambda se compone de una sola sentencia e invoca a algún método existente por medio de su nombre, existe la posibilidad de escribirla usando métodos de referencia, con lo cual se logra un código más compacto y fácil de leer.
Al usarla en conjunto con la API Stream podemos realizar operaciones de tipo filtro/mapeo/reducción sobre colecciones de datos de forma secuencial o paralela y que su implementación sea transparente para el desarrollador. Sobre este uso con Streams veremos algunos ejemplos bastante interesantes a continuación.
Inconvenientes… o no
Obviamente, el uso de expresiones lambda tiene algunos inconvenientes. El principal es que, inicialmente, este código puede resultarnos algo confuso por su sintaxis. El uso tanto de estas expresiones como de la API Stream requiere un cambio de paradigma en la forma en la que hemos escrito código Java hasta el momento.
Este inconveniente debemos tomarlo como un reto a superar. ¿La recompensa? Tener a nuestra disposición una poderosa herramienta que nos va a facilitar mucho la vida a la hora desarrollar nuestro código.
Expresiones Lambda en colecciones (API Stream)
¿Qué es un Stream?
Los Streams en Java son un nuevo modelo de datos que nos permite tratar las colecciones como si de etapas de un proceso ETL (“Extract Transform and Load”) se tratara. Esto nos permite utilizar las funciones Map y Reduce tan comunes en esos procesos, especialmente en la etapa de transformación.
Desde Java 8, toda colección tiene un método stream() que transformará dicha estructura en Stream. Esto nos permitirá utilizar sus métodos para tratar colecciones.
Veamos ahora alguno de sus métodos más útiles y por ende, de uso más frecuente. Para todos los ejemplos usaremos un Stream de String al que llamaremos “streangs”:
List<String> strings = …;
Stream<String> streangs = strings.stream();
Foreach
Como ya hemos visto en un el ejemplo anterior, aplica una función a cada uno de los elementos. Este método es terminal, no se puede encadenar con otra etapa del Stream.
streangs.forEach(s -> System.out.println(s)); // expresión Lambda
streangs.forEach(System.out::println); // referencia a función
// Imprime por pantalla cada una de las cadenas
Map
Transforma cada uno de los objetos que contiene la colección, pudiendo incluso cambiar el tipo de Stream.
streangs.map(s -> s + "_MAPPED");
// Añadiría la cadena "_MAPPED" a todos los elementos
streangs.map(s -> Integer.parse(s));
// Transforma cada elemento en un Integer
// A partir de aquí el Stream<String> cambia a Stream<Integer>
Reduce
Transforma la colección en único objeto del mismo tipo después de aplicar una función de agregación a cada elemento.
streangs.reduce("", // Valor inicial
(s1, s2) -> s1 + " " + s2); // Concatena cada elemento con el agregado
// Con esto obtendríamos una única cadena concatenando todos sus elementos
Filter
Filtra los elementos que satisfagan una determinada condición.
streangs.filter(s -> s.length() > 5);
// Solo mantenemos cadenas de longitud mayor a 5
Collect
El paso final de la transformación del Stream, nos permitirá por ejemplo, volverlo a convertir a una lista estándar.
List<String> result = streangs.collect(Collectors.toList());
Salvo los métodos finales como son collect, reduce o forEach todos se pueden ir encadenando como si fueran tuberías, creando un proceso de transformación más complejo.
// Obtenemos las cadenas ordenadas que representen números pares
List<String> even = strings.stream()
.map(string -> Integer.valueOf(s))
.filter(integer -> integer % 2 == 0)
.distinct()
.sorted()
.map(integer::toString)
.collect(Collectors.toList());
Conclusiones
Con los streams tenemos una nueva manera de procesar colecciones de datos respecto a las tradicionales sentencias de control, como pueden ser bucles y condiciones. Además, también nos proporcionan la ventaja de poder “exprimir” más a los procesadores, utilizando para ello los parallelStream, que utilizan varios hilos de ejecución en cada etapa de procesamiento (pero esto lo dejaremos para otro post).
A la hora de inclinarnos por los streams o las sentencias tradicionales, a la hora de acometer un desarrollo, es importante tener en cuenta que si se trata de grandes volúmenes de información, los streams suponen una ventaja absoluta en cuanto a claridad y sencillez del código y rapidez de ejecución, por el contrario, en volúmenes muy pequeños puede llegar a ser hasta contraproducente usar streams y lambdas. Pero esto además también dependerá de otros factores que deberá evaluar cada desarrollador.
Aunque Java 8 ha sido la primera puerta hacia la programación funcional, existen otros lenguajes como Scala que ofrecen una programación funcional bastante más avanzada.