Notas sobre programación funcional (I)

La programación funcional es un tema realmente interesante que abarca desde operaciones muy comunes hasta técnicas de mucha especialización. Operaciones como currying, piping o transducing son parte de un paradigma que nos permite minimizar los estados compartidos en nuestras aplicaciones, y escribir código más legible y más fácil de testear.

Para esta serie vamos a utilizar un lenguaje común: Javascript. No es un lenguaje puramente funcional, y carece nativamente de muchas estructuras de datos requeridas y características que otros lenguajes con más soporte de este paradigma sí proveen. De modo que tendremos que implementarlas, lo que nos enseñará bastante sobre la esencia de la programación funcional.

Abordaremos este tema a modo de notas para recién llegados interesados en la programación funcional; pero también para mi futuro yo. Y lo haremos en dos partes: aquí desarrollaré los conceptos y operaciones más básicas que podemos encontrar en la programación funcional. Hablaremos de:

  • Inmutabilidad
  • Puridad
  • Aridad
  • Currying
  • Partial application
  • Estilo point free
  • Compose y pipe
  • Evaluación perezosa y ansiosa

Repasaremos cada uno de estos conceptos, presentando ejemplos sencillos en Javascript para mantener todo lo más sencillo posible.

En un segundo artículo cubriremosla composición otra vez, pero en este caso con las miras puestas en su relación con los Transductores.

Inmutabilidad

Uno de los principales problemas que nos podemos encontrar en programación es el estado compartido.

Digamos que estamos trabajando con Javascript, y que tenemos un objeto con una propiedad a, el cual es guardado en memoria en la variable x con cierto valor. Ahora copiamos x en una nueva variable y, lo que significa que hemos copiado la referencia de x en y. A partir de ahora todo lo que hagamos en y lo haremos sobre x también. Como se puede entender esto es problemástico, ya que podríamos cambiar x imperceptiblemente de maneras inesperadas.

language-javascript

⁠const john = { id: 1, name: "john" };
const bob = john;
⁠bob.name = "bob";
⁠console.log(john); // { id: 1, name: "bob" }
Run me in Playground 

Para solucionar este problema podemos usar el operador spread ... y extraer las propiedades de john al crear un nuevo objeto bob:

language-javascript

⁠const john = { id: 1, name: "john" };
⁠const bob = { ...john, name: "bob" ⁠};
⁠console.log(john); // { id: 1, name: "john" }
Run me in Playground 

Otro ejemplo del problema de los estados compartidos podría suceder cuando nos movemos en operaciones concurrentes. En este caso tenemos una operación A, cuyo resultado es guardado en la variable x. Pero también tenemos la operación B, que computa un nuevo valor, guardándolo también en x. El resultado es un estado compartido: x hace referencia tanto al valor de A como al de B. Si ahora A y B son procesos concurrentes —lo que significaría que no podemos asegurar el orden en que se ejecutan—, tendríamos un programa difícil de seguir, puesto que no podríamos conocer con seguridad el valor de x en cada paso.

language-javascript

⁠let x = 0;

⁠function operationA(a, b) {
⁠ setTimeout(() => (x = a + b), Math.random() * 200);
⁠}

⁠function operationB(a, b) {
⁠ setTimeout(() => (x = a * b), Math.random() * 200);
⁠}

⁠operationA(2, 3);
⁠operationB(2, 3);

⁠// x == 5 or x == 6?

setTimeout(() => console.log("x:", x), 500); // 5 or 6
Run me in Playground 

Este problema se puede solucionar igualmente eliminando el estado compartido y manteniendo cada valor en su correspondiente variable:

language-javascript

⁠⁠const operationA = (a, b) =>
⁠ new Promise((resolve) => {
⁠ setTimeout(() => resolve(a + b), Math.random() * 200);
⁠ });

⁠const operationB = (a, b) =>
⁠ new Promise((resolve) => {
⁠ setTimeout(() => resolve(a * b), Math.random() * 200);
⁠ });

⁠const asyncOperationA = operationA(2, 3); // x == 5
⁠const asyncOperationB = operationB(2, 3); // x == 6

