Programación funcional.

José Javier Blanco Rivero

Aunque el paradigma funcional es tan viejo como la programación misma, su popularidad creciente es un fenómeno relativamente reciente. Hoy en día todo lenguaje ofrece en sus librerías base uno que otro constructo de programación funcional, tales como la evaluación perezosa o las funciones map y reduce (algunos otros, incluso más). De allí que sea importante distinguir entre estilo funcional y paradigma funcional propiamente dicho.

Ciertamente, esta distinción puede evidenciar cierto puritanismo por parte de la comunidad funcional. No obstante, más allá de los extremismos que se puedan observar de parte y parte, en el fondo existe un buen argumento que sustenta esta distinción. Pero antes, es menester repasar algunos conceptos básicos.

Así como en el paradigma objetos las clases juegan un rol protagónico, en el paradigma funcional, como su nombre lo sugiere, las funciones son ciudadanas de primera clase. Como hemos discutido anteriormente, el paradigma funcional se sustenta sobre cuatro pilares, a saber, inmutabilidad, transparencia referencial, funciones de alto orden y composición de funciones.

Inmutabilidad quiere decir que no está permitido reasignar una variable o modificar una estructura de datos manteniendo la misma referencia. Desde el punto de vista de la concurrencia, es decir, de procesos que corren simultáneamente compartiendo información y recursos, la inmutabilidad de variables y estructuras de datos evita muchísimos dolores de cabeza, ya que con ella desaparecen toda una clase de problemas. Además, trabajar con valores inmutables tiene la ventaja de contribuir a la pureza de las funciones, es decir, que dado un input la función retorne siempre el mismo output.

La transparencia referencial indica la posibilidad de intercambiar la expresión de la función con su resultado sin que por esto cambie la conducta del código. Se trata básicamente de un indicador de pureza de las funciones.

Una característica significativa de la programación funcional consiste en la posibilidad de pasar funciones como argumento a otras funciones y/o que estas funciones devuelvan otras funciones como retorno. A esto se le llama funciones de alto orden.

Finalmente, composición de funciones, en estrecho sentido con la noción matemática, significa crear una nueva función a partir de dos funciones. Componer funciones es equivalente a llamar una función f con el parámetro x y después llamar a una función g pasándole el resultado de la operación anterior. Sólo que lo que hacíamos matemáticamente en dos pasos, podemos hacerlo programáticamente en sólo uno.

Cuando construimos nuestras funciones respetando los principios de transparencia referencial e inmutabilidad, nuestras funciones pueden considerarse puras o libres de efectos secundarios. ¿Qué son los efectos secundarios?

Cuando quiera que estamos guardando o escribiendo datos en un archivo o en una base de datos, o cuando imprimimos datos por pantalla, estamos produciendo un efecto secundario. Los efectos secundarios son útiles, ya que, de hecho, constituyen el propósito de la mayor parte de nuestros programas. Por ende, la cuestión no consiste en <<evitar>> los efectos secundarios, sino en organizar nuestro código de tal manera que los segmentos encargados de realizar este tipo de tareas permanezcan bien acotados. ¿Por qué? Porque de esta forma nuestro código se hará más fácil de predecir, de testear y de comprender.

Ahora bien, ¿por qué cabría distinguir entre un estilo funcional y la programación funcional a rajatabla? Digamos que hemos escrito nuestro programa siguiendo todos los principios de la programación funcional, pero usamos una librería que no está diseñada bajo los mismos principios. Sucede que esta dependencia puede contaminar nuestro sistema al producir errores (mejor conocidos en la jerga como bugs) inesperados. Este podría considerarse un escenario quizá marginal, sin embargo, si consideramos que puede tratarse de librerías que vienen incluidas con el lenguaje (como de hecho es el caso en muchos lenguajes), no lo es tanto.

Es por esta razón que lenguajes grandes y populares como Java, Python o Javascript, si bien nos permiten programar en un estilo funcional, en sí y por sí no son completamente funcionales, ya que no incluyen en su modelo de programación ninguna clase de mecanismos para mantener bajo control los efectos secundarios (esto no significa que no se pueda lograr, a menudo requiere una buena dosis de disciplina por parte del programador y/o el uso de alguna librería que modifique la conducta del código para que se acomode a estos principios, pero incluso así el programador debe asegurarse de que sus dependencias también obedezcan estos principios).

