JavaScript funcional: transformers

En nuestro anterior artículo desarrollamos algunos de los elementos básicos de la programación funcional. En él discutimos conceptos como inmutabilidad, puridad o aridad y operaciones como aplicación parcial, currying o composición, todas ellas elementos fundamentales de este paradigma.

En este artículo vamos a apoyarnos en estas operaciones básicas para construir algunas funciones que nos van a ayudar a la hora de procesar datos voluminosos. Pero antes repasemos de nuevo la composición.

Composición de funciones

Cuando describimos la composición de funciones comenzamos con la definición que se da en álgebra:

... 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.

Esto lo logramos haciendo uso de una función a la que llamamos compose, que se ejecuta de derecha a izquierda:

language-javascript

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

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

⁠const composed = compose(f, g, h);
⁠const result = composed(3);
⁠console.log(result); // 50
Compose, run me in Playground.

O una función pipe, que se ejecuta de izquierda a derecha:

language-javascript

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

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

⁠const piped = pipe(f, g, h);
⁠const result = piped(3);
⁠console.log(result); // 38
Pipe, run me in Playground.

También comentamos que la composición no es conmutativa, f \circ g \neq g \circ f:

⁠language-javascript

⁠const composedA = compose(f, g);
⁠const composedB = compose(g, f);

⁠const isCommutative = composedA(3) === composedB(3);

⁠console.log(isCommutative); // false
Run me in Playground.

Si bien sí es asociativa, f \circ (g \circ h) = (f \circ g) \circ h:

language-javascript

⁠const composedA = compose(f, compose(g, h));
⁠const composedB = compose(compose(f, g), h);

⁠const isAssociative = composedA(3) === composedB(3);

⁠console.log(isAssociative); // true
Run me in Playground.

Nada nuevo, todo esto ya habíamos visto en nuestro anterior artículo. Pero es bueno repasarlo, ya que es fundamental para lo que vamos a tratar a continuación.

Operando con iterables

Hasta ahora hemos visto cómo podemos componer funciones para trabajar con primitivos u objetos; pero hemos obviado el caso de los iterables, como puede ser el caso de Array en Javascript.

Para poder trabajar con ellos tenemos que apoyarnos en algunas utilidades que vamos a implementar a continuación.

Reducers

La primera función que nos va a ayudar a trabajar con iterables es conocida como "reducer: funciones binarias —de aridad 2— que reducen dos valores en un único resultado.

Un reducer sencillo tiene la siguiente forma:

language-javascript

⁠const accumulate = (a, b) => a + b;
⁠console.log(accumulate(1, 2)); // 3

⁠const concat = (array, val) => [...array, val];
⁠console.log(concat([1], 2)); // [1, 2]

⁠const power = (base, exponent) => base ** exponent;
⁠console.log(power(2, 4)); // 16
Run me in Playground.

Aunque también puede hacer merge de dos objetos:

language-javascript

⁠const merge = (a, b) => ({ ...a, ...b });
⁠console.log(merge({ a: 1 }, { b: 2 })); // { a: 1, b: 2 }
Run me in Playground.

O puede contener una lógica un poco más elaborada: por ejemplo, un reducer que recibe una lista y un valor, el cual es sumado a cada elemento de esa lista:

language-javascript

⁠const sumEach = (iterable, val) => {
⁠ const result = [];

⁠ for (const item of iterable) {
⁠ result.push(item + val);
⁠ }

⁠ return result;
⁠};

⁠const result = sumEach([1, 2], 3);
⁠console.log(result); // [4, 5]
Run me in Playground.

Esta última función es interesante, pero tiene un problema: hace dos cosas entrelazadas. Por una parte "reduce" los dos valores en uno sólo —dos enteros a un único valor, un array y un entero a otro array, dos objetos a uno sólo, etc.—, y por otra contiene cierta lógica que ejecuta sobre los valores a reducir —sumar un valor a cada item del array—.

Sería perfecto si pudiésemos desacopar ambas tareas.

Podemos empezar extrayendo la parte de la suma, que le pasaríamos como un callback y que a su vez sería un reducer: así podríamos ejecutarlo con el iterable a transformar, que será inicializado con un valor inicial initialValue. Y como nuestra función sumEach ya no tendrá la responsabilidad de la suma, podremos darle un nombre acorde a lo que hace: reduce.

