¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
¿Buscas nuestro logo?
Aquí te dejamos una copia, pero si necesitas más opciones o quieres conocer más, visita nuestra área de marca.
dev
Rafael Merino 14/10/2021 Cargando comentarios…
En el primer post de la serie, El Despertar de la Programación Funcional, hicimos una introducción del contenido del curso y os contamos las razones que motivaron que la programación funcional recuperase la atención que se merece.
En este post vamos a hablar de Java. Nos centraremos en las características más novedosas e importantes que vamos a utilizar durante el desarrollo de JIO, dedicando especial atención a las técnicas de pattern matching y tail-call elimination.
Java (no sin razón) siempre ha tenido fama de ser un lenguaje de programación muy verboso. El surgimiento de nuevos lenguajes en la JVM como Scala (2004), Groovy (2007), Clojure (2007) y Kotlin (2011), por citar algunos, hizo que Oracle finalmente cambiase la cadencia de publicación de releases a una cada seis meses. Quedarse atrás y no entregar features nuevas con mayor frecuencia significaba seguir perdiendo adopción con tanta competencia. Sin embargo, esto suponía un mayor riesgo para uno de sus pilares fundamentales: la retrocompatibilidad. Es por ello que aunque las features más novedosas se pueden ir utilizando de forma temprana con el flag preview-feature del compilador, siempre existe la posibilidad de que finalmente no se incluyan en ninguna LTR (Long Term Release) si surge algún problema de compatibilidad (lo que dudo que ocurra).
Este requisito tan exigente de retrocompatibilidad, hace que en Java todo lo que se añada sea para siempre. Es por ello que se lo piensan mucho antes de incorporar nada. No quieren cometer errores que terminen pagando toda la vida, como ocurrió con la serialización.
En mi opinión, la retrocompatibilidad es un tesoro que está claramente infravalorado. Si se aumenta la versión de Java y algo deja de funcionar, en general es muy mal síntoma. Lamentablemente es algo que puede ocurrir en proyectos con un elevado número de dependencias y frameworks cuyo control cae fuera de nuestras manos.
Java, como lenguaje funcional, es difícil que esté a la altura de otros lenguajes como Scala (no es su propósito). No obstante, vamos a ver durante el desarrollo de JIO como ha hecho unos avances increíbles que nos permiten implementar programas cada vez más concisos, expresivos y robustos siguiendo una filosofía puramente funcional. Esto unido al hecho de que la JVM es literalmente una bestia y que el JIT compiler es continuamente optimizado, hace de Java, le pese a quien le pese, una opción todavía muy atractiva. Todo esto sin mencionar GraalVM, que con el tiempo será la JVM de referencia, convirtiendo en legacy el resto de implementaciones.
Java 5 (septiembre de 2004) introdujo Generics con la idea de reducir la necesidad de especificar los tipos (lo que se conoce como type inference), dando lugar a programas más concisos. Desde entonces se han introducido mejoras con el mismo objetivo, como por ejemplo el diamond operator <> y var (JEP 286).
Generics puede resultar complejo debido a lo que se conoce como type erasure (necesario por razones de retrocompatibilidad), pero es indispensable para utilizar lambdas y diseñar APIs lo más flexibles posibles.
Teniendo en cuenta que type inference ocupa un apartado amplio en la especificación de Java y es complejo ya de por sí, no es de extrañar que en ocasiones el compilador no sea capaz de deducir el tipo de una expresión y haya que indicarlo explícitamente. Para profundizar en el tema, en la bibliografía aparecen dos recursos muy interesantes:
Java 7 (julio de 2012) introdujo el ForkJoin pool. Es un pool diseñado explícitamente para ejecutar tareas en paralelo siguiendo el principio divide and conquer. Los threads de este pool son daemons llamados workers que ejecutan por defecto las etapas de un parallel Stream y los stages de un CompletableFuture. Existe la falsa creencia de que no pueden ejecutar tareas bloqueantes, pero nada más lejos de la realidad 🥳, siempre y cuando se utilice la interfaz ManagedBlocker. Lo utilizaremos en JIO al ser indispensable para implementar una solución eficiente.
Java 8 (marzo de 2014) ha sido, sin lugar a dudas, de las versiones más revolucionarias de Java. Hizo posible programar siguiendo una filosofía funcional al incorporar funciones, suppliers y lambdas. Otra característica clave que introdujo, y de la que menos se habla, es la interfaz CompletableFuture. Como toda fluent interface, tiene un elevado número de métodos, pero está muy bien organizada. Es importante dominar esta interfaz y entender su modelo de ejecución ya que la utilizaremos constantemente. En la bibliografía aparecen algunos recursos muy recomendables.
Me gustaría matizar que la programación funcional es un concepto mucho más profundo que simplemente utilizar determinadas clases como Streams, Functions, Optionals y Futures.
Java 9 (septiembre de 2019), introdujo el concepto de módulos con el proyecto Jigsaw (JEP 200). Es una mejora muy importante que no ha sido todavía muy adoptada a pesar de las innumerables ventajas que ofrece 🤷♂️.
Aunque no es el objetivo de este post profundizar en Jigsaw, me gustaría resaltar algunos puntos de los que como desarrolladores nos podemos beneficiar.
Por un lado, la misma JDK está dividida en módulos (JEP 201). Por curiosidad, abre un terminal y ejecuta el comando java --list-modules. ¿Cuántos módulos tiene? Son unos cuantos ¿verdad? Además podemos reducir considerablemente el tamaño de los ejecutables de Java utilizando el comando jlink (JEP 282), ya que nos permite excluir los módulos de la JDK que no necesitamos en nuestras aplicaciones, con todas las ventajas que esto supone. Es decir podemos crear imágenes de Java totalmente customizadas.
Por otro lado, podemos decidir no exportar determinados paquetes del código fuente de nuestros módulos. Es decir, las aplicaciones que los utilicen no tendrán acceso a ninguna de sus clases, incluso si han sido declaradas como public. Desde el punto de vista del diseño de librerías esto es muy importante, ya que conviene que los clientes de nuestras APIs cuando utilizan el autocompletado de su IDE no se vean ahogados en un gran número de clases que no tienen ningún sentido para ellos.
Sin utilizar Jigsaw resulta muy complicado realizar una buena paquetización de nuestros proyectos. Por ejemplo, si queremos minimizar la visibilidad de ciertas clases no queda otra opción que no hacerlas públicas, lo que obliga a que las clases que las utilicen tengan que estar en el mismo paquete. Esto termina en paquetes con un gran número de clases. ¡Gracias a Jigsaw esto ya no es un problema!. Ya no tenemos que alcanzar un compromiso entre tener nuestro código bien organizado y exponer a los clientes clases que no necesitan, ¡podemos conseguir las dos cosas!
Java 9 también añadió JShell, que es un REPL (read eval print loop). Cualquier lenguaje de programación funcional tiene un REPL. Abre las puertas a la experimentación de forma rápida. Un test unitario no es la mejor forma de interactuar con el software que escribes ni con el lenguaje de programación que utilizas. Como curiosidad, no es necesario utilizar puntos y comas. ¡Sí, has oído bien, Java sin puntos y comas 💃!
Java 11 (septiembre de 2018) introdujo un cliente HTTP que utilizaremos para implementar con JIO el primer cliente reactivo con soporte OAuth en Java. Se diseñará también un sencillo servidor HTTP para testear el cliente sin necesidad de utilizar ninguna librería de terceros. Las ventajas de poder arrancar un servidor embebido para la ejecución de los tests son inmensas. Conviene matizar el verdadero significado de embebido: el PID del servidor y de la JVM es el mismo.
Un aspecto importante en el mundo de la programación y especialmente de la consultoría es el logging. Si aparece un bug y los logs no nos permiten identificarlo de forma rápida, eso significa que hay dos bugs. Hay que resolver los dos. Un mal hábito es hacer uso continuo de la herramienta de debug del IDE para identificar errores, en vez de hacer uso de los logs, familiarizarse con ellos y mejorarlos de forma continua. Yo siempre he echado de menos en Java alguna solución nativa para la escritura de logs. Es cierto que hay distintas librerías, pero no es simple la interoperabilidad entre ambas. Java 11 introdujo JFR (Java Flight Recorder). Lo utilizaremos en JIO para registrar toda la actividad de nuestro cliente HTTP.
Por último, conviene mencionar que Java 11 es una release LTS.
Vamos a explicar por qué records y sealed types son dos conceptos de los que nos podemos beneficiar en Java con independencia del paradigma que utilicemos. No ocurre lo mismo con pattern matching, que es simple syntactic sugar tal y como aparece descrito en la JEP 394, y solo a partir de Java 17 (septiembre de 2021), empieza a parecerse al que podemos encontrar en lenguajes funcionales como Haskell y Scala.
Comencemos hablando de records (JEP 395). En programación funcional se utilizan constantemente lo que se conoce como ADTs (Algebraic Data Types). Un tipo especial de ADTs son los records y las tuplas (en la jerga funcional se llaman product types). Java añadió records como preview feature en Java 14 (Marzo de 2020), pero decidió no implementar tuplas (aquí tienes su justificación, la cual no comparto). He utilizado tuplas ampliamente en Python y Scala y se echan de menos en Java.
Considera el siguiente ejemplo donde hemos definido la interfaz Shape con el método area y dos implementaciones definidas con records (el típico caso de polimorfismo):
public interface Shape { double area(); }
record Circle(double r) implements Shape {
double area() { return PI * (r ^ 2) ; }
}
record Square(double s) implements Shape {
double area() { return s ^ 2; }
}
La ventaja de utilizar records frente a objetos es que son inmutables y, como se observa, escribimos menos para crearlos. Así de simple. Por ejemplo, no es necesario definir los métodos hashcode, equals, toString y el constructor.
Por otro lado, a partir de Java 15 (septiembre de 2020), es posible impedir que se añadan nuevas implementaciones de Shape, a pesar de tratarse de una interfaz pública. Para ello basta con declarar la interfaz como sealed e indicar las únicas posibles con permits (JEP 360):
public sealed interface Shape permits Circle, Square {}
Vamos ahora a desarrollar la versión funcional del ejemplo anterior. En programación funcional se trabaja con tipos y no con objetos. La principal diferencia es que los tipos no tienen ningún comportamiento asociado:
public sealed interface Shape permits Circle, Square {}
record Circle(double radius) implements Shape {}
record Square(double side) implements Shape {}
Ahora Circle y Square no son objetos con el método area, sino simples tipos. ¿Y el método area ? ¿cómo qué método?, ¡dirás la función!:
// Java 17
public static Function<Shape, Double> area = shape -> {
return switch (shape){
case Circle c -> PI * (c.radius() ^ 2)
case Square s -> s.side() ^ 2 };
Como se observa, estamos testeando el tipo de shape para descomponerlo en cada uno de sus subtipos. Esto se conoce como pattern matching. A destacar los siguientes puntos:
Es natural preguntarse qué ocurre en versiones anteriores a Java 17. Pues que la solución es más verbosa al no poder utilizar un switch, y además el pattern matching no es exhaustivo. Concretamente en versiones anteriores a Java 16, la única opción es hacer downcastings de forma explícita:
// hasta Java 16
static Function<Shape, Double> area = shape -> {
if(shape instanceof Circle){
Circle c = ((Circle)shape);
return PI * (c.radius() ^ 2);
}
if(shape instanceof Square){
Square s = ((Square)shape);
return s.side() ^ 2;
}
throw new RuntimeException("compiler should catch this bug 😢");
};
Mientras que en el caso de Java 16 (Marzo de 2021), podemos refactorizar el ejemplo anterior y simplificarlo un poco gracias a la JEP 394:
// Java 16
static Function<Shape, Double> area = shape -> {
if(shape instanceof Circle c) return PI * (c.radius() ^ 2);
if(shape instanceof Square s) return s.side() ^ 2;
throw new RuntimeException("compiler should catch this bugs 😢");
};
Hay que destacar que conceptualmente son versiones equivalentes. Como se observa en ambos casos es responsabilidad del programador lanzar una excepción en tiempo de ejecución para detectar que haya tipos no tratados en la función area.
¿Qué opción es mejor? ¿Objetos y polimorfismo o ADTs y pattern matching? Yo diría que en Java es más natural el polimorfismo, pero, como siempre, la mejor respuesta es: depende. Dominar las dos alternativas y tener claro lo que se está haciendo es sin duda lo más acertado.
Por último, a modo ilustrativo, en el siguiente ejemplo aparece la implementación en Haskell. Llama la atención la sencillez con la que se define el tipo Shape y se implementa la función area con pattern matching:
data Shape = Circle c | Square s
area Circle c = pi*c*c
area Square s = s*s
Dudo que exista un lenguaje donde se pueda implementar nuestro ejemplo con menos código. Dicen que hay cosas en la vida que son descubiertas y no inventadas. Haskell es sin duda una de ellas.
Sin lugar a dudas, Loom es el proyecto más esperado en Java, y no sin razón, ya que es considerado por muchos la mayor innovación introducida en la JVM en los últimos 20 años. Pretende incorporar lo siguiente:
Finalizo resaltando que para todos los diseñadores de librerías que utilizan Futures, el proyecto Loom tiene un gran impacto ya que surge la pregunta de "¿son ya necesarios?". Dejemos la respuesta para más adelante cuando entendamos mejor la programación funcional y el por qué de los Futures.
Tail-call es un concepto relacionado con la recursividad que puede resultar difícil de entender. Puesto que es de gran importancia, pondremos especial interés en su explicación abordándolo desde diferentes prismas.
Un método (o función) es recursivo si se llama a sí mismo. Además, un método recursivo es tail-call si y solo si la línea de retorno es una llamada a sí mismo y no incluye ninguna operación adicional con el resultado de esa llamada. Vamos a poner unos ejemplos:
int factorial(int n) {
if(n == 0) return 1;
return n * fac(n-1);
}
¿Qué te parece? ¿Es el método factorial tail-call? No, aparte de la llamada a él mismo en la última línea, se multiplica el resultado por el parámetro de entrada n. Veamos otro ejemplo:
int sum(int a, int b) {
if(b == 0) return a;
return 1 + sum(a, b - 1);
}
¿Y ahora? ¿Es el método sum tail call? No, aparte de la llamada a él mismo, en la última línea se suma uno a el resultado. Un último ejemplo:
List<Integer> padWithZeros(List<Integer> list, int nZeros) {
if(nZeros <= 0) return list;
list.add(0);
return padWithZeros(list, nZeros-1);
}
¿Y el método padWithZeros? ¡Ahora sí! Es un método tail-call ya que la última línea es una llamada a él mismo y no hay que realizar ninguna operación adicional con el resultado.
Existen llamadas recursivas que no son tail-call y sí pueden refactorizarse y convertirse en tail-call, mientras que otras no. Depende del problema en cuestión. Por ejemplo, vamos a refactorizar el método factorial de nuestro primer ejemplo:
int factorial(int n){
return factorial(n-1,n);
}
private int factorial(int n, int acc){
if(n == 0) return acc;
return factorial(n-1,n*acc); // is tail-call 🥳
}
Como se observa, ahora sí es tail-call porque no hay que realizar ninguna operación adicional con el resultado de la llamada recursiva. Además, este caso requiere de un segundo método ya que se necesita un parámetro adicional de acumulador.
Ahora vamos a abordar el concepto de tail-call desde un punto de vista más práctico. ¡Para ello voy a necesitar de vuestra ayuda! Imagina que Fulano de Tal me pregunta por el factorial de cinco. Tengo dos alternativas para calcularlo.
La primera consiste en pedir a uno de vosotros que calcule el factorial de cuatro y esperar la respuesta. Cuando la reciba, la multiplicaré por cinco y responderé a Fulano con el resultado. La persona que tenga que calcular el factorial de cuatro hará lo mismo que yo, es decir, pedir a otro de vosotros que calcule el factorial de tres y esperar la respuesta, para posteriormente multiplicarla por cuatro y entregarme el resultado, y así sucesivamente hasta que alguien sea preguntado por el factorial de uno. Obviamente contestará que es uno y entonces se establecerá una comunicación en sentido contrario al inicial de forma que todos los que estamos esperando, uno a uno, recibamos el cálculo que solicitamos, realicemos la operación pertinente y respondamos a quien nos preguntó. Todo esto en pseudocódigo es:
Fulano: Rafa, cuál es el fac(5)
Rafa: Peter, dime el resultado de fac(4)
Peter: Martin, dime el resultado de fac(3)
Martin: Rich, dime el resultado de fac(2)
Rich: Joshua, dime el resultado de fac(1)
Joshua: Rich, el resultado es 1 (primera respuesta que desencadena el resto)
Rich: Martin, el resultado es 2 * fac(1) = 2
Martin: Peter, el resultado es 3 * fac(2) = 6
Peter: Rafa, el resultado es 4 * fac(3) = 24
Rafa: Fulano el resultado es 5 * fac(4) = 120
¿Qué os parece esta forma de resolver el problema? ¿Con qué implementación se corresponde, con la que es tail-call o la que no? ¡Eso es! Con la que no es tail-call. Si lo piensas detenidamente, el que la última línea del método incluya una llamada a sí mismo y una operación adicional con el resultado, en este caso una multiplicación, se traduce en que hay gente esperando recibir una respuesta durante el proceso.
Exploremos una segunda alternativa. Esperar no nos gusta a nadie, ¿verdad? Pues voy a realizar parte del cálculo y dar a uno de vosotros el resultado parcial acumulado para que podáis responder a Fulano directamente, de manera que yo ya no sea necesario para nada más (¡recuerda que antes me tenía que quedar esperando y dar el resultado a Fulano!). Al que le toque, hará lo propio, es decir realizar su parte del cálculo y entregar el acumulador a otra persona para que pueda responder a Fulano, y así sucesivamente hasta que alguien reciba directamente el resultado final y le conteste. Hagamos el mismo ejercicio que en el caso anterior y traduzcamos a pseudocódigo el mismo ejemplo:
Fulano: Rafa, cuál es el fac(5)
Rafa: Peter, dile a Fulano cuánto vale fac(4, 5)
Peter: Martin, dile a Fulano cuánto vale fac(3, 20)
Martin: Rich, dile a Fulano, cuanto vale fac(2, 60)
Rich: Joshua dile a Fulano cuanto vale fac(1, 120)
Joshua: Oye Fulano, el resultado es 120
Seguro que ya te has dado cuenta de que esta solución se corresponde con la implementación tail-call de factorial. Además, comparando este ejemplo con el anterior, ¿te llama algo la atención? Eso es, ocupa la mitad. Es debido a que ahora es una comunicación unidireccional de forma que no hay nadie esperando en el proceso. Cualquiera en la cadena de comunicación establecida puede potencialmente responder a Fulano, mientras que en el primer caso sólo podía yo (Rafa).
Ahora es cuando la cosa se pone interesante. Teniendo en cuenta que cada llamada a un método requiere un frame en el method-call stack y que las llamadas recursivas no están exentas de tal coste, podemos concluir que la ejecución de la función factorial(n) requiere de un stack de longitud n si la implementación es tail-call y del doble si no lo es (piensa que en nuestro ejemplo, cada persona es un frame en el stack). ¿Y qué problema hay? Pues que los lenguajes de programación suelen imponer una restricción en el tamaño del stack, lo que significa que en ambos casos, para un n lo suficientemente elevado, recibiremos un StackOverflowError.
¿Significa esto que cada vez que he utilizado recursividad hay un potencial error acechando? Así es. Es posible que no se manifieste nunca por la naturaleza del problema. Por ejemplo, si estás utilizando recursividad para procesar Jsons, pues tener 10000 Jsons anidados es poco realista. Pero el tener una lista de ese tamaño es bastante normal.
Podemos concluir que independientemente de si una llamada recursiva es tail-call o no, siempre existe el riesgo de desbordar el stack y recibir un error en tiempo de ejecución. Pero solo en el caso de que las llamadas sean tail-call tenemos una última esperanza: tail-call elimination.
Una vez entendido que es tail-call, pasemos a explicar el concepto tail-call elimination. Es muy sencillo. Es una optimización realizada por el lenguaje en los métodos recursivos que son tail-call, que consiste en no almacenar en el method-call stack las llamadas intermedias, que no se necesitan para computar el resultado final. En otras palabras, ¡para que vamos a tener a Rafa y el resto de personas esperando sin necesidad! De esta forma nunca se produciría un StackOverFlowError, ya que el tamaño del stack sería siempre uno.
Pero hay un problema, Java y muchos otros lenguajes como Python no cuentan con esta optimización (aquí tenéis al creador de Python explicando sus razones). Esperemos que Java cumpla los objetivos establecidos en la JEP de Loom y acabe siendo una realidad.
Otros lenguajes como Scala, Clojure o Kotlin sí cuentan con tail-call elimination. ¿Pero cómo lo hacen si están implementados en Java y Java no lo soporta? Lo hacen en tiempo de compilación. Su compilador transforma la llamada recursiva tail-call en una iteración. Por ejemplo, nuestra función factorial quedaría:
private int factorial(int n){
int acc = 1;
for(int i = n; i > 0; i--) acc = acc * n;
return acc;
}
Esto se puede generalizar y afirmar que toda llamada recursiva tail-call se puede refactorizar en un lenguaje imperativo a una interacción con un bucle. ¿Pero por qué no hacemos una iteración desde el principio? Por un lado hay lenguajes como Haskell donde no es posible. El concepto de variables cuyo contenido va cambiando con el tiempo, como acc en nuestro caso, no existe. Todo se hace con recursividad. Por otro lado, cuando te acostumbras, la recursividad da lugar a código más declarativo y resulta más natural llamar a una función que cuadrar los índices de un bucle. Además hay problemas, sobre todo los relacionados con estructuras de datos recursivas, que sólo se pueden resolver con recursividad.
A modo de curiosidad y para los más geeks, existe una técnica en funciones recursivas tail-call que no desborda el stack aún no soportando el lenguaje tail-call elimination. Se llama Trampolines y en Java solo se pudo hacer a partir de la versión 8 gracias a los suppliers. No es objetivo del post profundizar en esto, pero ya sabes, si estás interesado háznoslo saber en los comentarios. Dejo un link en la bibliografía para los más curiosos.
Java es un lenguaje que está avanzando a una velocidad que no nos tenía acostumbrados. No solamente está introduciendo mejoras de rendimiento de la JVM, sino que también están haciendo el lenguaje más funcional de forma que la programación es cada vez más expresiva y menos verbosa. Además el proyecto Loom va a suponer una auténtica revolución. Por último, desarrollando JIO haremos uso de las características más novedosas añadidas en Java en las últimas releases. ¡No te lo puedes perder!
Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.
Estamos comprometidos.
Tecnología, personas e impacto positivo.
Cuéntanos qué te parece.