Arquitecturas Concurrentes, Episodio 5: Sólo promesas

flbulgarelli
Arquitecturas Concurrentes
8 min readSep 22, 2015

En capítulos anteriores vimos una posible forma de estructurar nuestros programas, utilizando CPS. Esta técnica, a diferencia de call-and-return y memoria compartida, nos permite implementar, de forma fácil:

  • computaciones con un único resultado
  • computaciones que pueden fallar
  • computaciones no determinísticas
  • excepciones
  • asincronismo

Bueno, quizás no tan fácilmente: vimos que si no tenemos cuidado y no delegamos apropiadamente, es muy posible que caigamos en el callback-hell: continuaciones anidadas dentro de continuaciones. Para ser justos, esto no es un problema de CPS propiamente dicho sino de la subutilización de una de las herramientas más poderosas del paradigma funcional: el orden superior.

De todas formas, lo admitimos, para el programador inexperto en estos territorios, razonar sobre abstracciones que combinan funciones, como compose’s o pipeline’s no es simple: las funciones no son valores obvios.

Quizás por esto surge otra forma de estructurar programas concurrentes: las promesas (futures o promises, en inglés). Se trata de una técnica que de nueva no tiene nada (data de fines de los ‘70), pero que se ha popularizado y gracias a implementaciones en lenguajes como Scala o JavaScript.

Veamos qué tienen para ofrecernos.

Primera Intuición: Contenedores

El código completo de este episodio se encuentra acá

Para empezar, volvamos a nuestro clásico ejemplo: la función successor (o simplemente succ):

Como ya sabemos, succ expresará su resultado a través de su retorno. Y como era muy fácil, ¡lo vamos a complejizar! Introduzcamos entonces nuestra primera intuición sobre las promesas:

Nota: las intuiciones que iremos mostrando son sólo eso: aproximaciones sucesivas a la idea, de forma similar a cuando hacemos TDD. No pretenden en ningún momento representar la implementación real.

¿Qué cambió? Que si bien el resultado se sigue representando a través del retorno, la Promise se presenta como una indirección: para obtener el valor de resultado, tenemos que explícitamente pedírselo a la promesa (en este caso, mediante el mensaje value()).

Una implementación directa de esta idea es la siguiente:

Esto no parece ser muy útil. Y de hecho, en este momento, ¡no lo es! Pero nos sirve para ver a las promesas como contenedores de valores. Vemos que de esta forma, implementar computaciones que devuelven un único resultado de forma sincrónica, es trivial.

Segunda intuición: Contenedores vacíos

¿Toda promesa tiene siempre un valor? No. Otra forma de usar una promesa es la siguiente:

Es decir, cuando trabajamos con promesas, en realidad podemos crear tanto promesas con valor como sin valor. Por lo que antes de usar una promesa, en realidad deberíamos preguntarle si tiene valor:

Claramente nuestra implementación anterior no soporta esto, así que la extenderemos:

Como se observa, introduciendo una EmptyPromise y refactorizando convenientemente la forma en que las construimos, podemos lograr el comportamiento deseado.

Nota: prestar atención a esta implementación, en próximos episodios la retomaremos desde Haskell y el tipo Maybe

¿La moraleja?: parecería que las promesas nos permiten representar computaciones que fallan. Pero no nos crean del todo, vamos rápido a nuestra siguiente intuición.

Tercera Intuición: Contenedores ”dobles”

A la implementación anterior podemos darle una vuelta de tuerca y fácilmente cambiarla para que en el caso de error, podamos proveer un valor, de la siguiente forma:

Es decir, las promesas, en lugar de tener un valor o no tenerlo ( fallar), mas bien tendrán siempre un valor de éxito o un valor de fracaso. Cuando una promesa tiene valor de éxito, se dice que está resuelta, y si tiene un valor de fracaso, está rechazada. Por eso, renombremos hasValue/value a isResolved/resolvedValue:

Moraleja: acabamos de implementar excepciones. Quizás un poco precarias aún, porque tenemos que recurrir a un if al no tener ninguna forma interesante de propagar el error. Pero esperen, ¡ya lo mejoraremos!

