JavaScript funcional: conceptos básicos

Voy a iniciar una serie de artículos introductorios a la programación funcional. Abordaré este tema a modo de notas para recién llegados, pero también para mi futuro yo. El objetivo no será cubrir la programación funcional de un modo exhaustivo, sino proporcionar los conceptos y operaciones básicas sobre las que se basa.

La programación funcional se construye sobre operaciones muy comunes, como la composición o la aplicación parcial, desembocando en técnicas y operaciones especializadas, como los transductores. Su objetivo es minimizar los estados compartidos en nuestras aplicaciones, escribir código más declarativo —y por tanto más legible— y, finalmente, módulos más fáciles de testear.

Para esta serie vamos a utilizar un lenguaje común: Javascript. Este es un lenguaje que aunque funciona bien con programación funcional, no es un lenguaje pensado con este paradigma en mente como pueden serlo Erlang o Haskell. Por ello tendremos que implementar numerosas estructuras que en otros lenguajes se encuentran nativamente, lo que nos enseñará bastante sobre este tipo de programación.

Como aquí trataremos la programación funcional de un modo somero, para quien busque una aproximación algo más exhaustiva recomiendo dos recursos:

En este primer artículo vamos a 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 subsiguientes artículos cubriremos la composición otra vez, pero con la mirada puesta en los tipos y su relación con operaciones de programación funcional más avanzadas.

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 sherlock = { name: "Sherlock" };
⁠const bilbo = sherlock;
⁠bilbo.name = "Bilbo";
⁠⁠console.log(sherlock); // { name: "Bilbo" }
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 sherlock = { name: "Sherlock" };
⁠const bilbo = { ...sherlock };
⁠bilbo.name = "Bilbo";
⁠sherlock.name = "Sherlock";
⁠console.log(sherlock); // { name: "Sherlock" }
Run me in Playground 

Hay que tener en cuenta que el operator spread copiará por valor las propiedades en un primer nivel del objeto, pero las anidadas las copiará por referencia con shallow copy. Para evitar este problema podemos usar los métodos que nos provee el objeto JSON.

Otro ejemplo del problema de los estados compartidos podría suceder cuando nos movemos en operaciones concurrentes o que su. En este caso tenemos una operación increment, cuyo resultado es guardado en un objeto sharedState. Pero también tenemos la operación decrement, que computa un nuevo valor, guardándolo también en sharedState. El resultado es un estado compartido: sharedState hace referencia tanto al valor producido por increment como al de decrement. Teniendo en cuenta además que ambas operaciones son impuras, tenemos un código dificil de seguir y de testear.

language-javascript

const sharedState = { type: "shared state", value: 0 };

⁠const increment = () => {
⁠ sharedState.value += 1;
⁠};

⁠const decrement = () => {
⁠ sharedState.value -= 1;
⁠};

⁠increment();
⁠decrement();
⁠decrement();
⁠increment();
⁠decrement();
⁠increment();

⁠⁠console.log(sharedState); // { type: "shared state", value: 0 }
Run me in Playground 

Este ejemplo no es exactamente concurrente, pero ejemplifica bien la dificultad de seguir el valor del estado compartido a través del flujo de la aplicación. Si además tuviésemos diferentes módulos consumiendo y modificando sharedState podríamos encontrarnos con problemas a la hora de saber cuál es el estado de nuestra aplicación en cada momento de su ejecución.

Este problema se puede solucionar fácilmente eliminando el estado compartido y produciendo uno nuevo con cada operación que se realiza:

language-javascript

const initialState = { type: "non-shared state", value: 0 };

⁠const increment = (state) => ({
⁠ ...state,
⁠ value: state.value + 1,
⁠});

⁠const decrement = (state) => ({
⁠ ...state,
⁠ value: state.value - 1,
⁠});

⁠const stateIncremented = increment(initialState);
⁠const initialState2 = decrement(stateIncremented);
⁠const initialDecremented = decrement(initialState2);
⁠const initialState3 = increment(initialDecremented);
⁠const stateDecremented2 = decrement(initialState3);
⁠const initialState4 = increment(stateDecremented2);

⁠console.log(initialState4); // { type: "non-shared state", value: 0 }
Run me in Playground 

Los nombres de las variables elegidos no son elegantes ni demasiado expresivos, pero muestran la ventaja de este código frente al anterior: hemos pasado el estado de nuestro programa de una variable mutable cuyo contenido es sólo conocido en tiempo de ejecución, a una variable inmutable cuyo contenido es predecible en tiempo de compilación, motivo por el cual esta nueva versión de nuestro programa será más fácil de comprender, depurar y testear. En este sentido la inmutabilidad facilita la predictibilidad de nuestra aplicación.

Podríamos incluso guardar un histórico de todos los estados de nuestra aplicación, lo que nos facilitaría el proceso de depuración y análisis post-mortem en caso de que surjan problemas.

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 1— 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 = f(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 conmutativa:

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 variádica 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 a su vez como parámetro el dato inicial 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 variádica 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 || y el operador ternario, 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.

Conclusión

Esto es todo por hoy. Todos estos conceptos y operaciones son bastante comunes; pero son los cimientos de las operaciones que veremos próximamente. El código del post se puede ejecutar en el Playground de TypeScript, así como en git.antoniodiaz.me.

Si tienes cualquier duda o has encontrado un error no dudes en escribirme a la dirección que figura en el pie.