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:

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:

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:

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:

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 ú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:

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.

Suscríbete

Estamos comprometidos.

Tecnología, personas e impacto positivo.