Promise.race([asyncOperationA, asyncOperationB]).then((value) => {
console.log(value);
⁠}); // 5 or 6
Run me in Playground 

Esta nueva versión de nuestro programa será más fácil de comprender y depurar, puesto que podemos conocer los valores guardados en memoria en cada paso.

Puridad

La programación funcional tiene como objetivo la simplicidad y predictabilidad de nuestros programas, de ahí el uso intenso que hace de las funciones. Pero para que las funciones sean predecibles, dado el mismo input deben retornar el mismo output. En este sentido una función reprensenta una relación inmutable entre dos sets: input y output, y esta relación debe mantenerse constante:

language-javascript

⁠const pureFunction = (a, b) => a < b;
⁠console.log(pureFunction(1, 2)); // true

⁠const impureFunction = (a, b) => (Math.random() > 0.5 ? a < b : a > b);
⁠console.log(impureFunction(1, 2)); // true or false
Run me in Playground 

La puridad facilita la predictabilidad, pero también nos permite memoizar resultados de funciones, evitando computaciones innecesarias.

Aridad

La aridad se refiere al número de parámetros que recibe una función.

  • Una función es de aridad 0 cuando no recibe ningún parámetro: es nularia.
  • Una función es de aridad 1 cuando recibe sólo un parámetro: es unaria.
  • Una función es de aridad 2 cuando recibe sólo un parámetro: es binaria.
  • Una función tiene aridad n cuando puede recibir cualquier número de parámetros: es n-aria —también conocida como variádica—.
language-javascript

⁠const nullaryFunction = () => "Hello world";
⁠console.log(nullaryFunction()); // hello world

⁠const unaryFunction = (greeting) => `${greeting} world`;
⁠console.log(unaryFunction("hello")); // hello world

⁠const binaryFunction = (greeting, subject) => `${greeting} ${subject}`;
⁠console.log(binaryFunction("hello", "world")); // hello world

⁠const nAryFunction = (...strings) =>
⁠ strings.reduce((acc, curr) => `${acc}${curr} `, "");
⁠console.log(nAryFunction("hello", "world", "and", "pluton"));
⁠// hello world and pluton
Arities, run me in Playground 

Nada especial aquí, únicamente algo de terminología. Pero es importante, ya que las funciones unarias están en el centro de algunas operaciones en programación funcional.

Currying

Currying es el proceso de transformar una función que recibe varios parámetros —aridad mayor que 0— en diversas funciones unarias anidadas —funciones que reciben únicamente un parámetro—.

language-javascript

⁠// Non curried function
⁠const sum = (a, b, c) => a + b + c;
⁠const result = sum(1, 2, 3);
⁠console.log(result); // 6

⁠// Curried function
⁠const sum = (a) => (b) => (c) => a + b + c;
⁠⁠const result = sum(1)(2)(3);
⁠console.log(result); // 6
Currying, run me in Playground 

Como veremos más adelante ésta es una técnica clave en programación funcional: hay muchas operaciones en las que necesitaremos que las funciones tengan una estructura similar —un sólo input para un output—, permitiendo así su combinación en diferentes formas.

Aplicación parcial

La aplicación parcial se refiere al proceso de transformar una función recibiendo diversos parámetros en otra función diferente que recibirá únicamente algunos de esos parámetros, fijándolos en su contexto y devolviendo otra función que recibirá el resto de parámetros, combinándolos con los prefijados.

Esto es realmente útil, puesto que nos permite crear funciones con parámetros preaplicados con anterioridad. Lo que significa que podemos especializar funciones.

Por ejemplo, digamos que tenemos una función createUrl que recibe tres cadenas de texto como parámetros: protocol, domain y path, combinándolos y devolviendo otra cadena:

language-javascript

⁠const createUrl = (scheme, domain, path) => `${scheme}://${domain}/${path}`;

⁠const httpUrl = createUrl("http", "example.com", "hello");
⁠console.log(httpUrl); // "http://example.com/hello"

⁠const httpsUrl = createUrl("https", "example.com", "hello");
⁠console.log(httpsUrl); // "https://example.com/hello"
Run me in Playground 