Cuarta intuición: deferred

Hasta ahora, con las Promises pudimos implementar resultados únicos y excepciones, de forma sincrónica: al terminar de ejecutar la computación que devuelve la promise, la misma ya se encontraba o bien resuelta o bien rechazada.

Sin embargo, las cosas se ponen más interesantes cuando empezamos a verlos como contenedores cuyo resultado aún no está determinado, pero lo estará en el futuro. Para eso, introduciremos un nuevo elemento: deferred, que permite crear una promise asincrónica.

Al separar claramente la instanciación de la promesa de su resolución o rechazo, estamos en definitiva difiriendo en el tiempo el momento de la generación del resultado.

Con lo cual, la preguntas isResolved/isRejected cobra nuevo sentido: no solamente necesitamos estos mensajes para saber si contiene un valor de éxito o de fracaso, sino…¡saber si siquiera ya tiene el valor!

Moraleja: una promesa puede estar resuelta, rechazada… ¡o ni siquiera estar terminada! Con lo cual, vamos a tener que preguntar esto varias veces (quizás dentro de un while o periódicamente), hasta que el flujo del programa “entre” en alguna de las dos primeras ramas del if

Otro ángulo: Computaciones

Ahora que ya casi sabemos todo lo que necesitamos sobre las promesas, intentemos verlas desde otro ángulo: las promesas son computaciones.

Esta dualidad contenedor — computación será común y se continuará dando en próximos episodios.

¿Que significa esto? Que en lugar de pensarlas como contenedores que eventualmente se llenan con un resultado o con un error, a veces puede ser más conveniente pensarlas de una forma totalmente diferente: la cosificación de una computación CPS. Veamos una implementación trivial de esta idea:

Es decir, en esta implementación, la promise es simplemente un wrapper sobre una computación CPS, que toma dos continuaciones: una de éxito y una de error. Con esto podemos definir una función inverse de la siguiente forma:

Y usarla así:

Y lo interesante es que pudimos lograr esto sin recurrir a deferreds, ni tener que preguntar si la computación terminó, ni si el resultado fue de éxito ni de de fracaso.

OK, es cierto que esta implementación trivial tiene varios problemas: ejecuta dos veces la computación CPS (lo cual es un problema grave si tenía efecto), y no la ejecuta hasta que no se use onResolved/onRejected. Las promises reales son ciertamente más inteligentes.

¿Saben que es lo mejor? Que no sólo ambas visiones de contenedor — computación son válidas… sino que ¡podemos trabajar con promesas de ambas formas!

¡A combinar se ha dicho!

¿Ya llegamos a la india?

De la misma forma que preguntar una y otra vez si ya llegamos a la india, es molesto para el pobre Apu, la estrategia de preguntarle a la promesa si ya está terminada, y luego analizar si fue rechazada o no, es engorrosa y un desperdicio de recursos.

Y si bien también vimos que a una promesa podemos decirle qué hacer cuando se resuelva o sea rechazada, hacer cosas complejas dentro del callback de onResolved/onRejected tampoco es un gran negocio; al fin y al cabo estamos trabajando nuevamente con continuaciones.

La buena noticia es que las promises, o mejor dicho, las operaciones contra ellas, pueden ser combinadas fácilmente. Probablemente la más fácil de estas transformaciones sea map:

Esto lo que nos devuelve es una nueva promise, que calcula el siguiente de la inversa de 4.

Este map es análogo al map de las listas. De la misma forma que

[1, 3, 4].map(function(x) { return x + 1 })

nos devuelve una nueva lista con los resultados de transformar los elementos de la lista original (en este caso: 2, 4, 5),

Promise.resolve(9).map(function(x) { return x + 1 })

nos devolverá una nueva promise que contiene/calcula con la transformación aplicada al valor de la promise original (en este caso, será una promise que se resuelve con valor 10).

Esto es posible dado que las promises pueden ser pensadas como contenedores. Y sobre cualquier contenedor se puede definir una operación map, que aplica una transformación al valor contenido y genera un nuevo contenedor con este resultado. Otro nombre técnico para contenedor es functor, el cual retomaremos en próximos; veremos que todo lo que es mapeable es un functor, y functor es aquello que es mapeable.

