En el anterior post, Java Contraataca, hicimos un breve resumen de la evolución de Java y mencionamos las características más novedosas que utilizaremos en JIO. Hicimos una especial mención a la recursividad y el concepto de tail-call elimination. En este post describiremos los fundamentos teóricos de la programación funcional.

La transición de un paradigma imperativo a uno funcional, como todo cambio de paradigma, es dura. Pero la esencia, con independencia del lenguaje de programación, es la misma, y tenerla clara facilita la transición.

Funciones puras

La programación funcional consiste en programar con funciones puras. Pues tampoco es tan complicado, ¡claro que no!

Conviene antes de continuar distinguir una función de un método. Una función, a diferencia de un método, se puede utilizar como parámetro de entrada o salida de otra función o método (en la jerga funcional esto se conoce como high-order functions). Por lo demás, son conceptos análogos en el sentido de que ambos producen unos outputs a partir de unos inputs.

Centrémonos ahora en el concepto de puro, aplicable tanto a funciones como a métodos. Para que una función o método se consideren como puros, tienen que cumplir las siguientes características:

  • Producir unos outputs a partir únicamente de unos inputs.
  • Todos los inputs y todos los outputs tienen que estar reflejados en la signatura.
  • No hacer nada adicional a producir los outputs, o dicho de otra manera, estar libres de cualquier side-effect.

Es curioso que hemos utilizado funciones puras durante mucho tiempo sin ser conscientes de ello. ¿Cómo? Sí, las funciones que aparecen en los libros de matemáticas que hemos estudiado durante el colegio, instituto o universidad, son todas funciones puras. No se mencionaba porque se daba por hecho. De no ser así sería imposible establecer propiedades, leyes y realizar cualquier tipo de razonamiento matemático. Pasó el tiempo y empezamos a utilizar métodos y funciones impuras cuando aprendimos a programar en cualquier lenguaje imperativo como Java, Python o C++, por citar algunos. ¿Seguro? ¡Y tanto 🤭! Si no me crees, vamos a poner unos ejemplos de métodos impuros:

  • Métodos que retornan outputs pero no aceptan inputs. Como por ejemplo un getter. Obtener algo sin dar nada a cambio. Esto sólo pasa en el mundo de las informática 🤭. Y no acaba ahí la cosa, es probable que cada vez que invoquemos el método retorne un valor distinto o null o incluso lance una excepción. ¿Y la signatura del método te dice algo al respecto? Nada. Al menos en Java tienes las checked exceptions para hacer explícito un posible error, pero por alguna razón están muy mal vistas.
  • Métodos que no retornan outputs y sí aceptan inputs. Como un setter. Dar algo y no obtener nada a cambio ¡Qué egoísta! Eso sí, también puedes recibir una excepción y mandarte cien líneas de código más abajo. Lo que está claro que un método que no devuelve nada, realiza algún tipo de side-effect. De no ser así, ¿para qué ha sido llamado?
  • Métodos que no aceptan inputs y no retornan outputs. Por ejemplo el método run de la clase Thread. Aquí la cosa se pone emocionante. Es una combinación de los dos casos anteriores. Cuando invocas un método de este tipo la fe es muy importante ya que lo que sea que necesite, esperemos que esté ahí, y lo que quiera que haga, esperemos que vaya bien, porque no te devolverá nada.
  • Métodos que lanzan excepciones, lo que es considerado un side-effect y es incompatible con la programación funcional. El seguir el flujo de un programa que lanza excepciones es complicado. Es cómo perseguir saltamontes. Y no es un problema del código, es un problema intrínseco de las excepciones. A medida que un proyecto crece, la complejidad en este sentido aumenta exponencialmente.
  • Métodos que retornan null. Otro side-effect. ¿Cómo hacer explícita esta posibilidad en la signatura del método y obligar al consumidor a que lo trate? La mejor opción es sin duda no retornar null. Cabe destacar que lenguajes como Kotlin han avanzado ya que es el compilador quien garantiza que el código está libre de NullPointerExceptions. Normalmente es Kotlin quien copia a Scala, pero esta vez ocurrió a la inversa y Scala también lo incorporó en Dotty. En cualquier caso, retornar null debe considerarse como un side-effect.

