En este punto del libro, ya tienes todos los conceptos en bruto para los fundamentos de la PF a la que yo llamo "Programación Funcionalmente-Ligera". En este capítulo, vamos a aplicar estos conceptos a un contexto diferente, pero no presentaremos ideas particularmente nuevas.
Hasta ahora, casi todo lo que hemos hecho es sincrónico, lo que significa que llamamos funciones con entradas inmediatas e inmediatamente recuperamos los valores de salida. Se puede hacer mucho trabajo de esta manera, pero no es suficiente para la totalidad de una aplicación de JS moderna. Para estar verdaderamente preparados para la PF en el mundo real de JS, necesitamos entender PF asincronica.
Nuestro objetivo en este capítulo es expandir nuestro pensamiento sobre la gestión de valores con PF, para extender dichas operaciones a lo largo del tiempo. Veremos que los Observables (¡y las Promesas!) son una gran manera de hacer exactamente eso.
El estado más complicado en toda tu aplicación es el tiempo. Es decir, es mucho más fácil administrar el estado cuando la transición de un estado a otro es inmediata y afirmativamente bajo Tu control. Cuando el estado de tu aplicación cambia implícitamente en respuesta a eventos distribuidos en el tiempo, la administración se vuelve exponencialmente más difícil.
Cada parte de cómo hemos presentado a la PF en este texto ha tenido que ver con hacer que el código sea más fácil de leer al hacerlo más confiable y predecible. Cuando introduces la asincronía en tu programa, esos esfuerzos reciben un gran impacto.
Pero seamos más explícitos: no es el mero hecho de que algunas operaciones no terminen sincrónicamente lo que es preocupante; desencadenar un comportamiento asincrónico es fácil. Es la coordinación de las respuestas a estas acciones, cada una de las cuales tiene el potencial de cambiar el estado de tu aplicación, lo que requiere un tanto de esfuerzo adicional.
Entonces, ¿es mejor para ti que el autor haga ese esfuerzo, o deberías dejar que el lector de tu código descubra cuál será el estado del programa si A termina antes de B, o viceversa? Esa es una pregunta retórica, pero con una respuesta bastante concreta desde mi punto de vista: para tener alguna esperanza de hacer que un código tan complejo sea más legible, el autor tiene que preocuparse mucho más de lo que normalmente lo haría.
Uno de los resultados más importantes de los patrones de programación asíncronos es simplificar la gestión del cambio de estado al abstraer el tiempo de nuestra esfera de interés. Para ilustrar esto, primero veamos un escenario donde existe una condición de carrera (también conocida como complejidad de tiempo) y esta deba ser gestionada manualmente:
var IdCliente = 42;
var cliente;
buscarCliente( IdCliente, function enCliente(recordCliente){
var ordenes = cliente ? cliente.ordenes : null;
cliente = recordCliente;
if (ordenes) {
cliente.ordenes = ordenes;
}
} );
buscarOrdenes( IdCliente, function enOrdenes(ordenesCliente){
if (!cliente) {
cliente = {};
}
cliente.ordenes = ordenesCliente;
} );
Las devoluciones de llamadas enCliente(..)
y enOrdenes(..)
están en una condición de carrera binaria. Suponiendo que ambas se ejecuten, es posible que cualquiera de las dos se ejecute primero, y es imposible predecir qué sucederá.
Si pudiéramos insertar la llamada a buscarOrdenes(..)
dentro de enCliente(..)
, nos aseguraríamos de que enOrdenes(..)
se ejecutara después de enCliente(..)
. Pero no podemos hacer eso, porque necesitamos que las dos búsquedas ocurran al mismo tiempo.
Por lo tanto, para normalizar esta complejidad de estado basada en el tiempo, utilizamos un emparejamiento de comprobaciones de declaración if
en las respectivas devoluciones de llamada, junto con un cliente
de variable externa léxica externa. Cuando se ejecuta cada devolución de llamada, comprueba el estado de cliente
y, por lo tanto, determina su propio orden relativo; si cliente
no está configurado para una devolución de llamada, es el primero en ejecutarse, de lo contrario es el segundo.
Este código funciona, pero está lejos de ser ideal en términos de legibilidad. La complejidad del tiempo hace que este código sea más difícil de leer. Usemos en cambio una promesa de JS para factorizar el tiempo fuera de la operación:
var IdCliente = 42;
var promesaCliente = buscarCliente( IdCliente );
var promesaOrdenes = buscarOrdenes( IdCliente );
promesaCliente.then( function enCliente(cliente){
promesaOrdenes.then( function enOrdenes(ordenes){
cliente.ordenes = ordenes;
} );
} );
La devolución de llamada enOrdenes(..)
ahora está dentro de la devolución de llamada enCliente(..)
, por lo que su orden relativa está garantizada. La concurrencia de las búsquedas se lleva a cabo haciendo que las llamadas buscarCliente(..)
y buscarOrdenes(..)
se separen antes de especificar el manejo de la respuesta then(..)
.
Puede que no sea obvio, pero de otro modo existiría una condición de carrera inherentemente en este fragmento, de no ser por la forma en que se define el comportamiento de las promesas. Si la búsqueda de las ordenes
termina antes de que promesaOrdenes.then(..)
se llame para proporcionar una devolución de llamada enOrdenes(..)
, algo necesita ser lo suficientemente inteligente como para mantener esa lista ordenes
hasta que pueda llamarse enOrdenes(..)
. De hecho, la misma preocupación podría aplicarse a que cliente
esté presente antes de que enCliente(..)
se especifique para recibirlo.
Ese algo es el mismo tipo de lógica de complejidad de tiempo que discutimos con el fragmento anterior. Pero no tenemos que preocuparnos por esa complejidad, ni en la redacción de este código ni, lo que es más importante, en su lectura, porque las promesas se ocupan de esa normalización del tiempo por nosotros.
Una Promesa representa un único valor (futuro) de una manera independiente al tiempo. Además, extraer el valor de una promesa es la forma asíncrona de la asignación síncrona (mediante =
) de un valor inmediato. En otras palabras, una promesa extiende una operación de asignación =
a lo largo del tiempo, pero de una manera confiable (independiente del tiempo).
Ahora exploraremos de qué manera podemos distribuir de manera similar las varias operaciones de PF sincrónicas de este libro de una forma asincrónica a lo largo del tiempo.
Ansioso y perezoso en el ámbito de la informática no son cumplidos o insultos, sino más bien formas de describir si una operación finalizará de inmediato o progresará con el tiempo.
Las operaciones de PF que hemos visto en este texto se pueden caracterizar como ansiosas porque operan sincrónicamente (en este momento) en un valor inmediato discreto o lista/estructura de valores.
Recuerda:
var a = [1,2,3]
var b = a.map( v => v * 2 );
b; // [2,4,6]
Este mapeo de a
a b
es ansioso porque opera en todos los valores del array a
en ese momento, y produce un nuevo array b
. Si luego modificas a
, por ejemplo, agregando un nuevo valor al final, nada cambiará con respecto al contenido de b
. Eso es PF ansiosa.
Pero, ¿cómo se vería tener una operación de PF perezosa? Considera algo como esto:
var a = [];
var b = mapPerezoso( a, v => v * 2 );
a.push( 1 );
a[0]; // 1
b[0]; // 2
a.push( 2 );
a[1]; // 2
b[1]; // 4
El mapPerezoso(..)
que hemos imaginado aquí esencialmente "escucha" al array a
, y cada vez que se agrega un nuevo valor al final de la misma (con push(..)
), se ejecuta la función de mapeo v => v * 2
y el valor transformado es empujado al array b
.
Nota: La implementación de mapPerezoso(..)
no se ha mostrado porque es una ilustración ficticia, no una operación real. Para lograr este tipo de sincronización de operación perezosa entre a
y b
, necesitaremos algo más inteligente que los arrays básicos.
Considera los beneficios de poder emparejar un a
y b
juntos, donde en cualquier momento (¡incluso asincrónicamente!) puedes poner un valor en a
, este se transforma y luego se proyecta en b
. Ese es el mismo tipo de potencia de PF declarativa de una operación map(..)
, pero ahora se puede extender con el tiempo; no es necesario que conozcas todos los valores de a
en este momento para establecer el mapeo de a
a b
.
Para comprender cómo podríamos crear y usar un mapeo perezoso entre dos conjuntos de valores, necesitamos abstraer un poco nuestra idea de lista (array). Imaginemos un tipo de arreglo más inteligente, no uno que simplemente contenga valores, sino uno que reciba y responda perezosamente (también conocido como "reaccionar") a valores. Considera:
var a = new ArrayPerezoso();
var b = a.map( function doble(valor){
return valor * 2;
} );
setInterval( function cadaSegundo(){
a.push( Math.random() );
}, 1000 );
Hasta ahora, este fragmento no se ve diferente de un array normal. Lo único inusual es que estamos acostumbrados al map(..)
ejecutándose ansiosamente y produciendo inmediatamente un array b
con todos los valores mapeados actualmente de a
. El temporizador que introduce valores aleatorios en a
es extraño, ya que todos esos valores vienen después de la llamada map(..)
.
Pero este ArrayPerezoso
ficticio es diferente; asume que los valores vendrán uno a la vez, a lo largo del tiempo; solo empuja valores con push(..)
en el momento que lo desees. b
será un mapeo perezoso de todos los valores que eventualmente terminen en a
.
Además, no es necesario que guardemos los valores en a
o b
una vez que se han manejado; este tipo especial de array solo mantiene un valor por lo que sea necesario. Por lo tanto, estos arrays no crecen estrictamente en el uso de memoria a lo largo del tiempo, una característica importante de las estructuras y operaciones de datos perezosas. De hecho, es menos como un array y más como un buffer.
Un array normal está ansioso por tener todos sus valores en este momento. Un "array diferido" es un array donde los valores llegarán con el tiempo.
Como no sabremos necesariamente cuándo ha llegado un nuevo valor en a
, otra cosa clave que necesitamos es poder escuchar a b
para que se nos notifique cuando haya nuevos valores disponibles. Podríamos imaginar a un oyente como este:
b.escuchar( function enValor(valor){
console.log( valor );
} );
b
es reactivo porque está configurado para reaccionar a los valores a medida que estos entran en a
. Hay una operación de PF map(..)
que describe cómo cada valor se transfiere desde el origen a
al objetivo b
. Cada operación de mapeo discreto es exactamente cómo modelamos las operaciones de valor único con PF sincrónica normal, pero aquí estamos ampliando el origen de los valores a lo largo del tiempo.
Nota: El término más comúnmente aplicado a estos conceptos es la Programación Reactiva Funcional (PRF). Estoy evitando deliberadamente ese término porque hay cierto debate sobre si PF + ReactivA realmente constituye PRF. No vamos a profundizar en todas las implicaciones de la PRF aquí, así que seguiré llamándola PF reactiva. Alternativamente, podrías llamarle PF-eventuada si eso te parece menos confuso.
Podemos pensar que a
produce valores y b
que los consume. Por lo tanto, para la legibilidad, reorganicemos este fragmento para separar las tareas en roles de productor y consumidor:
// productor:
var a = new ArrayPerezoso();
setInterval( function cadaSegundo(){
a.push( Math.random() );
}, 1000 );
// **************************
// consumidor:
var b = a.map( function doble(valor){
return valor * 2;
} );
b.escuchar( function enValor(valor){
console.log( valor );
} );
a
es el productor, que actúa esencialmente como una corriente de valores. Podemos pensar en cada valor que llega en a
como un evento. La operación map(..)
luego desencadena un evento correspondiente en b
, que usa escuchar(..)
para que podamos consumir el nuevo valor.
La razón por la que separamos las tareas productor y consumidor es para que diferentes partes de nuestra aplicación puedan ser responsables de cada tarea. Esta organización de código puede mejorar drásticamente la legibilidad y el mantenimiento del código.
Estamos siendo muy cuidadosos acerca de cómo introducimos el tiempo en la discusión. Específicamente, así como las promesas abstraen al tiempo fuera de nuestra preocupación por una operación única asincrónica, la PF reactiva abstrae (separa) el tiempo de una serie de valores/operaciones.
Desde la perspectiva de a
(el productor), el único problema de tiempo evidente es nuestro ciclo setInterval(..)
manual. Pero eso es solo para propósitos de demostración.
Imagine que a
podría estar conectado a otra fuente de eventos, como los clics de un mouse o a las teclas del usuario, los mensajes de un servidor en la web, etc. En ese escenario, a
en realidad no tiene que preocuparse por el tiempo. Es simplemente un conducto de valores independiente del tiempo, siempre que estén listos.
Desde la perspectiva de b
(el consumidor), no sabemos ni nos importa cuándo/de dónde vienen los valores en a
. Como cuestión de hecho, todos los valores podrían estar ya presentes. Lo único que nos importa es que queremos esos valores, siempre que estén listos. Nuevamente, este es un modelado independiente del tiempo (también conocido como perezoso) de la operación de transformación map(..)
.
La relación del tiempo entre a
y b
es declarativa (¡e implícita!), no imperativa (o explícita).
El valor de organizar tales operaciones en el tiempo de esta manera puede no sentirse particularmente efectivo todavía. Comparemos cómo este mismo tipo de funcionalidad podría haberse expresado imperativamente:
// productor:
var a = {
enValor(valor){
b.enValor( valor );
}
};
setInterval( function cadaSegundo(){
a.enValor( Math.random() );
}, 1000 );
// **************************
// consumidor:
var b = {
map(valor){
return valor * 2;
},
enValor(valor){
valor = this.map( valor );
console.log( valor );
}
};
Puede parecer bastante sutil, pero hay una diferencia importante entre esta versión más imperativa del código y la versión más declarativa anterior, aparte simplemente del b.enValor(..)
que necesita llamar a this.map(.. )
en sí mismo. En el fragmento anterior, b
tira de a
, pero en el último fragmento, a
empuja a b
. En otras palabras, compara b = a.map(..)
con b.enValor(v)
.
En este último fragmento imperativo, no está claro (en términos de legibilidad) desde la perspectiva del consumidor de donde provienen los valores valor
. Además, la codificación dura e imperativa de b.enValor(..)
en el medio de la lógica del productor a
es una violación de la separación de tareas. Eso puede hacer que sea más difícil razonar sobre el productor y el consumidor de forma independiente.
Por el contrario, en el fragmento anterior, b = a.map(..)
declara que los valores de b
provienen de a
, y tratan a a
como un origen de datos de secuencia de eventos abstractos que utilizamos por el cual no tenemos que preocuparnos en ese momento. Declaramos que cualquier valor que provenga de a
a b
pasará por la operación map(..)
como se especifica.
Para mayor comodidad, hemos ilustrado esta noción de vincular a
y b
juntos en el tiempo a través de un map(..)
eo uno a uno. Pero muchas de nuestras otras operaciones de PF también podrían modelarse a lo largo del tiempo.
Considera:
var b = a.filter( function esImpar(valor) {
return valor % 2 == 1;
} );
b.escuchar( function soloImpares(valor){
console.log( "Impar:", valor );
} );
Aquí, un valor de a
solo entra en b
si pasa el predicado esImpar(..)
.
Incluso reduce(..)
puede ser modelado a lo largo del tiempo:
var b = a.reduce( function suma(total,valor){
return total + valor;
} );
b.escuchar( function totalAcumulado(valor){
console.log( "Nuevo total actual:", valor );
} );
Como no especificamos un valorInicial
a la llamada reduce(..)
, ni en el reductor suma(..)
, ni en la devolución de llamada totalAcumulado(..)
se invocarán hasta que al menos dos valores han llegado a través de a
.
Este fragmento implica que la reducción tiene una memoria de algun tipo, en el sentido de que cada vez que un valor futuro sea ingresado, el reductor suma(..)
se invocará con lo que fue el total
anterior así como con el nuevo siguiente valor valor
.
Otras operaciones de PF extendidas a lo largo del tiempo podrían incluso incluir un buffer interno, como por ejemplo uniCA(..)
haciendo un seguimiento de cada valor que se ha visto hasta ahora.
Espero que a esta altura ya puedas ver la importancia de una estructura de datos reactiva, eventuada y parecida a un array, como el ArrayPerezoso
ficticio que hemos conjurado. La buena noticia es que este tipo de estructura de datos ya existe y se llama Observable.
Nota: Solo para establecer algunas expectativas: la siguiente discusión es solo una breve introducción al mundo de los Observables. Este es un tema mucho más profundo de el que tenemos espacio para explorar completamente. Pero si has entendido la programación funcionalmente-ligera en este texto, y ahora entiendes cómo el tiempo asincrónico se puede modelar a través de los principios de la PF, los Observables deberían de seguir de manera muy natural tu aprendizaje continuado.
Los observables han sido implementados por una variedad de librerias de usuario, especialmente RxJS (https://github.com/Reactive-Extensions/RxJS) y Most (https://github.com/cujojs/most). En el momento de escribir estas líneas, hay una propuesta en progreso para agregar observables nativos a JS, al igual que las promesas se agregaron en ES6. Por el bien de la demostración, usaremos los Observables con sabor a RxJS para estos próximos ejemplos.
Aquí está nuestro ejemplo reactivo anterior, expresado con observables en lugar de ArrayPerezoso
:
// productor:
var a = new Rx.Subject();
setInterval( function cadaSegundo(){
a.next( Math.random() );
}, 1000 );
// **************************
// consumidor:
var b = a.map( function doble(valor){
return valor * 2;
} );
b.subscribe( function enValor(valor){
console.log( valor );
} );
En el universo RxJS, un Observador se suscribe a un Observable. Si combinas la funcionalidad de un Observador y un Observable, obtienes un Sujeto. Por lo tanto, para mantener nuestro fragmento más simple, construimos a
como Sujeto, para que podamos llamar next(..)
en él y asi poder insertar valores (eventos) en su flujo.
Si queremos mantener el observador y el observable separados:
// productor:
var a = Rx.Observable.create( function enObservar(observador){
setInterval( function cadaSegundo(){
observador.next( Math.random() );
}, 1000 );
} );
En este fragmento, a
es EL observable, y como era de esperar, el observador separado se llama observador
; es capaz de "observar" algunos eventos (como nuestro bucle setInterval(..)
); y usamos su método next(..)
para alimentar eventos en el flujo observable a
.
Además de map(..)
, RxJS define más de un centenar de operadores que se invocan perezosamente a medida que entra cada nuevo valor. Al igual que con los arrays, cada operador en un Observable devuelve un nuevo Observable, lo que significa que son encadenables. Si una invocación de la función del operador determina que un valor debe pasar a lo largo de la entrada Observable, se disparará en la salida Observable; de lo contrario, es descartado.
Ejemplo de una cadena observable declarativa:
var b =
a
.filter( valor => valor % 2 == 1 ) // solo números impares
.distinctUntilChanged() // solo distintos consecutivos
.throttle( 100 ) // ralentizar un poco
.map( valor = valor * 2 ); // duplicarlos
b.subscribe( function enValor(valor){
console.log( "Siguiente:", valor );
} );
Nota: No es necesario asignar el observable a b
y luego llamar b.subscribe(..)
separadamente de la cadena; eso se hace aquí para reforzar que cada operador devuelva un nuevo observable del anterior. En muchos ejemplos de codificación encontrarás que la llamada subscribe(..)
es solo el método final en la cadena. Como subscribe(..)
está mutando técnicamente el estado interno de los observable, los Programadores-Funcionales generalmente prefieren tener estos dos pasos por separado, para marcar el efecto secundario más obviamente.
Este libro ha detallado una amplia variedad de operaciones de PF que toman un solo valor (o una lista inmediata de valores) y los transforma en otros valores/valores.
Para las operaciones que se realizarán a lo largo del tiempo, todos estos principios fundacionales de PF se pueden aplicar con independencia de tiempo. Exactamente como las promesas modelan valores futuros únicos, podemos modelar listas de valores ansiosas en lugar de flujos de valores de Observables (evento) perezosos que pueden venir en uno por vez.
Un map(..)
en un array ejecuta su función de mapeo una vez para cada valor actual en el array, colocando todos los valores mapeados en el array de resultados. Un map(..)
en un Observable ejecuta su función de mapeo una vez para cada valor, cada vez que entra, y empuja todos los valores mapeados a la salida Observable.
En otras palabras, si un array es una estructura de datos ansiosa para las operaciones de PF, un Observable es su contraparte de tiempo perezosa.
Nota: Para obtener un giro diferente en PF asíncrona, consulte una libreria llamada fasy, de la que se habla en el Apéndice C.