Problablemente usemos esta función en numerosos lugares de nuestro código con "http" o "https". Para escribir código más declarativo y evitar pasar el esquema siempre que necesitemos una urlpodemos crear dos versiones especializadas de createUrl: createHttpUrl y createHttpsUrl , fijando el parámetro scheme en el contexto de dichas funciones:

language-javascript

⁠const createUrl = (scheme, domain, path) => `${scheme}://${domain}/${path}`;

⁠// Partial application: fixing `scheme` parameter
⁠const createUrlWithProtocol = (scheme) =>
⁠ (domain, path) => createUrl(scheme, domain, path);
⁠⁠
⁠⁠const createHttpUrl = createUrlWithProtocol("http");
⁠const createHttpsUrl = createUrlWithProtocol("https");

⁠const httpUrl = createHttpUrl("http", "example.com", "hello");
⁠console.log(httpUrl); // "http://example.com/hello"

⁠⁠const httpsUrl = createHttpsUrl("https", "example.com", "hello");
⁠console.log(httpsUrl); // "https://example.com/hello"
With partial application, run me in Playground 

Estas nuevas funciones son más expresivas semánticamente, así como más eficientes, puesto que reducen la aridad de las funciones que utilizamos para crear las urls.

Estilo libre de puntos

El estilo libre de puntos —point-free style en inglés— es una forma de escribir funciones en la que éstas son procesadas sin hacer mención a sus parámetros —puntos—.

Veamos un ejemplo: cuando pasamos una función como parámetro a otra función es práctica común escribirla dentro de ella:

language-javascript

⁠const userName = users.map(
⁠ ({ first, lastName }) => `${first} ${lastName}`
⁠);
With parameters

En un estilo libre de puntos extraeríamos la declaración de la función para guardarla en una variable, que es lo que pasaremos a la nueva función:

language-javascript

⁠const composeName = ({ first, lastName }) => `${first} ${lastName}`;
⁠const userName = users.map(composeName⁠);
Point-free style

En general el estilo libre de puntos es más declarativo y legible, facilitando la composición de funciones como veremos más adelante.

Compose y pipe

En álgebra la composición de funciones es el proceso de transformar un conjunto secuencial de funciones unarias en otras anidadas, de modo que el resultado de una función se pasará como parámetro a la siguiente envolviéndola.

Digamos que tenemos dos funciones:

f(x) = 2x\\ ⁠⁠g(x) = x^2

Expresamos que las componemos con la siguiente sintaxis:

f \circ g =(g(x))

Lo que quieres decir que ejecutamos primero g(x), y después f(x) con el resultado obtenido.

Es importante remarcar que la composición no es conmitativa:

f \circ g \neq g \circ f

aunque sí es asociativa:

f(g(h(x))) = (f \circ g)(h(x)) = f(g(h(x))).

Todo esto se puede traducir a Javascript con facilidad, dado que en este lenguaje las funciones son ciudadanos de primera clase. Esto hace referencia a que podemos tratarlas como variables, de modo que cualquier función pueda aceptar otra como parámetro.

Dadas dos funciones podemos ejecutar una dentro de la otra:

language-javascript

⁠⁠const f = (x) => x * 2;
⁠const g = (x) => x * x;

⁠const result = f(g(3));
⁠console.log(result); // 18
Run me in Playground 

Y digamos que este tipo de operación es algo que hacemos a menudo: sería interesante si tuviésemos una utilidad que, dadas dos funciones nos las devolviese compuestas:

⁠language-javascript

⁠const f = (x) => x * 2;
⁠const g = (x) => x * x;

⁠const compose = (f, g) => (x) => f(g(x));

⁠const composed = compose(f, g);
⁠const resultComposed = composed(3); // 18
Run me in Playground 

En este caso lo que tenemos es una función que recibe dos parámetros —aridad 2—, realiza una aplicación parcial para fijarlos en el contexto de la closura, y devuelve una nueva función que, recibiendo un nuevo parámetro, los usará para ejecutar nuestras funciones anidadas, usando ese nuevo parámetro como argumento inicial.