No existen funciones en el mundo de las matemáticas como las descritas más arriba. Por otro lado, acabamos de describir algunos de los principios fundamentales de la programación funcional. Se dice pronto. Además son principios universales de los que siempre te puedes beneficiar, ya sea escribiendo un simple shell script o diseñando un sistema más complejo.

Types vs Objects

En programación funcional se utilizan tipos en vez de objetos. ¿Pero cuál es la diferencia? Un tipo no es nada más que un nombre para un conjunto de valores. Por ejemplo, en el siguiente ejemplo tenemos los tipo A y B con dos y tres valores respectivamente:

A = ["a", "b"]
B = [1, 2, 3 ]

No como los objetos, los tipos no tienen ningún comportamiento. Esto hace que sea posible realizar operaciones con ellos. Podemos unirlos y crear tuplas. Por ejemplo, una tupla de dos elementos de tipo (A, B) sería:

(A, B) =  [ ("a", 1), ("a", 2), ("a", 3), ("b", 1), ("b", 2), ("b", 3) ]

Las tuplas son una clase de lo que se conoce como product-types ¿Sabrías por qué? En efecto. Porque hay #A x #B = 2 x 3 = 6 posibles valores. Por cierto, las tuplas tienen orden. Es decir, invirtiendo sus elementos daría lugar a un tipo distinto (B,A):

(B, A) = [ (1, "a"), (2, "a"), (3, "a"), (1, "b"), (2, "b",), (3, "b") ]

También podemos agruparlos en pares nombre-valor y formar lo que se conoce como records. Por ejemplo, un record con los campos letter y number, siendo el primero de tipo A y el segundo de tipo B:

Record = { letter: A,  number: B } 

Los records son otra clase de product-types. ¿A qué ya sabes por qué? Así es, también hay #A x #B = 2 x 3 = 6 posibles combinaciones:

[{ letter: "a", number: 1 }  { letter: "a", number: 2 }, 
 { letter: "a", number: 3 }  { letter: "b", number: 1 }  
 { letter: "b", number: 2 }, { letter: "b", number: 3 }]

A diferencia de las tuplas, no es una estructura de datos ordenada ya que el orden de sus campos es indiferente.

Ya sabemos multiplicar tipos y formar tuplas y records. ¡Ahora toca sumarlos! Definamos un tipo C cuyos posibles valores son los valores de tipo A o B:

C = A | B

C= ["a", "b", 1, 2, 3]

Como se observa hay #A + #B = 2+3 = 5 posibles valores. ¡No es de extrañar que se le denomine sum-type!

Si recuerdas bien, a los product-types y sum-types se les denomina Algebraic Data Types (ADT). Son fundamentales en programación funcional y se utilizan constantemente para realizar cualquier tipo de modelado.

Values

En programación funcional, la información, como en la vida, es inmutable. ¡Los hechos, una vez que ocurren, no se pueden modificar, son los que son! A las estructuras de datos inmutables, ya sean listas, mapas, records, o tuplas, se les denomina values.

Rich Hickey, creador de Clojure, dio una charla en 2012 con título the value of values, que tuvo (y todavía tiene) una gran resonancia en el mundo de la programación. Ya sabes, coge 🍿 y disfrútala. ¿Qué te ha parecido? Es sin duda una de mis charlas favoritas.