Sin más que acotar vamos a discutir algunas técnicas populares de programación funcional.

Iteración vs. Recursión

Una técnica muy común en programación consiste en ejecutar repetidamente una instrucción con el objeto de alcanzar un resultado. Esto se puede hacer de dos maneras, a través de la iteración (donde las estructuras de control de tipo bucle que ya vimos son un tipo) o de la recursión.

La iteración es un recurso propio del paradigma imperativo, ya que es característico de este proceder el indicarle al computador cómo va a realizar los cálculos. Entretanto, la recursión conlleva un estilo más declarativo, más elegante y, ciertamente, más claro en la mayoría de los casos.

Para comprender con conocimiento de causa las diferencias, hagamos una pequeña práctica: codifiquemos una función que calcule el enésimo número en la sucesión de Fibonacci.

Como es bien sabido, la sucesión tiene como regla generativa la suma de los dos elementos anteriores. Comenzando con 0 y 1, el siguiente sería 1, 1+1 suma 2, el siguiente sería 3 y así sucesivamente tenemos 5,8,13,21,34,55,89,144,233,377,610...

Para calcular la sucesión debemos tener en cuenta las siguientes ecuaciones:

f(0) = 0

f(1) = 1

f(n) = f(n-1)+f(n-2)

Tratemos de resolver este problema de forma iterativa usando Java:

public int fibo(int n){
  int res [] = new int[n+1];
  for(int i = 0; i <= n; i++){
    if(i==0) res[i] = 0; // Primera ecuación
    if(i==1){
      res[i] = 1; // Segunda ecuación
    }
    if(i>1){
      res[i] = res[i-1] + res[i-2]; // Tercera ecuación
    } 
  }
  return res[n];
}
0.2s
fibo(17);
0.2s
//Comprueben el funcionamiento correcto del método en otras celdas de código. 
0.1s

Pues bien, si nos fijamos en lo que hicimos arriba, empleamos un bucle for para iterar por cada número de la secuencia. Dentro del bucle implementamos nuestra lógica: si i es 0 almacenamos el 0 en el array, si es 1 lo mismo, en cambio, si es mayor a uno lo que vamos a agregar en el array es la suma de lo que se encuentra en la última posición (i-1) con lo que se encuentra en la penúltima (i-2).

Lo característico de la iteración es usar un variable como acumulador, en este caso empleamos un array. Este acumulador almacena el estado de nuestro cálculo y lo actualiza en el siguiente momento de la iteración.

Veamos ahora la solución recursiva:

public int fibrec(int n){
  if(n==0) return 0;
  if(n==1) return 1;
  return fibrec(n-1)+fibrec(n-2);
}
0.2s
fibrec(10);
0.2s

¡Genial! (Sigan haciendo pruebas por ustedes mismos de todas maneras). Como podemos ver la definición recursiva es mucho más concisa y mucho más fácil de implementar. Básicamente leímos su expresión matemática y la tradujimos en código y, en lenguajes fuertemente inspirados en la matemática como Haskell, la traducción es mucho más evidente.

El secreto está en la última línea del método. Allí estamos llamando a nuestra función dos veces, primero sobre n-1, después sobre n-2 y, por último, le pedimos que devuelva el resultado del cálculo.

Sin embargo, no todo son rosas. Especialmente en Java las definiciones recursivas tienen limitaciones importantes, a saber, estamos realizando los mismos cálculos una y otra vez, cosa que resulta muy poco eficiente y, especialmente en el contexto de la JVM, llega a un punto donde produce un error de memoria. O en el mejor de los casos veremos que la ejecución tarda una eternidad. Comparemos:

// Descomente para probar y vuelva a comentar cuando termine para que no interfiera con la ejecución de otras celdas
//fibrec(125);
0.0s
fibo(125);
0.2s

Como pueden ver, es posible que la primera celda todavía se esté ejecutando y tengan que volver a evaluar toda la notebook de nuevo. Entretanto, si ejecutan la versión iterativa devuelve el resultado casi al instante (por cierto, si les extraña el número negativo se debe a que hemos excedido el límite del tipo integer, debimos haber escogido un tipo de dato que soporte números más grandes como long o BigInteger).

Funciones parciales

En programación una función parcial es aquella que invocamos con sus paramétros incompletos, de donde obtenemos como resultado otra función que necesita recoger el parámetro faltante para arrojar un resultado. ¿Suena muy intrincado? Veamos un ejemplo.

