Una rápida introducción

Bienvenido al maravilloso mundo de las funciones generadoras y de los generadores asíncronos en JavaScript.

Estás a punto de conocer una de las funciones más exóticas (según la mayoría de los desarrolladores) de JavaScript.

¡Comencemos!

Funciones generadoras en JavaScript

¿Qué es una función generadora?

Una función generadora (ECMAScript 2015) en JavaScript es un tipo especial de función sincrónica que es capaz de detener y reanudar su ejecución a voluntad.

En contraste con las funciones regulares de JavaScript, que son de disparar y olvidar, las funciones generadoras también tienen la capacidad de:

  • comunicarse con la persona que llama a través de un canal bidireccional.
  • mantener su contexto de ejecución (alcance) sobre las llamadas posteriores.

Puedes pensar en las funciones generadoras como en los cierres con esteroides, pero las similitudes terminan aquí.

Tu primera función generadora

Para crear una función generadora ponemos una estrella * después de la palabra clave function:

function* generate() {//}

Nota: las funciones generadoras también pueden asumir la forma de un método de clase, o de una expresión de función. Por el contrario, las funciones generadoras de flechas no están permitidas.

Una vez dentro de la función podemos utilizar yield para pausar la ejecución:

function* generate() { yield 33; yield 99;}

yield pausa la ejecución y devuelve un objeto llamado Generator al llamante. Este objeto es tanto un iterable, como un iterador al mismo tiempo.

Desmitifiquemos estos conceptos.

Iterables e iteradores

Un objeto iterable en JavaScript es un objeto que implementa Symbol.iterator. He aquí un ejemplo mínimo de iterable:

const iterable = { : function() { /* TODO: iterator */ }};

Una vez que tenemos este objeto iterable, asignamos una función a para devolver un objeto iterador.

Esto parece mucha teoría, pero en la práctica un iterable es un objeto sobre el que podemos hacer un bucle con for...of (ECMAScript 2015). Más sobre esto en un minuto.

Ya deberías conocer un par de iterables en JavaScript: los arrays y las cadenas, por ejemplo, son iterables:

for (const char of "hello") { console.log(char);}

Otros iterables conocidos son Map y Set. for...ofTambién es útil para iterar sobre los valores de un objeto:

const person = { name: "Juliana", surname: "Crain", age: 32};for (const value of Object.values(person)) { console.log(value);}

Sólo recuerda que cualquier propiedad marcada como enumerable: false no aparecerá en la iteración:

const person = { name: "Juliana", surname: "Crain", age: 32};Object.defineProperty(person, "city", { enumerable: false, value: "London"});for (const value of Object.values(person)) { console.log(value);}// Juliana// Crain// 32

Ahora, el problema con nuestro iterable personalizado es que no puede ir muy lejos solo sin un iterador.

Los iteradores también son objetos, pero deben ajustarse al protocolo de los iteradores. En resumen, los iteradores deben tener al menos un método next().

next() debe devolver otro objeto, cuyas propiedades son value y done.

La lógica de nuestro método next() debe obedecer las siguientes reglas:

  • devolvemos un objeto con done: false para continuar la iteración.
  • devolvemos un objeto con done: true para detener la iteración.

valueen cambio, debe contener el resultado que queremos producir para el consumidor.

Ampliemos nuestro ejemplo añadiendo el iterador:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};

Aquí tenemos un iterable, que implementa correctamente Symbol.iterator. También tenemos un iterador, que devuelve:

  • un objeto cuya forma es { value: x, done: false} hasta que count llega a 3.
  • un objeto cuya forma es { value: x, done: true} cuando count llega a 3.

Este iterable mínimo está listo para ser iterado con for...of:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};for (const iterableElement of iterable) { console.log(iterableElement);}

El resultado será:

123

Una vez que conozcas mejor las funciones generadoras, verás que los iteradores son la base de los objetos generadores.

Tenga en cuenta:

  • los iterables son los objetos sobre los que iteramos.
  • los iteradores son lo que hace que el iterable … «loopable» over.

Al final, ¿para qué sirve un iterable?

Ahora tenemos un bucle estándar y fiable for...ofque funciona prácticamente para casi cualquier estructura de datos, personalizada o nativa, en JavaScript.