Rich Hickey habla de las ventajas de programar utilizando values y no posiciones en memoria cuyo valor va cambiando con el tiempo, lo que denomina como PLOP (place oriented programming). Los puntos a resaltar son los siguientes:

  • PLOP tenía sentido mucho tiempo atrás cuando para programar había serias limitaciones de memoria y espacio en disco, pero no en la actualidad.
  • Los values se pueden compartir (no es necesario utilizar locks). Pueden viajar libremente por toda tu arquitectura sin temor a ser mutados. Es muy común cuando no se trabaja con values el tener que realizar copias defensivas para evitar cualquier tipo de mutación indeseada (lo que se denomina copy-on-write). Esto penaliza el rendimiento y es bastante antinatural y propenso a errores.
  • Los values, al no cambiar nunca, dan lugar a tests reproducibles.
  • Los values son fáciles de agregar dando lugar a nuevos values. Podemos crear listas de mapas de listas de listas y así sucesivamente ¡hasta el infinito y más allá!
  • Son fáciles de crear, es más, son independientes del lenguaje de programación. ¿Conoces algún lenguaje que no tenga strings, números, listas o mapas?
  • Las bases de datos tampoco están orientadas a values: tienen registros que se pueden actualizar y sólo almacenan la última instantánea. ¿Cómo analizas la historia de un registro en estas condiciones? Es imposible. Al final el programador tiene que resolver el problema por sí mismo (más bugs y complejidad), bien auditando todas los cambios, o bien a partir de los logs que, por cierto, no son otra cosa que un caos de texto (¿vas a almacenar lo más importante de tu negocio ahí? ¿en serio?). Por no hablar de los problemas de rendimiento asociados a bloquear registros o incluso la base de datos entera para evitar problemas de concurrencia ¿Por qué no hacer bases de datos orientadas a values? Rich Hickey se hace la misma pregunta en Database as a Value y explica por qué creó Datomic.
  • Ya tenemos sistemas de control de versiones orientados a values. ¿Sabrías cual? Sí, en efecto, es Git. A diferencia de SVN, cada modificación crea una instantánea nueva que es inmutable. Su creador Linus Torvalds, un auténtico crack, y por qué no decirlo, no muy fan seguramente de todo lenguaje de programación que no sea C, sabía de la importancia de la inmutabilidad en los niveles de abstracción donde prima el ser capaz de razonar y reconstruir la historia en base a los hechos acontecidos.
  • ¡A gran escala si usamos values! Cuando llamas a un servicio REST por ejemplo, este te devuelve un JSON, un value y no una url donde encontrar el JSON y cuyo contenido puede cambiar. ¡Menos mal!

Pero no todo el monte es orégano. El utilizar values puede tener un inconveniente: tener que estar continuamente produciendo nuevas copias por cada modificación puede ser prohibitivo en términos de rendimiento cuando hay que copiar estructuras de datos con un elevado número de elementos. Para solucionar este problema surgieron las estructuras persistentes de datos. Básicamente consisten en estructuras de datos cuyas modificaciones dan lugar a nuevas versiones que comparten internamente sus elementos con la estructura original, de forma que no es necesario copiarlos todos. En la bibliografía puedes consultar más recursos al respecto.

A modo de curiosidad, Rich Hickey, que se tomó un año sabático para crear Clojure, lo primero que implementó fueron estructuras persistentes de datos. Tenía claro que no quería crear un lenguaje sin ellas. Scala también hizo lo propio. No es el caso de Java, Kotlin o Python por ejemplo. Si bien siempre puedes encontrar librerías, el que estén implementadas de forma nativa por el propio lenguaje siempre da más garantías. Para mí las estructuras persistentes de datos son la prueba del algodón para saber cómo de funcional es un lenguaje y ya sabéis, ¡el algodón no engaña!

Hay que destacar que la inmutabilidad es un concepto genérico y es considerado una buena práctica en cualquier lenguaje. Sirva de ejemplo el item 15 del libro Effective Java (considerado por muchos la biblia de Java), cuyo título dice literalmente minimiza la mutabilidad. Ya comenté que cuando Joshua Bloch dice algo hay que escuchar con atención.

Voy a acabar este apartado con una anécdota: el libro Structure and interpretation of computer programs (más conocido como SICP) es considerado de forma unánime como uno de los mejores en la historia de la programación. Es el libro de texto del curso con el mismo nombre que daban en la prestigiosa universidad de MIT (Massachusetts Institute of Technology). Puedes ver los videos de las clases en Youtube o en la siguiente web. Pues bien, Robert Martin, más conocido como Uncle Bob, hace referencia al libro en este vídeo y cuenta atónito como los autores se disculpan por introducir el concepto de variables y mutación en un capítulo muy avanzado del libro. "¿Llevamos más de 200 páginas programando sin utilizar variables?," se preguntó Robert, "¡no puede ser!", pensó, y con incredulidad repasó todo el libro, página a página, para asegurarse de que en efecto no le estaban engañando. También resulta curioso ver el vídeo de la clase Assignments, State And Side-effects, donde el gran Gerald Jay Sussan, hace casi 40 años, comienza diciendo hoy vamos a hacer algo horrible. El resto es historia.