Digamos que tenemos una función para calcular la potencia de un número.

(defn potencia
  [base expt]
  (Math/pow base expt))
0.1s

Para usar esta función, como sabemos, debemos pasarle ambos parámetros. De lo contrario obtendremos una excepción.

(potencia 2)
0.6s

Como vemos, el error es una ArityException que nos dice que pasamos un argumento a la función cuando esta esperaba dos.

Ahora bien, digamos que en este momento nosotros no sabemos aún cuál será el exponente de nuestro cálculo, pero necesitamos guardar la base mientras hacemos otros cálculos. Pues bien, para crear una función parcial sólo debemos llamar la función partial y pasarle como argumento nuestra función y uno o varios argumentos parciales.

(def base2 (partial potencia 2))
0.1s

Bien, digamos ahora que ya conozco el exponente. Lo que tengo que hacer es pasárselo a base2 para obtener el resultado.

(base2 10)
0.1s

¡Et voilá!

Sin embargo, otros lenguajes se toman la definición de función parcial de forma más próxima a su sentido matemático. Matemáticamente una función parcial alude a que el dominio de la función es un subconjunto del conjunto sobre el que fue inicialmente definida. Un ejemplo común es el de la función que define la raíz cuadrada: inicialmente está definida para los números reales R, pero sólo puede haber raíces cuadradas de números positivos.

Este es el caso de Scala. Para definir una función parcial definimos un nombre e invocamos el objeto Partial Function junto con los tipos que van a definir el dominio de nuestra función. Si definimos, por ejemplo, la función de la raíz cuadrada sería así:

val raizcuad: PartialFunction[Double, Double] = {
  case n if n >= 0 => java.lang.Math.sqrt(n);
}
1.8s

Sin duda alguna hay muchos detalles sintácticos que retener en Scala. Si nos damos cuenta, como mencionamos arriba, esto no tiene la sintaxis familiar de una función: no estamos pasando ningún parámetro, sino que sencillamente tenemos un tipo compuesto PartialFunction[Double, Double] y recién entre las llaves escribimos lo que sería el cuerpo de una función. Decimos, básicamente, que si n es mayor o igual a 0, le pasamos n al método Java de la clase Math, sqrt().

Probemos:

raizcuad(144);
0.8s

A diferencia de Clojure donde las funciones parciales pueden tener cualquier cantidad de argumentos, en Scala las funciones parciales sólo pueden ser unarias, esto es, funciones cuya aridad o número de parámetros es 1.

En Scala existen algunos métodos de la clase Collections que necesitan funciones parciales, por ejemplo, collect. Digamos que tenemos una colección y deseamos realizar algún tipo de transformación sobre sus elementos, por ejemplo, tenemos una lista de sueldos a los que aplicaremos un aumento a los que estén por debajo de 30 mil (recordemos que una función parcial se define justamente por aplicarse a un subconjunto del dominio).

A continuación nuestra función parcial:

val aumentaSalarioMinimo: PartialFunction[Double, Double] = {
  case n if n < 30000 => n + 20000;
}
0.8s

Ahora definamos nuestra colección y llamando a collect apliquémosle la función parcial que recién definimos:

List(120000.01, 23000.22, 1800000.732, 12000.32, 24022.21, 150000.43) collect { aumentaSalarioMinimo}
0.6s

Incluso podemos hacerlo más directamente y pasar la función parcial como una función anónima. En este caso dividamos entre 2 los elementos de nuestra lista si son pares:

List(2,3,3243,21,100,20,40) collect { case x if x % 2 == 0 => x / 2}
1.1s

Composición de funciones

En Clojure podemos resolver el problema anterior utilizando composición de funciones. Para componer funciones utilizamos la función comp.

