(Array) Reduce: cómo y cuando
Vamos a hablar del elefante rosa en el prototipo Array
, me refiero al a veces odiado método reduce pero no vamos a discutir sobre si esta función es buena o mala. Vamos a descubrir qué es lo que hace internamente, luego intentaremos descubrir las situaciones en las que puede ser una solución efectiva.
Para asegurarnos de entender su funcionamiento vamos a empezar implementando nuestra versión.
¿Cómo funciona?
reduce
es una función que toma una lista de valores y la transforma en otra cosa. La palabra clave aquí es transformación. Esta transformación la determina el "usuario" de nuestra función, son ellos los que deciden qué pasará ¿Qué quiere decir eso? Significa que aparte del arreglo que vamos a procesar necesitamos aceptar una función (un callback) como parámetro. Así que la firma de la función sería esta.
function reduce(arr, callback) {
// código...
}
Ya tenemos algunos valores, y ahora ¿Qué hacemos con ellos? Sabemos que los métodos del prototipo Array
aplican una función a cada uno de sus elementos. Hagamos eso.
function reduce(arr, callback) {
for(const valor of arr) {
callback(valor);
}
}
Todavía no hace lo que queremos pero se acerca. Ahora falta el ingrediente secreto, el acumulador. Esta será una variable que crearemos para recordar el estado actual de nuestra transformación. Cada vez que apliquemos la función callback
a un valor guardamos el resultado en el acumulador. Como bono extra, antes de guardar el nuevo estado en el acumulador le pasamos a callback
el estado actual para que nuestro "usuario" no tenga que hacer ningún esfuerzo extra.
function reduce(arr, callback) {
+ let estado;
for(const valor of arr) {
- callback(valor);
+ estado = callback(estado, valor);
}
+ return estado;
}
Recuerden bien esas líneas que están resaltadas. Por muy complicado que se vea reduce
en el exterior, sin importar cuantos trucos raros vean por ahí, esas tres líneas son lo único que importa.
Aunque no sea una replica exacta de Array.reduce
será suficiente para nuestros propósitos. Vamos a probarla.
const array1 = [1, 2, 3, 4];
const callback = (estado, valor) => {
if(estado == null) {
return valor;
}
return estado + valor;
};
// 1 + 2 + 3 + 4
reduce(array1, callback);
// valor esperado: 10
¿Ven ese if
? Está ahí porque en la primera iteración estado
no tiene un valor, eso parece innecesario. Nosotros como autores de reduce
podemos ayudar a reducir la cantidad de código que necesita callback
. Al disminuir la carga de responsabilidad que necesita callback
podemos hacer que reduce
sea mucho más flexible. Lo que haremos será tomar el primer valor del arreglo y ese se convertirá en el estado
para nuestra primera iteración.
function reduce(arr, callback) {
- let estado;
- for(const valor of arr) {
+ let estado = arr[0];
+ let resto = arr.slice(1);
+ for(const valor of resto) {
estado = callback(estado, valor);
}
return estado;
}
Vamos otra vez.
const array1 = [1, 2, 3, 4];
const callback = (estado, valor) => {
return estado + valor;
};
// 1 + 2 + 3 + 4
reduce(array1, callback);
// valor esperado: 10
Si aún les cuesta un poco descifrar lo que está pasando, puedo ayudarles con eso. Si sacamos callback
de la ecuación esto es lo que sucede.
function reduce(arr) {
let estado = arr[0];
let resto = arr.slice(1);
for(const valor of resto) {
estado = estado + valor;
}
return estado;
}
¿Recuerdan las tres líneas?
function reduce(arr) {
+ let estado = arr[0];
let resto = arr.slice(1);
for(const valor of resto) {
+ estado = estado + valor;
}
+ return estado;
}
¿Lo notaron? Eso es todo lo que necesitan recordar. Básicamente,reduce
nos da la habilidad de transformar una operación que actúa sobre dos valores a una que actúa sobre una cantidad variada.
¿Cuando es útil?
reduce
es una de esas funciones que pueden utilizarse en muchas ocasiones pero no en todas es la mejor solución. Ahora que sabemos cómo funciona veamos en qué tipo de situaciones puede ser la mejor opción.
Un caso ideal
El ejemplo anterior ya debería darles una pista. Nuestra función es más efectiva cuando seguimos ciertos patrones. Pensemos un momento en lo que hace callback
en nuestro ejemplo. Sabemos que necesita dos números, ejecuta una operación matemática y nos devuelve otro número. Entonces hace esto.
Número + Número -> Número
Bien, pero si damos un paso atrás y pensamos en términos más generales lo que tenemos es esto.
TipoA + TipoA -> TipoA
Hay dos valores del mismo tipo (TipoA) y una operación (el signo +) que nos devuelve otro valor del mismo tipo (TipoA). Cuando lo vemos de esa manera podemos darnos cuenta de un patrón que puede ser útil más allá de las operaciones matemáticas. Hagamos otro ejemplo con números pero esta vez lo que haremos será una comparación.
function max(un_numero, otro_numero) {
if(un_numero > otro_numero) {
return un_numero;
} else {
return otro_numero;
}
}
max
es una operación que actúa sobre dos números, los compara y devuelve el mayor. Es muy general y con una capacidad limitada. Si volvemos a pensar en lo abstracto vemos ese patrón otra vez.
TipoA + TipoA -> TipoA
O si somos más específicos.
Número + Número -> Número
Ya saben lo que significa, podemos usar reduce
para ampliar su capacidad.
const array2 = [40, 41, 42, 39, 38];
// 40 > 41 > 42 > 39 > 38
reduce(array2, max);
// valor esperado: 42
Resulta que el patrón que hemos estado siguiendo para crear el callback
que necesita reduce
tiene un nombre en el paradigma funcional, a este lo llaman Semigrupo. En cualquier ocasión en la tengan dos valores de un mismo tipo y puedan combinarlos para crear otra instancia, están en la presencia de un semigrupo. En otras palabras, dos valores + forma de combinarlos = Semigrupo.
Una manera de probar que tienen una operación que sigue las reglas de un semigrupo es asegurarse de que la función cumple con la propiedad asociativa. Nuestra función max
por ejemplo.
const max_1 = max(max(40, 42), 41);
const max_2 = max(40, max(42, 41));
max_1 === max_2;
// valor esperado: true
¿Ven? Ejecutarla con el tipo de dato adecuado en diferente orden no afecta su resultado. Esto nos da la garantía de que funcionará si la combinamos con reduce
y un arreglo de números.
¿Pero podríamos aplicar estas reglas a una estructura más compleja? Claro que sí. En javascript ya tenemos un par que las cumplen. Piensen en los arreglos, en el prototipo Array
tenemos el método concat
, este nos permite mezclar dos arreglos y crear uno nuevo con los elementos de ambos.
function concat(uno, otro) {
return uno.concat(otro);
}
Con esto tenemos que.
Array + Array -> Array
Okey, el segundo parámetro de concat
no tiene que ser un arreglo pero ignoraremos eso por el momento. Entonces si combinamos concat
con reduce
.
const array3 = [[40, 41], [42], [39, 38]];
// [40, 41] + [42] + [39, 38]
reduce(array3, concat);
// valor esperado: [40, 41, 42, 39, 38]
Ahora si quisieramos podríamos crear una función que "aplana" un nivel de un arreglo multidimensional, ¿No es genial? Y al igual que con los números, con los arreglos no tenemos porque limitarnos a operaciones que nos provee javascript. Si tenemos alguna función auxiliar que trabaje con dos arreglos y cumpla con la propiedad asociativa podemos combinarla con reduce
.
Digamos que tenemos una función que une los elementos únicos de dos arreglos.
function union(uno, otro) {
const set = new Set([...uno, ...otro]);
return Array.from(set);
}
Bien, tenemos una función que trabaja con dos valores de un mismo tipo ahora veamos si cumple con la propiedad asociativa.
const union_1 = union(union([40, 41], [40, 41, 42]), [39]);
const union_2 = union([40, 41], union([40, 41, 42], [39]));
union_1.join(',') == union_2.join(',');
// valor esperado: true
Sí cumple con las reglas, eso quiere decir que es posible procesar una cantidad variada de arreglos si usamos reduce
.
const array4 = [
['hello'],
['hello', 'awesome'],
['world', '!'],
['!!!!', 'world']
];
reduce(array4, union);
// valor esperado: [ "hello", "awesome", "world", "!", "!!" ]
Algo de resistencia
Habrán notado que en todos los ejemplos nuestros arreglos de datos son todos del tipo correcto, esto no siempre es así en el "mundo real". Podemos encontrarnos situaciones en las que el primer elemento de un arreglo no es un dato válido para nuestra operación.
Imaginemos que queremos usar concat
otra vez pero el arreglo que tenemos para procesar es el siguiente.
const array5 = [40, 41, [42], [39, 38]];
Si intentamos usar reduce
.
reduce(array5, concat);
Obtenemos esto.
TypeError: uno.concat is not a function
Esto ocurre porque en la primera iteración el valor de uno
es el número 40
, el cual no posee un método concat
. ¿Qué debemos hacer? Por lo general se considera una buena práctica utilizar un valor inicial fijo para evitar este tipo de errores. Pero tenemos un problema, nuestro reduce
no acepta un valor inicial, así que deberíamos corregir eso.
- function reduce(arr, callback) {
- let estado = arr[0];
- let resto = arr.slice(1);
+ function reduce(arr, ...args) {
+ if(args.length === 1) {
+ var [callback] = args;
+ var estado = arr[0];
+ var resto = arr.slice(1);
+ } else if(args.length >= 2) {
+ var [estado, callback] = args;
+ var resto = arr;
+ }
for(const valor of resto) {
estado = callback(estado, valor);
}
return estado;
}
Ahora para evitar el error anterior lo que haremos será pasarle a reduce
un arreglo vacío como valor inicial.
reduce(array5, [], concat);
// valor esperado: [ 40, 41, 42, 39, 38 ]
Ya no hay error y pudimos obtener el arreglo que queríamos. Pero fíjense en una cosa, el arreglo vacío no sólo logró evitar el error sino que también dejó intacto el resultado de la operación. Al igual que con los números, con los arreglos tenemos la noción de un elemento vacío que podemos usar en nuestras operaciones sin causar un error en nuestro programa.
El arreglo vacío puede considerarse como un elemento identidad, un valor neutro que al aplicarlo a una operación no tiene ningún efecto en el resultado final. Adivinen qué, este comportamiento también tiene un nombre en el paradigma funcional, se le conoce como Monoid. Cuando tenemos un semigrupo con un elemento identidad estamos en presencia de un monoid. Entonces, semigrupo + elemento identidad = Monoid.
Podemos probar que los arreglos siguen las reglas de un monoid para nuestras operaciones.
// Concat
const concat_1 = concat([], ['hello']) // => ["hello"]
const concat_2 = concat(['hello'], []) // => ["hello"]
concat_1.join(',') == concat_2.join(',');
// valor esperado: true
// Union
const union_3 = union([], ['hello']); // => ["hello"]
const union_4 = union(['hello'], []); // => ["hello"]
union_3.join(',') == union_4.join(',');
// valor esperado: true
¿Por qué es importante? Piensen en esto: ¿Cuantas veces han tenido que escribir un if
para resguardar una operación de un valor null
o undefined
? Si podemos representar un "valor vacío" de una manera más segura podemos eliminar toda una categoría de errores en nuestros programas.
Otra situación donde los monoids son útiles es cuando queremos ejecutar una operación "insegura" sobre un valor. Podríamos aplicar esa operación sobre una referencia a un valor vacío y así dejar el resto de los elementos intactos.
Imaginen que tienen fragmentos de información esparcidos en varios objectos y queremos unirlos.
const array6 = [
{name: 'Harold'},
{lastname: 'Cooper'},
{state: 'wrong'}
];
Normalmente usarían la sintaxis de extensión para mezclar todo eso, pero digamos que vivimos en un mundo donde eso no es posible. No teman, tenemos una función que puede hacer el trabajo.
Object.assign;
Si lo piensan Object.assign
también sigue el patrón.
TipoA + TipoA -> TipoA
Si le pasamos dos objetos nos devuelve un nuevo objecto. Pero hay algo que deben saber, Object.assign
modifica el objeto que le pasamos como primer parámetro. Entonces si hacemos esto.
reduce(array6, Object.assign);
// Valor esperado: { "name": "Harold", "lastname": "Cooper", "state": "wrong" }
Parecería que todo está bien, pero no es así. Si revisan array6[0]
verán que ha cambiado, definitivamente no quieren eso. Por suerte para nosotros los objetos en javascript se comportan como monoids, así que podemos usar un "valor vacío". Entonces la manera correcta de usar reduce
en este caso sería esta.
reduce(array6, {}, Object.assign);
// Valor esperado:
// { "name": "Harold", "lastname": "Cooper", "state": "wrong" }
array6;
// Valor esperado:
// [ { "name": "Harold" }, { "lastname": "Cooper" }, { "state": "wrong" } ]
Podemos decir que cuando trabajamos con un arreglo de estructuras que siguen las reglas de los monoid podemos estar seguros que reduce
será una buena opción para procesarlo.
Más allá de los arreglos
Si nosotros pudimos implementar una versión de reduce
para los arreglos entonces no sería del todo extraño pensar que otras personas hayan incorporado algo similar a otras estructuras. Saber cómo funciona reduce
puede ser muy útil si utilizan una librería que tenga un método parecido.
Por ejemplo, la librería mithril-stream tiene un método llamado scan
que tiene la siguiente forma.
Stream.scan(fn, accumulator, stream)
Esa variable fn
debe ser una función que debe tener la siguiente firma.
(accumulator, value) -> result | SKIP
¿Reconocen eso? Espero que sí. Son los mismos requerimientos de reduce
. ¿Pero qué hace esa función? Bueno, ejecuta la función fn
cuando la fuente (stream
) produce un nuevo dato. Cuando la función fn
es ejecutada recibe como parámetro el estado actual del acumulador y el nuevo dato producido, luego el resultado retornado por fn
se convierte en el nuevo estado del acumulador. ¿Les suena familiar ese comportamiento?
Pueden probar el método scan
con nuestra función union
y ver cómo se comporta.
import Stream from 'https://cdn.pika.dev/mithril-stream@^2.0.0';
function union(one, another) {
const set = new Set([...one, ...another]);
return Array.from(set);
}
const list = Stream(['node', 'js']);
const state = Stream.scan(union, [], list);
state.map(console.log);
list(['node']);
list(['js', 'deno']);
list(['node', 'javascript']);
Deberían observar cómo la lista sólo agrega elementos que no han sido agregados antes.
Pueden ver en acción una versión modificada de ese fragmento en codepen.
¿Vieron? nuestro conocimiento de reduce
(y tal vez algo de semigrupos y monoids) nos puede ayudar a crear funciones auxiliares que podemos reusar con diferentes estructuras. ¿No es genial?
Conclusión
Aunque no mencioné todas las cosas que pueden hacer con reduce
ahora tienen las herramientas para poder identificar los casos en lo que puede ser utilizado efectivamente, incluso si no están seguros pueden hacer las pruebas necesarias para garantizar que la operación que quieren ejecutar tiene las características adecuadas.
Fuentes
- Practical Category Theory: Monoids (video)
- Funcadelic.js
- Functional JavaScript: How to use array reduce for more than just numbers
- Array.prototype.reduce (MDN)
- Fantasy Land
Gracias por su tiempo. Si este artículo les pareció útil y quieren apoyar mis esfuerzos para crear más contenido, pueden dejar una propina en buymeacoffee ☕.