El libro está muy bien, pero el curso es especial. ¡Está grabado en 1986! Además la música de introducción de cada episodio es lo más. ¡Tienes que escucharla! Y qué decir de las pizarras. ¿Pero de dónde salen tantas? El lenguaje de programación que utilizan es Lisp, pero no te preocupes, su sintaxis es tan sencilla que la explican en una clase. Por último, me gustaría mostrar cómo se define e inicializa una variable en Lisp:

(set! counter 100) 
(set! counter 101) 

Como podéis ver, hay un símbolo de admiración. ¿Qué propósito crees que tiene? Sin lugar a dudas alguien que se disculpa por crear una variable tiene la intención de que no pasen desapercibidas. No pretendo que aprendas Lisp, pero sí su filosofía de tratar de minimizar la mutación y resaltar todo aquello que es fuente de complejidad, y por tanto de bugs. ¡Es una lástima que no podamos utilizar admiraciones en Java!

Side-effects

Hemos mencionado tímidamente los side-effects, pero dada su importancia merecen todo un apartado. No pueden quedar dudas de qué son y de por qué dan lugar a bugs y a código donde es complicado razonar sin utilizar la herramienta de debug del IDE.

Recordemos su definición: todo lo que no esté relacionado con producir los outputs. Veamos algunos ejemplos:

int sum(int a, int b){
   int result = a + b;
   System.out.println("Result "+ result);  
   return result;
}

El anterior método sum, además de sumar, imprime por pantalla el resultado. Esto es un side-effect.

int acc = 0
int sum(int a, int b){
   int result = a + b;
   acc += result; 
   return result;
}

El anterior método, además de sumar, acumula el resultado en una variable. Otro side-effect. Y por último, un clásico de cuyo framework no quiero acordarme:

void activateUser(User user){
    user.setActive(true);
    user.setUpdateDate(new Date());
    dao.update(user);
} 

¿Qué te parece? ¿Ves algún side-effect en el método activateUser? Desde el punto de vista de la programación funcional, existen los siguientes bugs:

  • El método no retorna nada.
  • Modifica el parámetro de entrada (user debería ser un value y por tanto ser inmutable).
  • El método llama a una base de datos y por lo tanto tiene una latencia asociada que debería reflejarse en la signatura.
  • ¿Y si se produce un fallo en la bbdd o algún elemento de red? Pues ya sabes, ¡a perseguir saltamontes!
  • Hay inputs que no aparecen como parámetros de entrada del método, como, por ejemplo, la fecha.
  • ¿Cómo testeo esto? ¡Noooooo! Mockito no, por favor 🙏.

Este estilo de programación no se puede calificar de programación funcional por muchos streams, funciones u optionals que se utilicen.

Finalicemos preguntándonos cuál es el verdadero problema de los side-effects. Muy sencillo, el programador los olvida porque son acciones que no vienen reflejadas en las signaturas de las funciones/métodos que utiliza. En los ejemplos anteriores, ¿por qué tengo que recordar que cuando hago una suma se imprime el resultado por pantalla o se acumula el resultado en una variable?

Memoization

El trabajar con funciones puras, libres de side-effects, nos permite aplicar técnicas como memoization. Consiste en almacenar los outputs que producen las funciones de forma que sólo se computen una vez y se retornen siempre y cuando los inputs sean los mismos. Permíteme hacerte las siguientes preguntas para ver si lo has entendido:

  • ¿Por qué es necesario que no haya estado? Porque se tiene que cumplir la propiedad de que los mismos inputs siempre tienen que producir los mismos outputs. El estado hace que los outputs dependan del tiempo además de los inputs.
  • ¿Por qué es necesario que no haya side-effects? Porque se ejecutarían una única vez, ya que una vez computado el output, este se almacena internamente y se retorna directamente en las sucesivas llamadas.
  • ¿En qué se parece memoization a una caché de una base de datos? En nada. En una caché hay estado, es decir, los mismos inputs no producen siempre los mismos outputs. Es más, puede que el recurso que se cachea se elimine. Además una caché hay que refrescarla.

Por último, decir que es una técnica que se aplica en funciones que realizan cálculos costosos computacionalmente.

Referential transparency

Otro concepto de gran importancia del que no podemos dejar de hablar es referential transparency. Se entiende muy bien con un par de ejemplos prácticos. Dada la lista de números enteros:

List.of(1, 1)  

