Por amor al código Code snippets y reflexiones sobre tecnología

Generadores en JavaScript

Los generadores son una herramienta de programación muy poderosa, pero difícil de entender cuando la vemos por primera vez. En este artículo trataré de definir de forma lo más sencilla posible qué son y como se usan los generadores y pasar a varios ejemplos prácticos en los que los generadores nos permiten simplificar código o directamente hacer cosas que no pensábamos que se pudieran hacer en JavaScript como funciones de evaluación perezosa y corutinas.

¿Qué es un generador?

Imagen de un generador en una planta eléctrica

Un generador es una función especial en JavaScript que puede pausar su ejecución y retomarla en un punto arbitrario. Para definirlos utilizamos dos nuevas palabras reservadas del lenguaje: function* y yield.

Este es uno de los casos más claros en los que he encontrado que la barrera del idioma a veces dificulta la comprensión de ciertos conceptos. yield es una palabra poco habitual en inglés, y para un no-nativo como yo, me suena totalmente fuera de contexto. Se traduce como producir o ceder

Trataré de explicar su funcionamiento con un ejemplo de código:


function* counterGenerator() {
  let i = 0
  while (true) {
    yield i
    i++
  }
}

var counter = counterGenerator()

counter.next() // { value: 0, done: false }
counter.next() // { value: 1, done: false }
counter.next() // { value: 2, done: false }
... // hasta el infinito y más allá!

Este sencillo ejemplo muestra el funcionamiento de un generador. El uso más habitual de los generadores es crear Iteradores. Un Iterador es un objeto que devuelve un elemento de una colección cada vez que llamamos a su método .next. counterGenerator devuelve un iterador que asignamos a la variable counter.

Los generadores siempre devuelven un iterador y en el momento en el que llamamos al método .next del iterador, éste ejecuta la función del generador hasta llegar al primer yield que encuentra, que detiene la ejecución de la función y produce un resultado, o dicho de otra forma, produce un elemento de la colección.

El resultado es siempre un objeto con dos propiedades, value y done, en la primera está el valor producido por yield y la segunda es para indicar si el iterador ha terminado, es decir, si ese era el último elemento de la colección.

En la siguiente llamada a .next la función continúa desde el yield y hasta el siguiente yield, y así hasta encontrar un return que devolverá true como valor de done.

El iterador devuelto por counterGenerator Puede usarse a su vez dentro de un bucle for of, ya que estos bucles utilizan el interface del iterador para obtener el valor de cada iteración:


for(var c of counter) { 
  console.log(c)
  if(c > 10) break // break detiene el bucle for como si hubiera encontrado done === true
}

// 1
// 2
// 3
// ...
// 10

Bucles infinitos y evaluación perezosa

En el ejemplo anterior hemos usado todo el tiempo un bucle while (true) sin bloquear o saturar la cpu y sin ninguna alerta por parte de node. Esto es así porque yield pausa la ejecución de la función, y por lo tanto, pausa el bucle infinito, cada vez que produce un valor.

Esto es lo que se llama evaluación perezosa y es un concepto importante en lenguajes funcionales como Haskell. Básicamente nos permite tener listas o estructuras de datos “infinitas” y operar sobre ellas, por ejemplo podemos tener un operador take(n) que toma los N primeros elementos de una lista infinita:


function* oddsGenerator() {
  let n = 0
  while (true) {
    yield 2*n + 1
    n++
  }
}

function take(n, iter) {
  let counter = n
  for ( c of iter) {
    console.log(c)
    counter--
    if(counter <= 0) break
  }
}

var oddNumbers = oddsGenerator() // TODOS los números impares 

take(5, oddNumbers) // toma 5 números impares
// 1
// 3
// 5
// 7
// 9

La evaluación perezosa permite construir este tipo de estructuras “infinitas” o completas sin producir errores de ejecución y también son más eficientes en algoritmos de búsqueda, recorrido de árboles y cosas así, al evaluar el mínimo número de nodos necesarios para encontrar la solución. Para ver más usos y ventajas de la evaluación perezosa puedes ver este hilo de stackoverflow

Como añadido en JavaScript, los generadores nos permiten crear una sintaxis más legible en el uso de arrays. Podemos obtener los valores producidos por el generador en ES6 mediante el spread operator:


function* range (limit) {
  let c = 0
  while ( c < limit ) {
    yield c
    c++
  }
}

[...range(5)]
// [ 0, 1, 2, 3, 4 ] 

Pero cuidado con utilizar el spread operator o los bucles for con listas infinitas como la de arriba:


for(let c of oddNumbers) { // bucle infinito!!
  console.log(c) 
}

[...oddNumbers] // bucle infinito y 'out of memory', no podemos crear un array infinito en la memoria!!

Async/await y corutinas

Además de la generación de iteradores, los generadores nos permiten controlar la ejecución de funciones asíncronas gracias al mecanismo de pausa de la función de yield. Para explicar por qué esto es importante, vamos a desviarnos un momento y hablar de async/await

Una de las funcionalidades más coreadas de ES7 son las nuevas construcciones async y await, que nos permiten ejecutar código asíncrono pero escribiéndolo de forma lineal, sin necesidad de pensar en callbacks o promesas. Veamos cómo funciona:


function helloDelayed() {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve('Hello'), 5000)
  })
}