Para usar for...of en tu estructura de datos personalizada tienes que:

  • implementar Symbol.iterator.
  • proveer un objeto iterador.

¡Eso es todo!

Más recursos:

  • Iterators gonna iterate por Jake Archibald.

Los iterables son propagables y desestructurados

Además de for...of, también podemos usar la propagación y la desestructuración en iterables finitos.

Consideremos de nuevo el ejemplo anterior:

const iterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return { value: count, done: false }; } return { value: count, done: true }; } }; }};

Para sacar todos los valores podemos extender el iterable en un array:

const values = ;console.log(values); // 

Para sacar sólo un par de valores en su lugar podemos desestructurar el array del iterable. Aquí obtenemos el primer y segundo valor del iterable:

const = iterable;console.log(first); // 1console.log(second); // 2

Aquí en cambio obtenemos el primero, y el tercero:

const = iterable;console.log(first); // 1console.log(third); // 3

Volvamos ahora a centrarnos en las funciones generadoras.

Extraer datos de una función generadora

Una vez que tenemos una función generadora podemos empezar a interactuar con ella. Esta interacción consiste en:

  • obtener valores del generador, paso a paso.
  • Opcionalmente enviar valores de vuelta al generador.

Para sacar valores de un generador podemos utilizar tres enfoques:

  • llamar a next() sobre el objeto iterador.
  • iteración con for...of.
  • extensión y desestructuración de arrays.

Una función generadora no calcula todos sus resultados en un solo paso, como hacen las funciones normales.

Si tomamos nuestro ejemplo, para obtener valores del generador podemos primero calentar el generador:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();

Aquí gose convierte en nuestro objeto iterable/iterador, resultado de llamar a generate.

(Recuerda que un objeto Generator es tanto un iterable como un iterador).

A partir de ahora podemos llamar a go.next() para avanzar en la ejecución:

function* generate() { yield 33; yield 99;}const go = generate();// Consume the generatorconst { value: firstStep } = go.next(); // firstStep is 33const { value: secondStep } = go.next(); // secondStep is 99

Aquí cada llamada a go.next() produce un nuevo objeto. En el ejemplo desestructuramos la propiedad value de este objeto.

Los objetos devueltos al llamar a next() sobre el objeto iterador tienen dos propiedades:

  • value: el valor del paso actual.
  • done: un booleano que indica si hay más valores en el generador, o no.

Hemos implementado dicho objeto iterador en la sección anterior. Al usar un generador, el objeto iterador ya está ahí para ti.

next() Funciona bien para extraer datos finitos de un objeto iterador.

Para iterar sobre datos no finitos, podemos usar for...of. Aquí tenemos un generador infinito:

function* endlessGenerator() { let counter = 0; while (true) { counter++; yield counter; }}// Consume the generatorfor (const value of endlessGenerator()) { console.log(value);}

Como puedes notar no hay necesidad de inicializar el generador cuando se usa for...of.

Por último, también podemos extender y desestructurar el objeto generador. Aquí está la propagación:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Spreadconst values = ;console.log(values); // 

Aquí está la desestructuración:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Destructuringconst = go;console.log(first); // 1console.log(second); // 2

Es importante notar que los generadores se agotan una vez que se consumen todos sus valores.

Si extiendes los valores de un generador, no queda nada que sacar después:

function* generate() { yield 33; yield 99;}// Initialize the generatorconst go = generate();// Spreadconst values = ;console.log(values); // // Exhaustconst = go;console.log(first); // undefinedconsole.log(second); // undefined

Los generadores también funcionan a la inversa: pueden aceptar valores o comandos del llamador, como veremos en un minuto.

Volviendo a las funciones del generador

Los objetos del generador, el objeto resultante de una llamada a una función del generador, exponen los siguientes métodos:

  • next()
  • return()
  • throw()

Ya vimos next(), que ayuda a sacar objetos de un generador.

Su uso no sólo se limita a extraer datos, sino también a enviar valores al generador.

Considera el siguiente generador:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}

Lista un parámetro string. Proporcionamos este argumento en la inicialización:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");

En cuanto llamamos a next() por primera vez en el objeto iterador, la ejecución comienza y produce «A»:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // A