¿Podemos hacer el siguiente refactor?

int a = 1
List.of( a, a) 

No hay duda de que sí. Son dos expresiones equivalentes. Hagamos el mismo ejercicio pero con una función genérica f, que dejamos de momento sin implementar:

int f(int input){ ??? } 

Dada la lista

List.of( f(1), f(1) )

¿Podemos hacer el siguiente refactor?

int a = f(1)
List.of( a, a )

Dejando de un lado la sintaxis, en un lenguaje como Haskell la respuesta es sí, mientras que en cualquier lenguaje imperativo como Java o Python la respuesta es depende del programador, ya que son dos expresiones equivalentes si y sólo si f es una función pura. Demos claridad a la anterior afirmación implementando f con un side-effect:

int f(int input){
    System.out.println("hola")
    return input + 1
}

En este caso no serían dos expresiones equivalentes ya que

List.of(f(1), f(1))

Imprimiría el texto hola dos veces por pantalla mientras que

int a = f(1)
List.of(a, a)

Lo imprimiría una sola vez. Vamos a poner otro ejemplo. ¿Son equivalentes las siguientes expresiones?

List.of(new Date(), new Date())   

Date a = new Date()
List.of(a, a)

Tampoco son expresiones equivalentes. El constructor de Date cada vez que se invoca retorna un valor distinto.

Resulta curioso que muchas veces es el propio IDE quien nos sugiere hacer algún refactor como los de los ejemplos anteriores, introduciendo un bug difícil de localizar. No es de extrañar, ¡parece tan lógico hacerlo!

El poder extraer las mismas expresiones en un único valor y no cambiar el significado de un programa es una propiedad que se llama referential transparency. Es una de las propiedades claves de la programación funcional.

Reflexionemos un poco más acerca de este concepto tan importante. Imagina que estás analizando un programa complejo y centras tu atención en diez líneas de código que producen un determinado resultado. El hacer el ejercicio mental de sustituir esas diez líneas de código por el valor que producen y seguir analizando el resto del programa es posible sí y sólo si se cumple la propiedad de referential transparency. En el momento en el que además de producir un resultado esas líneas que tienen nuestra atención también realizan algún tipo de side-effect, no podemos hacer la sustitución y el deducir que hace el programa se complica enormemente. Si utilizas mucho la herramienta de debug de tu IDE, esta es una de las razones.

Además, en el ejemplo anterior, es el mismísimo compilador quien podía haber hecho el refactor para no evaluar dos veces la misma expresión f(1), pero obviamente no lo puede hacer. Y es por el mismo motivo que la programación funcional, sin side-effects y estado, simplifica enormemente la programación concurrente y en paralelo.

Tipos de lenguajes funcionales

Definidas ya las funciones puras y values, es fácil entender los tipos de lenguajes funcionales que existen:

  • Lenguajes funcionales puros como Haskell. Todo son funciones puras y values. Te lo impone el lenguaje.
  • Lenguajes funcionales impuros como ML o Lisp. Te ofrecen la posibilidad de ser un poco travieso y seguir un estilo imperativo con una sintaxis especial. Me encanta esta filosofía ya que si aparece un bug sabes donde iniciar la búsqueda: ¡en aquellas partes declaradas explícitamente como impuras!
  • Lenguajes donde se puede hacer programación funcional pero no te lo garantiza el lenguaje, sino que depende del programador. Aquí podemos incluir los lenguajes más convencionales como Clojure, Scala, Kotlin, Python, Java y un largo número de ellos. Es importante decir que unos se centran más en la programación funcional que otros. Por ejemplo, los más funcionales con diferencia son Scala y Clojure. Mi preferido es Scala. Está muy inspirado en Haskell y es desde mi punto de vista la mejor opción en la JVM para profundizar en la programación funcional.

Conclusiones

En este post hemos descrito algunos de los principios fundamentales de la programación funcional. Todas las ideas expuestas son universales y por tanto aplicables a cualquier lenguaje de programación. Es importante que no te queden dudas, y si es así, no dejes de preguntarlas en los comentarios.

Además empezaremos a definir el modelo de JIO, de forma que ya podrás descargarte el proyecto de GitHub y ponerte a prueba 💪 con las primeras implementaciones.

Bibliografía

Cuéntanos qué te parece.

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.