Sobre esta promise resultante podemos aplicar más maps, pedirle el valor de resuelto, enviarle el mensaje onRejected, etc.

Ah, ¿y si la promise aún no estaba resuelta? No hay problema: la transformación será aplicada cuando el resultado esté listo.

¿Y si la promise está o será rechazada? Simple: el map no tiene ningún efecto.

¿Casi una lista?

Ahora supongamos que tenemos dos computaciones que devuelven promesas: inverse y succ. Y con ellas queremos obtener el siguiente de la inversa de 19. ¿Que pasa si hacemos lo siguiente?

inverse(19).map(succ)

¿Obtendremos lo deseado? No: nos devolverá una promesa, que resuelva a otra promesa, que en tanto resolverá a 1/19 + 1. Qué problema, ¿no?

Por suerte las promises nos brindan muchas de las operaciones que podríamos aplicar sobre una lista, como por ejemplo flatMap:

[2, 3, 1].flatMap(function(x){ return [x, -x]})

que es similar al map, pero la transformación pasada por parámetro debe devolver una lista. Y el resultado final es una lista nueva, con la suma con la concatenación de todos los resultados (en este caso: 2, -2, 3, -3, 1, -1).

De forma análoga, podemos aplicarla flatMap sobre una promesa:

inverse(19).flatMap(succ)

En este caso, la transformación (succ) devolverá ya no un valor, sino una promesa. Y así obtendremos, finalmente, una promesa que se resuelva a 1/19 + 1.

No es casualidad que las listas y promesas compartan una interfaz común. Ambas son mónadas, como veremos más adelante. Y esta interfaz monádica es la que provee el mensaje flatMap

Promises A+

Y las promesas llegaron a la Web, de la mano de JavaScript. Y como no podía ser de otra forma en esta comunidad (en la que todos los días surge un nuevo framework o biblioteca), tenemos varias bibliotecas de Promises. Q, bluebird, incluso JQuery tiene su propia implementación. Y ECMA6 las incluye como parte de su biblioteca estándar. La mayoría de estas cumplen una misma interfaz conocida como A+

La diferencia fundamental entre éstas y aquellas que presentamos en este artículo, radica en dos aspectos:

  • nomenclatura: algunos nombres de mensajes cambian. Por ejemplo, isResolved se llama isFulfilled
  • fusión de operaciones: las promesas de JS exponen un mensaje then, que es al mismo tiempo un map y un flatMap: se comporta de una forma u otra según el tipo de lo que se devuelva en la transformación.

Les dejamos acá una implementación de nuestro modelos de cuentas bancarias presentado en capítulos anteriores, esta vez utilizando las Promises que provee la biblioteca Bluebird

Recapitulando

Vimos bastantes cosas en este episodio. Hagamos un rápido racconto:

  • A las promesas podemos verlas tanto como contenedores de valores, como computaciones que generan valores
  • Las promesas permiten implementar resultados únicos, excepciones y asincronismo.
  • Las promesas NO permiten implementar no determinismo (múltiples resultados): de haber un resultado, habrá uno sólo, ya sea de rechazo o de resolución.
  • Las promesas NO permiten implementar falla: si bien en nuestras primeras intuiciones esta parecía una idea razonable, cuando introdujimos el asincronismo, ya no calza bien: ¿si una computación no nos produjo ningún valor, es porque falló o porque aún no se completó?
  • Las promesas son un modelo menos poderoso que las continuaciones, pero son más fáciles de operar, y también es más facil combinar funciones que retornan promesas.
  • Las promesas de JavaScript fusionan la semántica del map y flatMap, en una sola operación: then

Sin embargo, la historia no termina acá. ¿Qué pasa si quiero tener computaciones diferidas en el tiempo, pero que son no determinísticas? Para eso contamos con otra abstracción, de la mano de lo que se conoce como Programación Reactiva: el Stream Observable. De ésto hablaremos en próximos episodios.

Para leer más

--

--