language-javascript

⁠const reduce = (reducer, iterable, initialValue) => {
⁠ let accumulation = initialValue;

⁠ for (const item of iterable) {
⁠ accumulation = reducer(accumulation, item);
⁠ }

⁠ return accumulation;
⁠};

⁠const data = [1, 2, 3];
⁠const increaseOne = (acc, val) => acc.concat([val + 1]);
⁠const result = reduce(increaseOne, data, []);
⁠console.log(result); // [2, 3, 4]
Run me in Playground.

Aquí ya hemos dado un paso: hemos logrado separar la parte de la operación de suma respecto de la operación de reducción, y albergarla en increaseOne. Y si esta función reduce te suena familiar, es porque es prácticamente idéntica al método Array.reduce de JavaScript.

language-javascript

⁠const data = [1, 2, 3];
⁠const increaseOne = (acc, val) => acc.concat([val + 1]);
⁠const result = data.reduce(increaseOne, []);
⁠console.log(result); // [2, 3, 4]
Run me in Playground.

Si ahora recordamos nuestra función compose, veremos que está usando Array.reduceRight().

language-javascript

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

Podemos ver qué sucede al reemplazar Array.reduceRight() por nuestro reduce.

language-javascript

⁠const compose = (...fns) => (initialData) =>
⁠ reduce((acc, curr) => curr(acc), fns.reverse(), initialData);

⁠const functionA = (value: number) => value + 1;
⁠const functionB = (value: number) => value * 2;
⁠const functionC = (value: number) => value + 3;

⁠const composed = compose(functionA, functionB, functionC);
⁠const result = composed(1);

⁠console.log(result); // 9
Run me in Playground.

Funciona perfectamente. Lógico, ya que los tipos de entrada y salida son prácticamente los mismos. Cabe destacar que hemos invertido el array que alberga las funciones a componer con Array.reverse(), ya que compose debe ejectarlas de derecha a izquierda.

Para implementar pipe procederíamos del mismo modo, pero sin invertir el array, ya que las debe ejecutar de izquierda a derecha:

language-javascript

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

⁠const functionA = (value: number) => value + 1;
⁠const functionB = (value: number) => value * 2;
⁠const functionC = (value: number) => value + 3;

⁠const piped = pipe(functionA, functionB, functionC);
⁠const result = piped(data);

⁠console.log(result); // 7
Run me in Playground.

Fantástico, ya hemos implementado nuestro propio Array.reduce(), que es agnóstico respecto del reducer que se le pasa, y lo hemos usado en nuestros propios compose y pipe.

Transformers

Ahora que ya tenemos nuestra función reduce podemos avanzar un paso más allá y ver qué son y para qué sirven los "transformers".

Una de las operaciones más comunes que se puede hacer sobre iterables es convertir cada uno de sus elementos de un tipo A a otro tipo diferente B.

Para ello podemos crear una función map que recibirá una función de transformación a la que llamaremos mapper, y que será la encargada de realizar la operación para transformar cada elemento del iterable. Este map creará otra función que admitirá un reducer, y que se encargará de la operación de acumular los elementos del iterable a una estructura de datos determinada —otro array, un objeto, un entero, etc—, pero independientemente de la transformación que se realice sobre cada elemento del iterable. A su vez esta segunda función devolverá otro reducer, el cual aplicará ambas operaciones —la transformación (el mapper) y la acumulación (el reducer)— sobre el iterable que se le pase:

language-javascript

const map = (mapper) => (reducer) => (acc, value) =>
⁠ reducer(acc, mapper(value));

Suena complicado, pero quedémonos con la idea de que nuestro map, al ser llamado con un mapper, crea una función que tiene una particularidad: recibe un reducer y devuelve otro reducer.

Fijémonos en el siguiente ejemplo: dado un array con enteros, podemos hacer uso de la función map pasándole una función que convertirá el entero que reciba a una cadena de texto. Después volvemos a llamar a esta función pasándole el acumulador a utilizar —devolver un nuevo array con concat— para finalmente pasarle esta última función a reduce y que la utilice.

