Hoy profundizamos en las promesas, un concepto que, si bien está implementado dentro de Javascript, no le pertenece. Ya que la mayoría de lenguajes en la actualidad lo implementan de una forma u otra en aras de lidiar con la asincronía.
El término "promise" fue introducido en el paper Aspects of Applicative Programming for Parallel Processing por Daniel P. Friedman y David S. Wise como una figura retórica para hacer referencia a la promesa de obtener un resultado en un futuro: “…the var z is initially bound only to a"promise"of this result”.
Pero lo que poca gente sabe es que el concepto de tener un tipo de dato "promesa" fue creado por dos mujeres, Barbara Liskov y Liuba Shrira en el paper: Promises: Linguistic Support for Efficient Asynchronous Procedure Calls in Distributed Systems.
En este documento, no solo exponen el concepto de promesas aplicado a procedimientos asíncronos, si no que definen el mecanismo por el cual estas se canalizan para obtener datos en un futuro. Un proceso que inicialmente denominaron como "call-stream":
“Nuestra solución es fácil e intuitiva: Cuando un stream-call es ejecutado, el que ejecuta recibe una "promesa" por un resultado que llegará más tarde”.
Así mismo, podemos definir una promesa como un objeto que puede ser usado para "reclamar" el resultado cuando esté listo.
Además, definieron los estados en los que se podia encontrar este objeto "promise" en aras de otorgar información al ejecutor (caller) sobre el estado del valor futuro:
El objeto "promise" puede estar en uno de estos estados: Blocked or Ready.
Cuando el objeto es creado, el estado de este es “blocked”'. Una vez que la llamada se completa, el estado pasa automáticamente a “ready”.
Inversión de control
La primera vez que escuché este concepto aplicado a promesas fue en una master class de Kyle Simpson, y ahora creo que es uno de los principales inconvenientes del patrón callback.
Imaginemos este ejemplo: Hemos instalado una librería de validación de formularios necesaria para nuestro proceso de compra en una tienda online. De modo que, el usuario proporciona sus datos bancarios y una vez que validamos que son correctos, procedemos al pago con la pasarela correspondiente.
Esta librería de validación necesita tres elementos para funcionar:
Un FormData con la información del formulario,
Un objeto con las reglas de validación,
Un callback que será llamado cuando el proceso validación acabe satisfactoriamente.
Atendiendo a estos requisitos, acabamos con el siguiente código:
const doPayment = (info) => {
// código encargado de pagar usando la info
};
validate(myFormData, myRules, doPayment);
Todo perfecto, salvo que al pasarle la función doPayment
a validate le estoy otorgando la responsabilidad de llamar a la función de pagar en bandeja de plata.
Si intencionadamente o mediante algún bug ocurre que la librería de validación invoca a la función doPayment un millón de veces, o le pasa los argumentos equivocados, quizá nos encontremos con serios problemas.
Y es que está inversión de control es muy peligrosa porque estamos delegando en un ente externo el cuándo y el cómo ejecutar una función que es crítica en nuestro programa.
Esto es lo que ocurre intencionadamente con jQuery por ejemplo, ya que cuando registramos un listener sobre un elemento con la función on, la librería internamente le cambiará el contexto (el valor de this) por el nodo que originó el evento:
// Este código pretende ser similar a como funciona la función on de jQuery
function on(el, eventType, callback) {
el.addEventListener(eventType, callback.bind(el));
};
Como es la función on la que tiene la potestad de usar el callback como guste, decide que va a "bindear" un contexto diferente a la que la función llevaba.
Contrato de confianza
Y es que las promesas no solo plantean un API más amigable, si no que establecen un contrato invariable entre la que crea la promesa y la que la usa. De tal forma que es el propio runtime del lenguaje el que hace de intermediario para evitar violaciones en ese contrato.
Así, las reglas que el lenguaje define sobre las promesas son las siguientes:
Solo se resuelven una vez.
Solo se pueden resolver satisfactoria o erróneamente.
Conservan la información con la que fueron resueltas.
Las excepciones se propagan como errores.
Una vez la promesa es resuelta, esta es inmutable.
Y es que estás reglas son las que permiten transacciones asíncronas basadas en la confianza sin delegar a ningún ente externo responsabilidades sobre la resolución o la cancelación de los procesos.
API de Promesas en Javascript
El API de las promesas en Javascript están implementadas como no podía ser de otra manera, siguiendo las especificaciones introducidas por Liskov y Shira. De tal forma que cuando creamos una promesa, el estado en la que se encuentra inicialmente es "pending" (similar a blocked) y cuando la llamada se resuelve, el estado cambia a "rejected" o "fulfilled". Dependiendo de si la ejecución fue satisfactoria o no:
function createPromise () {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('my result');
}, 3000)
});
};
const promise = createPromise(); // promise está en el estado 'pending'
// más tarde
promise.then(value => console.log(value)) // 'my result'.
// promise está en el estado 'fulfilled'
En el código anterior, la función createPromise crea una promesa que será resuelta al cabo de tres segundos (en el mejor de los casos) con el valor “my result”, por lo que justo en el momento de crearla el estado de esta será pending. Más tarde, mediante el método then
podremos acceder al valor de la promesa resuelta cuando está pase al estado fulfilled.
Nuestro objeto promise es como si fuera un “vale” o cupón, es decir un trozo de papel que no es más que la promesa de que podremos intercambiarlo por algo tangible en un futuro. El mecanismo por el que realizamos este intercambio, se produce al invocar la función then pasándole una función que será llamada con el valor cuando esté disponible – esto es, cuando la promesa alcance el estado fulfilled
, que representa los estados resolved o rejected –.
Representando estados de error
No obstante, todos sabemos que cuando se trata de procesos asíncronos impuros ajenos a nuestro control – como puede ser una petición a un servicio – los fallos están a la orden del día, por lo que necesitamos que las "promesas" puedan de alguna forma representar ese estado de error y si fuera posible además poder tener obtener la razón.
Es por eso que las promesas internamente tienen definido un mecanismo para propagar las excepciones como errores, en caso de producirse:
function createFailPromise () {
return new Promies((resolve, reject) => {
reject('something went wrong');
});
};
createFailPromise(); // promise state = 'pending'
// ... más tarde
// Uncaught (in promise) something went wrong
// promise state = 'rejected'
El problema es que una cosa es que haya un mecanismo por el cual las excepciones se propaguen – es decir que cuando ocurren se van a ir levantando hasta que alguien las controle – y otra cosa distinta es como podemos controlarla.
El hecho de que tengamos un objeto promise, no significa que seamos propietarios de la llamada que está ocurriendo dentro, si no que como se indicaba en el paper citado arriba, la promesa es simplemente la referencia al valor futuro. Por tanto, no nos serviría simplemente envolver a nuestra promesa en un bloque try/catch, puesto que el momento de crear la promesa y su resolución (errónea o satisfactoria) ocurre en sitios distintos, aunque parezca que toda la movida ocurre ante nuestros ojos.
Por ende, igual que la implementación de promesas nos ofrece un mecanismo para rescatar el valor de una promesa completada inmediatamente después de la creación de la promesa, contamos con una forma de poder acceder al error como si este ocurriese en el mismo momento en que el proceso es creado:
function createFailPromise () {
return new Promies((resolve, reject) => {
reject('something went wrong');
});
};
const promise = createFailPromise(); // promise state = 'pending'
promise.catch(console.log) // 'something went wrong'.
// promise state = 'rejected'
Creamos la promesa en una línea y controlamos la potencial excepción justo debajo con la función catch. Si no hay excepción, la función del catch nunca se ejecutará.
Haciendo que lo asíncrono parezca síncrono
No nos engañemos, la programación asíncrona no mola. Es difícil de entender y no es como el ser humano piensa, por lo que las promesas, las corrutinas — async/await — y demás mecanismos son un esfuerzo titánico por hacer que nuestro código parezca cada vez más síncrono.
El ser humano solo puede hacer una cosa a la vez, aunque cuando veamos a una persona programando, escuchando música y atendiendo a un stream en twitch, parezca que realmente está prestando atención a las tres, lo que su cerebro realmente está haciendo es un esfuerzo extraordinario para no sentirse atraído por ninguna de las dos cosas restantes mientras presta atención a una.
Es cierto que muchas personas tienen una habilidad innata para cambiar el foco entre varias cosas a la vez, pero en un instante de tiempo tu atención solo puede estar en un lugar.
Cuando hablamos de programación asíncrona tenemos el mismo problema. Nuestro cerebro no puede tener constancia de todos los procesos que el tiempo va generando en el programa a la vez sin volverse un poco loco. Por lo que urgen sistemas que traduzcan lo que una maquina puede hacer muy bien, a la forma en la que nos sentimos cómodos.
Veamos el siguiente ejemplo usando el patrón callback donde cada función – aunque esté definida en el mismo lugar – ocurre en momentos del programa distintos lo que provoca que nuestra mente tenga que pensar en términos de asincronía y multitarea, llevándonos muchas veces a error:
function process (cb) {
// más tarde que el primero (segundo)
setTimeout(cb, 1000);
}
function manage () {
// más tarde que handle (cuarto)
}
function handle (cb) {
// más tarde de process (tercero)
setTimeout(cb, 1000);
}
// primero
process(function () {
handle(manage));
});
El código anterior no solo es difícil de entender si no por tanto de mantener.
El continente Promise
Trata ahora de imaginarte las promesas, como si de una caja se tratase. De tal forma que eventualmente el runtime de Javascript va a colocar dentro de esa caja tu valor. Ahora, el mecanismo por el cual dispones de abrir esa caja, se llama then y este recibe una función que aplicará sobre el valor futuro cuando este sea metido dentro de tu caja:
const a = Promise.resolve(1) // Con Promise.resolve podemos directamente crear una promesa en el estado fulfilled con el valor que le pasemos. O dicho de otra forma, con Promise.resolve(value) ponemos el value dentro de una caja
a.then(x => x * 2); // Promise(2)
Como te habrás fijado, el resultado después de aplicar una función con then, es otra promesa. Esto es porque then
no solo aplica la función que le pases al valor que tiene dentro, sino que además envuelve ese nuevo valor – resultado de aplicarle la función – dentro de una promesa con estado fulfilled. De tal forma que podríamos seguir encadenando todas las transformaciones que queramos sobre el valor que hay dentro:
const a = Promise.resolve(1);
a.then(x => x * 2)
.then(x => x + 1)
.then(x => x * 2); // Promise(6)
Si recuerdas el artículo de Functors, decíamos que cuando teníamos un valor y lo colocábamos dentro de una caja podíamos acceder al valor usando una función llamada map. En este caso las promesas no tienen un map pero si un then que se comporta exactamente igual.
Y es que then es como si fuese un map asíncrono. Es más, catch es también un map, pero solo se ejecuta cuando dentro de la promesa hay una excepción en lugar de un valor.
Además, si la función que le pasamos a then genera una excepción, está excepción será envuelta en una promesa con estado “rejected”.
Pero, ¿qué ocurre si la función que le pasamos a then también devuelve una Promesa?, ¿acabamos acaso con Promise(Promise)? Pues no, porque aparte de un map, la función then
es también un flatMap, por lo que, si la función que recibe ya devuelve una Promise, es suficiente inteligente como para "aplanar" todas las promesas en una:
const a = Promise.resolve(2);
a.then(x => Promise.resolve(x * 2)) // Promise(4)
Por tanto, las promesas son Functors. Y además son Mónadas. Te doy la promesa de que en el futuro habrá una artículo de Mónadas.
Eliminando la variable Tiempo
Las promesas nos permiten poder razonar sobre valores o transformarlos sin que estos estén realmente presentes. De igual forma que yo puedo pensar que voy a hacer con un coche antes de comprármelo, podemos definir todo un programa en base a la promesa de un valor futuro, esté alguna vez disponible o no.
Dicho en otras palabras, estamos eliminando el factor tiempo de la asincronía, volviendo a algo más síncrono y por tanto reduciendo la complejidad:
const getUsers = res => res.json();
const getNames = R.pluck('name');
const usersPromise = fetch('/users'); // Devuelve una respuesta con un array de users
const namesPromise = usersPromise
.then(getusers)
.then(getNames)
namesPromise
.then(console.log) // ['pedro', 'laura']
.catch(console.error)
Si os fijáis, hemos podido razonar y crear nuestro programa alrededor del concepto de tener un array de usuarios y posteriormente un array de nombres, sin tenerlos realmente. Solo cuando las promesas se resuelvan o fallen, nuestro programa se ejecutará.
Vamos a ver ahora como quedaría esto si tuviésemos que lidiar con valores directamente:
const getObjectFromJSON = (json, cb) => {
cb(JSON.parse(json))
};
getUsers(function (usersJSON) {
getObjectFromJSON(function (users) {
const names = getNames(users);
console.log(names);
});
});
Podemos ver que al operar directamente sobre los valores y no sobre la promesa de obtenerlos, tenemos que recuperar la variable tiempo en nuestro código para poder especificar las relaciones. Y es que no podemos hacer getNames hasta que no tengamos un array de usuarios, y no podemos obtener el array hasta que no hayamos “parseado” el JSON que nos devuelve el servicio.
Además de eliminar la variable del tiempo, necesitamos una forma de proporcionar semántica sobre la relación que existe entre un proceso y el siguiente.
Haciendo las relaciones entre procesos explícitas
El nombre de then para el método que nos permite abrir las promesas para acceder a su valor, no es casual. Sino que fue así concebido para poder generar relaciones entre los procesos que se ejecutan sobre una promesa, introduciendo de nuevo el concepto de "tiempo" pero sin sus inconvenientes:
fetch('/users')
.then(getObjectFromJSON)
.then(getNames)
.then(toUpperCase)
.then(console.log) // ['MIGUEL', 'LAURA']
En este código, puedes establecer claramente las relaciones entre todas las funciones que se está ejecutando sobre la promesa que devuelve fetch:
Transformamos la respuesta un objeto (una lista de usuarios),
Después, obtenemos todos los nombres,
Después, los convertimos a mayúsculas,
Finalmente, los mostramos por consola.
Estamos, por tanto, haciendo todas las relaciones entre las funciones explicitas.
Conclusión
Hemos dado un buen repaso al tema de las promesas, desde una perspectiva diferente: en lugar de ver su API, que sinceramente para eso está la documentación. Hemos visto de dónde vienen, porque se definieron y que problemas pretenden solucionar.
Las promesas son una excelente solución a los problemas de control invertido que presentaban los callbacks, ya que en esencia se centran en establecer un contrato de confianza. No obstante, hay que tener en cuenta que al final lo que le pasamos a then no deja de ser otro callback, por lo que podemos encontrarnos con muchos de los problemas que también se le atribuían a este patrón, como los callbacks hell.
Espero que, a partir de ahora, cuando te pregunten que son las promesas tengas el contexto suficiente como para poder explicarlas con tus propias palabras, en lugar de aprenderte de memoria la definición prefabricada que puedes encontrar en cualquier artículo que hable de este tema.
Nada más por hoy :).