async function hi() {
  const greeting = await helloDelayed()
  console.log(greeting)
}

hi()

// a los 5 segundos aparece 'Hello'

Lo genial de async/await es que el código de la función async es lineal, le hemos pasado una promesa a await y nos devuelde directamente el valor con el que se ha resuelto, esperando y deteniendo la ejecución de la función.

No me voy a detener más en explicar cómo funciona, eso lo dejo para otro post, pero async/await en realidad no es más que un uso concreto de los generadores, azúcar sintáctico para usar un generador y evaluar una promesa, podríamos replicar esta funcionalidad, para una sola llamada (más adelante veremos la generalización) así:


function helloDelayed() {
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve('Hello'), 5000)
  })
}

function hi(gen) {
  const iterator = gen()
  iterator.next()

  helloDelayed.then(res => iterator.next(res))
}

hi(function* () {
  const greeting = yield;
  console.log(greeting)
})

Esta solución es más difícil de leer y de escribir, sobre todo por el doble .next necesario para que funcione, y por la poca legibilidad del comando yield en si mismo. Pero muestra una parte importante del funcionamiento de los generadores.

Lo que está pasando aquí es que hi recibe un generador como parámetro, lo ejecuta y llama una vez a .next para ejecutar el generador hasta el yield y luego lo vuelve a llamar cuando tiene el resultado de la promesa para revolver el resultado al yield.

Hasta ahora no habíamos hablado de esto para no complicar más, pero podemos añadir a la llamada a .next un parámetro, que a su vez podemos capturar en una variable asignándola a yield. Esta, para mi, es la funcionalidad más confusa de los generadores, pero es la clave para usarlos para ejecutar llamadas asíncronas o corutinas como veremos en los siguientes ejemplos. Veamos un pequeño ejemplo de como funciona:


function* counterGenerator() {
  let i = 0
  while (true) {
    const str = yield i
    console.log(str)
    i++
  }
}

var counter = counterGenerator()

counter.next('hi') 
// { value: 0, done: false }
// el primer 'next' no imprime nada porque el generador se ejecuta solo hasta el yield
counter.next('ho') 
// ho
// { value: 1, done: false }
counter.next('hu') 
// hu
// { value: 2, done: false }


Este mecanismo nos da una forma de comunicarnos con el generador, algo muy potente, aunque en mi opinión con una sintaxis difícil de leer y nada clara. Los generadores no son una herramienta que hay que usar con moderación, pero nos permiten hacer cosas que estarían fuera del alzance de JavaScript si no fuera con ellos, como el ejemplo que veremos a continuación.

Generalizando el código de helloDelayed, se puede construir una función que controle la ejecución de funciones asíncronas prácticamente igual a como hace async/await, veamos un ejemplo que lee dos ficheros (ejemplo tomado de este post de TJ HoloWaychuck, que recomiendo leer, el código original usa callbacks, pero lo he modificado para usar promesas, dos ejemplos por el precio de uno ;)):


const fs = require('fs')

function thread(fn) {
  var gen = fn()

  function next(res) {
    var ret = gen.next(res)
    if (ret.done) return
    ret.value.then(next)
  }
  
  next()
}

thread(function *(){
  var a = yield read('README.md')
  var b = yield read('index.html')
  console.log(a)
  console.log(b)
})


function read(path) {
  return new Promise(resolve => fs.readFile(path, 'utf8', (err, res) => resolve(res)))
}

Este código, sí se parece mucho más al de async/await, es más, si cambiamos thread por async y imaginamos que yield es await es prácticamente igual:


async(function *(){
  var a = yield read('README.md')
  var b = yield read('index.html')
  console.log(a)
  console.log(b)
})

Este ejemplo básico es una simplificación de la librería Co, que nos permite escribir este tipo de código asíncrono de forma lineal y con la seguridad de que captura todas las excepciones de forma parecida a como hacen las Promesas.

Técnicamente esto no son corutinas. En realidad, cuando hablamos de generadores, hablamos de ‘semicorutinas’ porque los generadores no son tan flexibles como las corutinas de lenguajes como Go, pero diremos que son equivalentes a corutinas, aún sabiendo que estamos simplificando, porque es la herramienta que tenemos para esta función en JavaScript a nivel nativo.

En cuando a otras librerías para corutinas, fibjs y node-fibers son implementaciones de ‘fibers’ que podríamos traducir como “fibras” o “hilos ligeros” que es más flexible que los generadores y que algunos desarrolladores quieren incluir en el núcleo de Node.js.

Los generadores y las corutinas son herramientas avanzadas del lenguaje que seguramente no tengas que utilizar directamente a no ser que hagas desarrollo de sistemas o librerías, pero de las que podemos sacar provecho en nuestro código con librerías como Co, node-fibers o el nuevo async/await nativo. Espero que estos ejemplos hayan resuelto algunas dudas y generado aun más dudas e interés por el lenguaje y sirva como introducción a todo este tema.

Otra lectura recomendada para profundizar en los Generadores es el libro de Kyle Simpson ES6 and Beyond, y en concreto el capítulo sobre Iteradores y Generadores.

Si quieres comentar, corregir algo o contribuir, puedes hacerlo en Github. Cualquier comentario, crítica o contribución será bien recibido.