language-javascript

⁠const map = (mapper) => (reducer) => (acc, value) =>
⁠ reducer(acc, mapper(value));

⁠const data = [1, 2, 3];
const integerToString = map((x) => x.toString());
⁠const integerToStringWithReducer = integerToString(concat);
⁠const result = reduce(integerToStringWithReducer, data, []);

⁠console.log(result); // ["1", "2", "3"]
Run me in Playground.

Y del mismo modo que hemos creado una función map que transforma valores podríamos crear una función similar para seleccionar elementos de un array.

Por ejemplo, en lugar de nuestro map, podemos crear una función filter que recibiese como argumento otra función a la que llamaremos predicate y que evaluase si un elemento dado del array debe o no quedarse en el array. Y al igual que en el map esta segunda función recibirá un acumulador encargado de la operacion reduce a realizar y devolverá otro reducer. La diferencia radica en que en el caso del filter este último reducer aplicará el predicado sobre el valor en cuestión, y si el resultado es true, lo añadirá al array; en caso contrario, lo descartará.

language-javascript

const filter = (predicate) => (reducer) => (acc, val) =>
⁠ predicate(val) ? reducer(acc, val) : acc;

Esta función filter la podríamos utilizar de un modo muy similar a como hicimos con map.

language-javascript

const filter = (predicate) => (reducer) => (acc, val) =>
⁠ predicate(val) ? reducer(acc, val) : acc;

⁠const data = [1, 2, 3];
⁠const isOdd = filter((x) => x % 2 !== 0);
⁠const isOddWithReducer = isOdd(concat);
⁠const result = reduce(isOddWithReducer, data, []);

⁠console.log(result); // [1, 3]
Run me in Playground.

Al igual que sucedía reduce, podemos ver que filter y map tienen sus análogos nativos en Array.map y Array.filter, produciendo resultados similares. Pero en este caso nuestras funciones tienen una particularidad: pueden componerse.

Por ejemplo, podemos crear sendas funciones que primero seleccionen los valores impares y seguidamente sumen 1 a cada uno de los resultantes con pipe.

language-javascript

const data = [1, 2, 3, 4, 5];

⁠const isOdd = filter((x: number) => x % 2 !== 0);
⁠const incrementValue = map((x: number) => x + 1);

const incrementOddValues = pipe(incrementValue, isOdd);
⁠const incrementValueWithReducer = incrementOddValues(concat);
⁠const result = reduce(incrementValueWithReducer, data, []);

⁠console.log(result); // [2, 4, 6]
Run me in Playground.

Y al igual que anteriormente, la composición no es conmutativa, de modo que el orden de las funciones importa: si en lugar de pipe —de izquierda a derecha— usamos compose —de derecha a izquierda— vamos a ver un resultado diferente.

language-javascript

const data = [1, 2, 3, 4, 5];

⁠const isOdd = filter((x: number) => x % 2 !== 0);
⁠const incrementValue = map((x: number) => x + 1);

const incrementOddValues = compose(incrementValue, isOdd);
⁠const incrementValueWithReducer = incrementOddValues(concat);
⁠const result = reduce(incrementValueWithReducer, data, []);

⁠console.log(result); // [3, 5]
Run me in Playground.

Conclusión

Recapitulemos: hemos visto qué son los reducers, qué son los transformers y dos funciones relacionadas con ellos: mappers y filters.

  • Reducer: función pura que acepta dos valores y los reduce en un único valor. No se pueden componer, ya que devuelven un valor único, mientras que reciben dos.
  • Transformer: función encargada de transformar datos en iterables. Las usamos para crear otro tipo de funciones que se pueden componer con facilidad.
  • Mapper: función que acepta otra función como parámetro, la cual convierte datos del tipo A al tipo B usando la función recibida.
  • Filter: función que acepta una función predicado —que devuelve booleano—, y crea otra función que usará ese predicado para saber si un elemento del array en cuestión debe mantenerse en el valor devuelto o no.

Es importante tener claros estos conceptos, ya que son la base de lo que vamos a ver a continuación: los transducers.

Esto es todo por hoy. Como siempre, el código se puede ejecutar en el Playground de TypeScript, así como está disponible para descarga 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.