De esta manera la función devuelta es guardada en la variable composed, que tendrá f y g en su contexto, y hará uso de ellos cuando es llamada con un nuevo argumento —3—.

Ahora digamos que queremos componer tres funciones:

f \circ g \circ h = f(g(x))

Usando la misma lógica podemos traducir esto a Javascript con una función compose de aridad 3:

language-javascript

⁠const f = (x) => x * 2;
⁠⁠const g = (x) => x * x;
⁠const h = (x) => x - 1;

⁠const compose = (f, g, h) => (x) => f(g(h(x)));

⁠const composed = compose(f, g, h);
⁠console.log(composed(3)); // 8
Run me in Playground 

Funciona perfectamente; pero no es muy escalable. El siguiente paso sería crear una función n-aria que, dado cualquier número de funciones pasadas como parámetros, las devuelve compuestas.

language-javascript

⁠const f = (x) => x * 2;
⁠⁠const g = (x) => x * x;
⁠const h = (x) => x - 1;

const compose = (...fns) => (x) =>
⁠ fns.reduceRight((acc, curr) => curr(acc), x);

⁠const composed = compose(f, g, h);
⁠console.log(composed(3)); // 8
Run me in Playground 

Esta función recibe cualquier número de funciones como parámetros individuales. Los distribuye en un array con el operador spread ..., y los fija en el contexto de la closura, devolviendo una nueva función. Esta nueva función será unaria, recibiendo como parámetro el dato initial para procesar nuestras funciones. Finalmente recorrerá el array de funciones de derecha a izquierda y ejecutándolas, lo que quiere decir que ejecutará primero la última con los datos iniciales proporcionados en el parámetro.

Lo que acabamos de hacer es crear una función compose: una utilidad n-aria muy socorrida para componer cualquier número de funciones, siempre y cuando éstas sean unarias.

Ahora digamos que queremos componer nuestras funciones, pero empezando desde la primera hasta la última: en lugar de Array.reduceRight podemos usar Array.reduce:

language-javascript

⁠const f = (x) => x * 2;
⁠⁠const g = (x) => x * x;
⁠const h = (x) => x - 1;

const pipe = (...fns) => (x) =>
fns.reduce((acc, curr) => curr(acc), x);

⁠const piped = pipe(f, g, h);
⁠console.log(piped(3)); // 35
Run me in Playground 

Este tipo de utilidad es conocida como pipe. Como dijimos anteriormente la composición no es conmutativa, por lo que devuelve un resultado distinto a la función compose.

Evaluación perezosa y ansiosa

Por "evaluación ansiosa" nos referimos al modelo de computación en que una variable es evaluada inmediatamente después de que una expresión aparezca en el código.

Por ejemplo, en Javascript:

language-javascript

⁠⁠const sum = (a, b) => a + b;
const result = sum(1, 2); // Computed here
⁠console.log(result); // 3
Run me in Playground 

Como vemos, la expresión es evaluada cuando llamamos a la función, no cuando el resultado de la función es usado.

Con la evaluación perezosa, por el contrario, se retrasa la computación hasta que el resultado es utilizado.

A excepción de los operadores lógicos && y || Javascript no soporta la evaluación perezosa. Y eso es un problema, puesto que nuestro código siempre consumira recursos por el total de operaciones que reflejemos en el código.

Sin embargo ésta puede ser implementada de diversas formas. Con los mencionados operadores lógicos por ejemplo —y de una forma muy limitada—, o usando generadores:

language-javascript

⁠function* lazySum(a, b) {
⁠ yield a + b;
⁠}

⁠let result = lazySum(1, 3); // computation delayed
⁠console.log(result); // not evaluated yet
⁠console.log(result.next().value); // 4
Run me in Playground 

Aquí la suma no se ejecutará cuando llamamos a lazySum, sino cuando usamos el resultado y llamamos next().

Más adelante veremos operaciones en programación funcional que se ejecutan perezosamente.

Conclusion

Esto es todo por hoy. Todos estos conceptos y operaciones son bastante comunes; pero son los cimientos de lo que veremos en nuestro próximo artículo: los transductores.