- Una rápida introducción
- Funciones generadoras en JavaScript
- ¿Qué es una función generadora?
- Tu primera función generadora
- Iterables e iteradores
- Los iterables son propagables y desestructurados
- Extraer datos de una función generadora
- Volviendo a las funciones del generador
- Casos de uso para las funciones del generador
- Funciones generadoras asíncronas en JavaScript
- ¿Qué es una función generadora asíncrona?
- Tu primera función generadora asíncrona
- Iterables e iteradores asíncronos
- Extrayendo datos de un generador asíncrono
- Volviendo a las funciones asíncronas del generador
- Casos de uso para iterables asíncronos y funciones generadoras asíncronas
- Resumiendo
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...of
Tambié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.
value
en 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 quecount
llega a 3. - un objeto cuya forma es
{ value: x, done: true}
cuandocount
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...of
que 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í go
se 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!