Los Functors son uno de esos conceptos ampliamente usados por su utilidad a la hora de abstraer la aplicación de funciones, pero a la vez menos comprendidos porque la teoría existente es espesa sobre todo si no se tiene cierto bagaje matemático. El caso es que cuando interiorizas que un simple Array de los que usamos diariamente es un Functor y su porqué, zambullirse en la piscina de conceptos más complejos como las mónadas, se vuelve mucho más llevadero.
He de decir también, que todo el tema de Functors, Monads, Endofuctors y demás conceptos es algo bastante trillado, unos tienen un enfoque más teórico, otros más visuales y otros más prácticos, pero en mi humilde opinión todos fallan en la misma cosa: No evitan que al finalizar la lectura se te quede la misma cara que tendría un niño tomando bicarbonato más el añadido de rondarte por la cabeza la famosa pregunta que tantas master class ha arruinado: ¿Esto para que vale?.
Aparte de que la mayoría de artículos están redactados en el idioma de Shakespeare y los pocos articulos en español que existen tienden, en mi opinión, a mezclar teoría de categorías con ejemplos demasiado sencillos que imposibilitan que el lector cree una relación entre concepto y aplicación. Además, no es la primera vez que explico este concepto, he hablado de Functors y Monads desde un enfoque práctico en mi trabajo, mis cursos, clases y charlas y he de decir que en la actualidad mis compañeros y yo los usamos en nuestro día a día.
Tipos de datos
Un tipo de dato es una forma de representar información y puede ser cualquier cosa realmente, desde los tipos de datos "primitivos" (en JavaScript: Number, String, Boolean, null, undefined, Symbol
) a los no primitivos (Objects y demás estructuras). En estos últimos podemos meter también a los tipos personalizados que nosotros nos creemos. Es decir, si definimos:
const Address = name => ({ name });
Ahora todos los objetos instanciados a partir de la factoría Address, pueden ser considerados "direcciones". Es cierto que no existe un mecanismo construido en el lenguaje que me permita elevar a Address a la categoría de tipo, puesto que el tipado del lenguaje es "débil", pero eso no nos impide el poder tratarlo como tal para otorgar semántica a nuestro código.
Sea cual sea nuestro dato, vamos a querer operar con ellos de una forma u otra.
Definiendo un API perfecta
Como bien define James Forbes en The perfect API, el API perfecta para poder trabajar con cualquier dato debería de proveer como mínimo lo siguiente:
Creación (Tenemos que poder crear el dato)
Transformación (Una vez que tenemos el dato, tenemos que transformarlo)
Manejo de errores
Vamos a ir viendo si nuestros tipos de datos actuales (primitivos y no primitivos) cumplen estas reglas:
Creación
const number = 1;
const str = "Extremadura";
const address = Street("C/ Greco 2"); // Address es un tipo de Object
const numbers = [1,2,3]; // Array es un tipo de Object
const promise = Promise.resolve(1); // Promise es un tipo de Object
Bien, parece que podemos crear tipos. El principal problema que vemos aquí es que la forma de "creación" no es estándar. Los tipos primitivos se "instancian" con un tipo literal y los no primitivos usando sus correspondientes factorías.
Transformación
Vamos a ver ahora como se comportan a la hora de transformarlos. Esto es, aplicar algún tipo de cambio sobre ellos:
const double = x => x * 2;
const toUpperCase = str => str.toUpperCase();
double(number); // 2
toUpperCase(str) // EXTREMADURA
toUpperCase(address) // NaN. Ups!
double(numbers) // NaN. Ups!
double(promise) // NaN. Ups!
Aquí ya aparecen más problemas. Con los tipos primitivos no hemos tenido mayor contrariedad. Pero con Address, Array y Promise la cosa se complica puesto que representan la información de forma muy diferente a los tipos anteriores. En lugar de tener el valor directamente para usarlo, en el caso de las promesas, arrays y objetos, parece como si nuestro contenido estuviese dentro de una "caja".
También podemos observar que el control de errores no es muy bueno, debido a que la forma de ejecutar funciones sobre valores no es un proceso transparente, nos tendremos que ver obligados a rodear el código anterior de mucha lógica, haciendo preguntas del palo:
¿Es number un numero? o ¿es
null
oundefined
?¿Es person un string para poder ejecutar toUpperCase? o ¿es un objeto y tengo que averiguar como acceder a sus propiedades?
Como podemos ver, al ser cada tipo de su madre y de su padre, la transformación es tediosa e implica tener en mente esas preguntas a la hora de aplicar funciones.
La caja, la caja
Hemos visto que aplicar funciones (ejecutar funciones sobre un valor) en los tipos primitivos es sencillo, tengo una variable con mi valor y simplemente la paso a las funciones. Siempre y cuando el valor esté dentro del dominio de valores que acepta mi función, todo irá bien (rollo si le paso el numero "1" a la funcion toUpperCase, pues lo mismo me insulta porque espera Strings, ergo el numero 1 no está en el dominio de String). Pero, ¿qué sucede con address, numbers y promise?, ¿acaso estos elementos ponen fin al uso de funciones para siempre?
Pues obviamente no.
Antes hemos mencionado que estos tipos de objetos representan la información como si estos estuviesen dentro de una caja, por tanto, debe de haber un mecanismo para abrir esa caja, ¿no?
Mappeable
Resulta que estas cajas son o pueden ser Mappeables, es decir tienen un método map que recibe una función y la aplica sobre los elementos que contiene. Ósea se, que si tengo un Array el map va aplicar la función a todos los elementos del mismo y si es una promesa va a esperar a que se resuelva y va a aplicar la función:
const numbers = [1, 2, 3];
numbers.map(double) // [2, 4, 6]
const promise = Promise.resolve(1);
promise.then(double) // Promise.resolve(2)
¡Ah! y ¿qué pasa con Address? Pues por defecto no tiene un método map, o lo que es lo mismo no es mappeable, pero como Address es un objeto de nuestro dominio y lo hemos definido nosotros, podemos añadírselo:
const Address = (name) => ({
name,
map(fn) {
return Address(fn(name))
}
});
¿Recordáis qué pasaba cuando usábamos el map o then de los arrays y las promesas para aplicar funciones?, que siempre acabábamos con el mismo tipo. Es decir, el resultado de hacer numbers.map(double)
era otro Array. Y es que esta es una regla fundamental: map siempre tiene que devolver el mismo tipo sobre el que estamos mapeando.
¿Quién contendría todos esos pobres numeritos si después de aplicar la función double
, rompemos el Array
?
Bien, una vez que tenemos una función map dentro de Address, vamos a ver si funciona nuestro ejemplo de antes:
const address = Address("C/ Luis Chamizo");
address.map(toUpperCase) // Address("C/ LUIS CHAMIZO")
El map de Array o de Address está implementado dentro de ellas porque ¿quienes mejor que Array o Address van a saber como mapearse?
Te habrás avispadamente fijado en que Promise no tiene un método map, pero si uno llamado then. Por las peculiaridades de Promise (y porque en el tc39 así fue definido), el método no sigue el "estándar" de Mappeable pero puedes pensar en then, como un map asíncrono.
Pues bien, en programación funcional, a un Mappeable, se le llama Functor.
Un API para gobernarlos a todos
Bien, ahora que hemos descubierto que un Functor es cualquier tipo no primitivo que tiene un método map, podemos decir que Array
, Promise (con la peculiaridad que hemos comentado más arriba) y Street son Functors, pero ¿qué pasa con los tipos como string, boolean, number y demás primitivos? ¿también son Functors?. No, porque no tienen un método map y aunque también son Objects al no ser los Functors (o Mappeables) una cosa que este dentro de las specs del lenguaje pues no tiene pinta de que lo vayan a tener.
Pero no todo está perdido ya que, igual que hemos añadido un método map a nuestra Street, ponemos poner a todos estos tipos bajo un mismo API que nos permita hacer lo mismo. Y para poder controlar el "cómo" se aplican funciones sobre esos tipos, necesitamos una capa de control, un wrapper.
Contenedores
Vamos a definir un contenedor que contenga cualquier valor, lo vamos a llamar "Type":
const Type = v => ({
});
Vamos también a añadir un método para poder meter un valor dentro de nuestro contenedor, o lo que es lo mismo, dado un valor, obtener un contenedor nuevo con ese valor dentro:
Type.of = x => Type(x);
Bien, ahora podemos tener nuestros tipos dentro de una caja que hemos llamado Type, por lo que si hacemos Type.of(1)
vamos a tener Type(1)
. Es lo mismo que cuando hacemos Array.of(1)
, que tenemos [1]
.
Sin embargo nuestro contenedor actualmente es un poco estúpido ya que no nos permite hacer nada con el valor que hay dentro. Vamos a convertirlo en un Functor agregándole un método map
para poder aplicar funciones sobre el valor:
const Type = v => ({
map(fn) {
return Type(fn(v));
}
});
Nos aprovechamos de la encapsulación que nos provee un closure para obtener data privacy sobre el valor (v). Así, desde fuera no se puede acceder al valor, obligando a toda persona desarrolladora a pasar por el aro de tener que llamar a map.
Recordad siempre que map toma una función que recibe un tipo A y devuelve un tipo B. Y al aplicarla sobre el valor devuelve el tipo del Functor con un tipo B dentro. Es decir, que si yo tengo la función getStringLength
que toma un string y devuelve un número, al hacer Type.of('abc').map(getStrintLength)
voy a obtener Type(3)
. El String
sería el tipo A, el Number
el tipo B y getStringLength
es una función que va de String
(tipo A) a Number
(tipo B), por lo que el resultado es Functor(B)
. Si no lo has entendido aún, dibújalo.
Esto es algo que queda muy chulo con Type Annotations:
map :: (a -> b) -> F a -> F b
Abstracción sobre aplicación de funciones
Y básicamente todo esto ha sido para poder tener un API común para aplicar funciones sobre valores, ya que ahora podemos refactorizar el ejemplo inicial donde demostrábamos como hacer transformaciones sobre diferentes tipos y estructuras, pero usando nuestros Functors:
const number = Type.of(1); // ahora number es un Functor
const str = Type.of("Extremadura"); // ahora str es un Functor
const address = Street("C/ Greco 2"); // Street tiene un metodo map, por lo que es un Functor
const numbers = [1,2,3]; // Array tiene un metodo map, por lo que es un Functor
const promise = Promise.resolve(1) // Promise tiene un metodo then, que es un map asincrono y podemos decir que es un Functor
// double :: Number -> Number (función que toma un Number y devuelve otro Number)
const double = x => x * 2;
// toUpperCase :: String -> String (toma un String y devuelve otro String)
const toUpperCase = str => str.toUpperCase();
number.map(double) // Type(2)
str.map(toUpperCase) // Type("EXTREMADURA")
address.map(toUpperCase) // Address("C/ GRECO 2")
numbers.map(double) // [2,4,6]
promise.then(double) // Promise.resolve(2)
Y vale, Array
, Street
o Type
no son del mismo tipo, pero los 3 tienen el mismo API para poder aplicar funciones, y los detalles sobre cómo aplicar esas funciones están encapsulados dentro de cada tipo. Y además nosotros tenemos el control total sobre la aplicación de esas funciones, es decir que si por ejemplo si no queremos que las funciones se apliquen siquiera si los valores son undefined
o null
, pues simplemente puedo hacer:
const Type = v => ({
map(fn) {
return Type(v === undefined || v === null ? null : fn(v));
}
});
Si hay valor, aplico la función, si no devuelvo Type(null)
:
Type.of(undefined).map(double) // Type(null)
Al intentar aplicar double
a Type(undefined)
, obtenemos de vuelta Type(null)
.
¿Nos damos cuenta de las posibilidades de esto? Ya da igual los maps que haya debajo, que no se va a ejecutar ninguno:
Type.of(undefined)
.map(double) // Type(null)
.map(double) // Type(null)
.map(double) // Type(null)
// ...
Parece una tontería, pero el simple hecho de no romper el API y siempre obtener un Type independientemente del resultado de aplicar una función, hace nuestro código más limpio y robusto.
Conclusión
Muy bien, pero "¿esto para qué vale?".
Espero que al menos esa pregunta haya quedado contestada a lo largo del artículo, pero si es cierto que esa pregunta pueda denigrar en otra muy parecida: "esto cuando se usa?". Y es que en este mundillo muchas veces no es tan importante el qué o el cómo sino el cuándo.
La idea clave con la que me gustaría que os quedaseis de un Functor, es que al estandarizar la forma en la que aplicamos funciones (a través del método map).
O dicho de otra forma:
“Estamos ganando el control de decidir cuándo y cómo aplicamos una función”.
Nada más por hoy :).