(def aplicar-filtro (comp (filter even?) (map #(/ % 2))))
0.1s

Nuestro filtro busca números pares y si lo son, los divide entre dos. Ahora cuando deseemos aplicar ese filtro llamamos a la función transduce, le pasamos la función compuesta, una función que agregará los resultados parciales obtenidos y, finalmente, la colección que vamos a trabajar.

(transduce aplicar-filtro conj (vector 328 384 2374 382132 32819 8329 3184 93 821 34 3983 890 843 9023 890 3280 92))
0.1s

Acá utilizamos conj como función agregadora para que nos devuelva un vector.

Nota sobre la función transduce

No discutiremos ahora el tema de los transductores en Clojure, pero cabe señalar que la función comp aplica las funciones que concatena de derecha a izquierda, sin embargo, cuando una función comp se le pasa como argumento a un transductor se invierte el orden de aplicación. Por ende, el filtro se aplica primero y luego el mapeo. Si aplicáremos la composición por sí sola no funcionaría para este caso en concreto.

Ciertamente pudimos haber hecho algo más sencillo, como esto:

(->> [23 823 983 438 4823 7238 942 384 2]
  (filter even?)
  (map #(/ % 2)))
0.0s

Sin embargo, la ventaja del código de más arriba radica en que es más reusable. Podemos aplicar ese mismo filtro a cualquier colección que necesitemos.

Probemos con otro ejemplo. Digamos que necesitamos calcular algunos porcentajes. Primero vamos a definir una clausura (una clausura es una función que devuelve otra función capturando el valor del parámetro que se le pasa en primera instancia, el cual usará para más tarde completar el cálculo) que nos permita crear una función para calcular un porcentaje específico.

(defn porcentaje
  [porcentaje]
  (fn [cantidad] (* porcentaje cantidad)))
;;Ahora definimos una función para obtener el 35%
(def treintaycinco% (porcentaje 35))
0.1s

Ahora usemos la composición para integrar el porcentaje con otra función que nos va a permitir calcular el porcentaje.

(def calcular-treintaycinco% (comp #(/ % 100) treintaycinco%))
0.1s

Hagamos una prueba tonta para verificar que funciona bien:

(calcular-treintaycinco% 100)
0.1s
(calcular-treintaycinco% 1000)
0.1s
(calcular-treintaycinco% 18950.)
0.1s

Desde luego que es más fácil y más práctico crear una función que calcule el porcentaje recibiendo como parámetros la cantidad y el porcentaje... pero valga el ejemplo para demostrar cómo podemos usar la composición de funciones.

En Scala las funciones parciales se prestan bien a la composición. Para ello podemos usar alguna de las funciones andThen o orElse.

val porDos: PartialFunction[Int,Int] = {
  case n if n >= 0 => n * 2;
}
2.3s
val imparPorTres: PartialFunction[Int, Int] = {
  case n if n % 2 == 1 => n * 3;
}
1.2s

Ahora que definimos nuestras funciones parciales compongámoslas:

val primeraCombinacion: PartialFunction[Int,Int]={
  imparPorTres orElse porDos
}
0.9s

Probemos:

List(2,21,33,313,2232,2,213,1223,134) collect { primeraCombinacion }
0.8s

Ahora tratemos de concatenar dos funciones con andThen:

(imparPorTres andThen porDos)(19)
0.6s

Por supuesto que la composición en Scala no está limitada a las funciones parciales. Acá veremos un ejemplo usando la función compose:

val cuadrado = (x:Double) => x*x; 
val cubica = (x:Double) => java.lang.Math.cbrt(x);
val composicionF = cuadrado compose cubica;
0.6s
composicionF(8)
0.4s

En Racket también tenemos la función compose, la cual al igual que la función comp de Clojure concatena la ejecución de las funciones que le pasemos como argumento de derecha a izquierda. Existen dos variantes, compose1 que limita a uno la cantidad de argumentos que las funciones se pasan unas a otras y compose que no impone este límite.

Para la mayoría de los casos, es preferible usar compose1.

(define cuadrado
  (lambda (x) (* x x)))
(define (entre-tres x) (/ x 3.))
0.0s

Acá definimos las funciones que vamos a componer (la sintaxis en la definición es ligeramente diferente entre ambas pero equivalente). Ahora compongámoslas:

((compose1 entre-tres cuadrado) 189)
0.0s

Ahora utilicemos la función compose:

(define funcionX (compose 
           (lambda (x) 
             (map entre-tres x)) 
           (lambda (y) 
             (map cuadrado y))))
0.0s
(funcionX '(12 32 90 23 78 23 931 782 23))
0.0s

En Julia para componer utilizamos el mismo símbolo que en matemáticas, a saber inline_formula not implemented(Este símbolo en Latex es \circ y para escribir Latex en nuestras notebooks debemos envolver las expresiones en $expresion$), también podemos hacer piping como en Linux, esto es, un operador que pasa el resultado de un comando a otro comando, esto es, |>. Veamos.

(sqrt  *)(12,12)  
0.5s

Acá pasamos dos argumentos a nuestra función compuesta, la cual primero multiplica ambos términos y luego pasa el resultado a la función sqrt que calcula su raíz cuadrada. (Lo negativo de usar tal símbolo matemático es lo difícil que es de conseguir; acá debes hacer click en TAB y subir con la flecha, para ir en reversa digamos, hasta conseguirlo entre la lista de funciones base).

El piping es conceptualmente muy similar al threading macro (-> ó ->>) de Clojure, pues concatena funciones. Digamos que tomamos una colección de números y les sacamos el 30%, esto es, los multiplicamos por 30 los dividimos entre 100:

[1250,4523,9802, 1900,19389,21000,3459] |> x -> x * 30 |> x -> x / 100
1.5s

Funciones de alto orden

Como decíamos arriba, una función de alto orden es una función que recibe una función y/o devuelve otra función. Se trata de una herramienta muy poderosa de abstracción.

En la mayoría de los lenguajes funcionales no tenemos que hacer nada especial para generar una función de alto orden aunque, en efecto, existen funciones que nos asisten en esta clase de tareas (e.g. la función transduce que vimos en Clojure). Sin embargo, el tema de los transductores es algo avanzado y quisiera dejarlo para más adelante.

Si lo consideramos atentamente, las funciones que en diversos lenguajes cumplen la tarea de componer funciones, son funciones de alto orden: toman funciones como input y devuelven una función como output. Otras funciones de alto orden que a menudo vienen incluidas en nuestro lenguajes son las de map, reduce y filter.

Pero, si el caso lo amerita, también podemos crear nuestras propias funciones de alto orden. Pongamos por caso la siguiente función:

(defn generar-secuencia
  [f]
  (fn [times init-val] (take times (iterate #(f %) init-val))))
0.0s

Esta función tiene un sólo parámetro, a saber, una función cualquiera. Esa función la pasamos a otra función que se encargará de aplicar esa función recursivamente a un valor inicial para producir una secuencia. Y retornamos una función que necesita como parámetros el número de veces que iterará la función y el valor inicial.

(def cuadrados (generar-secuencia #(*' % %)))
0.0s

Arriba nombramos una operación que nos devolverá una función. A generar-secuencia le pasamos una función anónima que calcula el cuadrado de un número, esto es, multiplica al número por sí mismo. (El símbolo ' después del * es para promover los números tipo long, con los que trabaja Clojure, a BigInteger, y así evitar un overflow). Ahora le indicaremos a cuadrados que deseamos una secuencia de 10 números y que el valor inicial será 2.

(cuadrados 10 2)
0.0s

Si lo que queríamos era la secuencia de x elevado al cuadrado, aquí está:

((generar-secuencia #(*' % 2)) 10 1)
0.0s

Pongamos otro ejemplo de una función que recibe una función y devuelve otra, pero esta vez hagámoslo en Racket. Digamos que necesito aplicar una función dos veces a determinado paramétro, pero en vez de hacerlo manualmente me genero una función.

(define (aplicar-dos-veces f)
  (compose1 f f))
0.1s

Ahora veamos:

(define masDos (aplicar-dos-veces add1))
(masDos 100)
0.0s

Ahora bien, también podemos hacer que sólo tengan una función como parámetro o que solo devuelvan una función. Pongamos un ejemplo con Scala: hagamos de cuenta que tenemos una función que calcula el monto de las liquidaciones mensuales en una empresa, y esa función recibe una lista de sueldos y una función que calcula las utilidades que le corresponden a cada cual según su sueldo:

val sueldos = List(12900.21, 22332.20, 231232.32,22312.23,32313.900,123940.31,313412.212,321321.932)
  
val calcularUtilidades = (sueldo:Double) => (sueldo * 15) / 100
  
val aplicarUtilidades = (salarios:List[Double], funcionUtilidades: Double => Double) => {
  salarios.map(x => funcionUtilidades(x) + x)
}
0.8s

Ahora probemos:

aplicarUtilidades(sueldos, calcularUtilidades)
0.6s

Y bien es todo por ahora. Como es costumbre, tomen las celdas que gusten y practiquen lo aprendido.

Runtimes (5)
Runtime Languages (12)