En este punto podemos hablar con el generador proporcionando un argumento para next:

go.next("b");

Aquí está el listado completo:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // Aconsole.log(go.next("b").value); // B

A partir de ahora, podemos introducir valores en yield cada vez que necesitemos una nueva cadena en mayúsculas:

function* endlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}const go = endlessUppercase("a");console.log(go.next().value); // Aconsole.log(go.next("b").value); // Bconsole.log(go.next("c").value); // Cconsole.log(go.next("d").value); // D

Si en algún momento queremos volver del todo de la ejecución, podemos usar return en el objeto iterador:

const { value } = go.return("stop it");console.log(value); // stop it

Esto detiene la ejecución.

Además de los valores también puede lanzar una excepción en el generador. Ver «Manejo de errores para las funciones del generador».

Casos de uso para las funciones del generador

La mayoría de los desarrolladores (yo incluido) ven las funciones del generador como una característica exótica de JavaScript que tiene poca o ninguna aplicación en el mundo real.

Esto podría ser cierto para el trabajo promedio de front-end, donde una pizca de jQuery, y un poco de CSS pueden hacer el truco la mayoría de las veces.

En realidad, las funciones generadoras realmente brillan en todos aquellos escenarios donde las actuaciones son primordiales.

En particular, son buenas para:

  • trabajar con archivos y conjuntos de datos de gran tamaño.
  • luchar contra los datos en el back-end, o en el front-end.
  • generar secuencias infinitas de datos.
  • computar lógica costosa bajo demanda.

Las funciones generadoras son también el bloque de construcción para sofisticados patrones asíncronos con funciones generadoras asíncronas, nuestro tema para la siguiente sección.

Funciones generadoras asíncronas en JavaScript

¿Qué es una función generadora asíncrona?

Una función generadora asíncrona (ECMAScript 2018) es un tipo especial de función asíncrona que es capaz de detener y reanudar su ejecución a voluntad.

La diferencia entre las funciones generadoras síncronas y las funciones generadoras asíncronas es que estas últimas devuelven un resultado asíncrono, basado en promesas, del objeto iterador.

Al igual que las funciones generadoras, las funciones generadoras asíncronas son capaces de:

  • comunicarse con la persona que llama.
  • conservar su contexto de ejecución (ámbito) sobre las llamadas posteriores.

Tu primera función generadora asíncrona

Para crear una función generadora asíncrona declaramos una función generadora con la estrella *, prefijada con async:

async function* asyncGenerator() { //}

Una vez dentro de la función podemos utilizar yield para pausar la ejecución:

async function* asyncGenerator() { yield 33; yield 99;}

Aquí yield pausa la ejecución y devuelve un objeto llamado Generator al llamante.

Este objeto es tanto un iterable, como un iterador al mismo tiempo.

Recapitulemos estos conceptos para ver cómo encajan en el terreno asíncrono.

Iterables e iteradores asíncronos

Un iterable asíncrono en JavaScript es un objeto que implementa Symbol.asyncIterator.

Aquí tienes un ejemplo mínimo:

const asyncIterable = { : function() { /* TODO: iterator */ }};

Una vez que tenemos este objeto iterable, asignamos una función a para devolver un objeto iterador.

El objeto iterador debe ajustarse al protocolo de iteradores con un método next() (como el iterador síncrono).

Ampliemos nuestro ejemplo añadiendo el iterador:

const asyncIterable = { : function() { let count = 0; return { next() { count++; if (count <= 3) { return Promise.resolve({ value: count, done: false }); } return Promise.resolve({ value: count, done: true }); } }; }};

Este iterador es similar al que construimos en los apartados anteriores, esta vez la única diferencia es que envolvemos el objeto que retorna con Promise.resolve.

En este punto podemos hacer algo parecido a esto:

const go = asyncIterable();go.next().then(iterator => console.log(iterator.value));go.next().then(iterator => console.log(iterator.value));// 1// 2

O con for await...of:

async function consumer() { for await (const asyncIterableElement of asyncIterable) { console.log(asyncIterableElement); }}consumer();// 1// 2// 3

Los iterables e iteradores asíncronos son la base de las funciones generadoras asíncronas.

Volvamos a centrarnos en ellos.

Extrayendo datos de un generador asíncrono

Las funciones generadoras asíncronas no calculan todos sus resultados en un solo paso, como hacen las funciones regulares.

En su lugar, sacamos los valores paso a paso.

Después de examinar los iteradores asíncronos y los iterables, no debería sorprendernos ver que para sacar promesas de un generador asíncrono podemos utilizar dos enfoques:

  • llamar a next() sobre el objeto iterador.
  • iteración asíncrona con for await...of.

En nuestro ejemplo inicial podemos hacer:

async function* asyncGenerator() { yield 33; yield 99;}const go = asyncGenerator();go.next().then(iterator => console.log(iterator.value));go.next().then(iterator => console.log(iterator.value));

La salida de este código es:

3399

El otro enfoque utiliza la iteración asíncrona con for await...of. Para utilizar la iteración asíncrona envolvemos el consumidor con una función async.

Aquí está el ejemplo completo:

async function* asyncGenerator() { yield 33; yield 99;}async function consumer() { for await (const value of asyncGenerator()) { console.log(value); }}consumer();

for await...of funciona bien para extraer flujos de datos no finitos.

Veamos ahora cómo enviar los datos de vuelta al generador.

Volviendo a las funciones asíncronas del generador

Considera el siguiente generador asíncrono:

async function* asyncGenerator(string) { while (true) { string = yield string.toUpperCase(); }}

Al igual que la máquina de mayúsculas sin fin del ejemplo del generador, podemos proporcionar un argumento a next():

async function* asyncEndlessUppercase(string) { while (true) { string = yield string.toUpperCase(); }}async function consumer() { const go = await asyncEndlessUppercase("a"); const { value: firstStep } = await go.next(); console.log(firstStep); const { value: secondStep } = await go.next("b"); console.log(secondStep); const { value: thirdStep } = await go.next("c"); console.log(thirdStep);}consumer();

Aquí cada paso envía un nuevo valor al generador.

La salida de este código es:

ABC

Aunque no haya nada intrínsecamente asíncrono en toUpperCase(), puedes intuir el propósito de este patrón.

Si en algún momento queremos salir de la ejecución, podemos llamar a return() sobre el objeto iterador:

 const { value } = await go.return("stop it"); console.log(value); // stop it

Además de los valores también puedes lanzar una excepción al generador. Ver «Manejo de errores para generadores asíncronos».

Casos de uso para iterables asíncronos y funciones generadoras asíncronas

Si las funciones generadoras son buenas para trabajar de forma síncrona con archivos grandes y secuencias infinitas, las funciones generadoras asíncronas permiten todo un nuevo terreno de posibilidades para JavaScript.

En particular, la iteración asíncrona facilita el consumo de secuencias legibles. El objeto Response en Fetch expone body como un flujo legible con getReader(). Podemos envolver tal flujo con un generador asíncrono, y más tarde iterar sobre él con for await...of.

Async iterators and generators por Jake Archibald tiene un montón de buenos ejemplos.

Otros ejemplos de flujos son los flujos de petición con Fetch.

En el momento de escribir esto no hay ninguna API de navegador que implemente Symbol.asyncIterator, pero la especificación Stream va a cambiar esto.

En Node.js el reciente Stream API juega muy bien con los generadores asíncronos y la iteración asíncrona.

En el futuro seremos capaces de consumir y trabajar sin problemas con flujos escribibles, legibles y transformadores en el lado del cliente.

Más recursos:

  • La API de flujos.

Resumiendo

Términos y conceptos clave que hemos cubierto en este post:

ECMAScript 2015:

  • iterable.
  • iterador.

Estos son los bloques de construcción para las funciones generadoras.

ECMAScript 2018:

  • iterable asíncrono.
  • iterador asíncrono.

Estos en cambio son los bloques de construcción para las funciones generadoras asíncronas.

Una buena comprensión de los iterables e iteradores puede llevarte muy lejos. No es que vayas a trabajar con funciones generadoras y funciones generadoras asíncronas todos los días, pero son una buena habilidad para tener en tu cinturón de herramientas.

¿Y tú? ¿Alguna vez las has usado?

Gracias por leer y sigue atento a este blog!

Deja una respuesta

Tu dirección de correo electrónico no será publicada.