23 de abril de 2018 – 10 min read

ES6 introdujo una nueva forma de trabajar con funciones e iteradores en forma de Generadores (o funciones generadoras). Un generador es una función que puede detenerse a mitad de camino y luego continuar desde donde se detuvo. En resumen, un generador parece ser una función pero se comporta como un iterador.

Dato divertido: async/await puede basarse en generadores. Lee más aquí.

Los generadores están intrínsecamente relacionados con los iteradores. Si usted no sabe acerca de los iteradores, aquí es un artículo para mejorar su comprensión de ellos.

Aquí está una simple analogía para tener una intuición para los generadores antes de proceder con los detalles técnicos.

Imagínese que usted está leyendo una mordedura de uñas techno-thriller. Absorto en las páginas del libro, apenas oye el timbre de su puerta. Es el repartidor de pizza. Te levantas para abrir la puerta. Sin embargo, antes de hacerlo, pones un marcador en la última página que has leído. Guardas mentalmente los acontecimientos de la trama. Luego, vas a por la pizza. Cuando vuelves a tu habitación, empiezas el libro por la página en la que pusiste el marcador. No vuelves a empezarlo desde la primera página. En cierto sentido, has actuado como una función generadora.

Veamos cómo podemos utilizar los generadores para resolver algunos problemas comunes al programar. Pero antes de eso, vamos a definir lo que son los generadores.

¿Qué son los generadores?

Una función normal como ésta no puede ser detenida antes de que termine su tarea, es decir, que se ejecute su última línea. Sigue algo que se llama modelo de ejecución hasta el final.

function normalFunc() {
console.log('I')
console.log('cannot')
console.log('be')
console.log('stopped.')
}

La única forma de salir de la normalFuncfunción es returnsaliendo de ella, o throwsiendo un error. Si se llama de nuevo a la función, ésta volverá a comenzar la ejecución desde el principio.

En cambio, un generador es una función que puede detenerse a mitad de camino y luego continuar desde donde se detuvo.

Aquí hay otras definiciones comunes de generadores –

  • Los generadores son una clase especial de funciones que simplifican la tarea de escribir iteradores.
  • Un generador es una función que produce una secuencia de resultados en lugar de un único valor, es decir, se genera una serie de valores.

En JavaScript, un generador es una función que devuelve un objeto sobre el que se puede llamar a next(). Cada invocación de next() devolverá un objeto de forma –

{ 
value: Any,
done: true|false
}

La propiedad value contendrá el valor. La propiedad done es true o false. Cuando el done se convierte en true, el generador se detiene y no generará más valores.

Aquí hay una ilustración de lo mismo –

Funciones normales frente a generadores

Nótese la flecha discontinua que cierra el bucle rendimiento-resumen-rendimiento justo antes de terminar en la parte de generadores de la imagen. Existe la posibilidad de que un generador no termine nunca. Veremos un ejemplo más adelante.

Creación de un generador

Veamos cómo podemos crear un generador en JavaScript –

function * generatorFunction() { // Line 1
console.log('This will be executed first.');
yield 'Hello, '; // Line 2 console.log('I will be printed after the pause');
yield 'World!';
}const generatorObject = generatorFunction(); // Line 3console.log(generatorObject.next().value); // Line 4
console.log(generatorObject.next().value); // Line 5
console.log(generatorObject.next().value); // Line 6// This will be executed first.
// Hello,
// I will be printed after the pause
// World!
// undefined

Cuidado con las partes en negrita. Para crear una función generadora, utilizamos la sintaxis function * en lugar de sólo function. Puede haber cualquier número de espacios entre la palabra clave function, el *, y el nombre de la función. Dado que es sólo una función, se puede utilizar en cualquier lugar que una función se puede utilizar es decir, dentro de los objetos, y los métodos de clase.

Dentro del cuerpo de la función, no tenemos un return. En su lugar, tenemos otra palabra clave yield (Línea 2). Es un operador con el que un generador puede pausarse a sí mismo. Cada vez que un generador encuentra un yield, «devuelve» el valor especificado tras él. En este caso, se devuelve Hello,. Sin embargo, no decimos «devuelto» en el contexto de los generadores. Decimos que «el generador ha devuelto Hello, «.

También podemos devolver de un generador. Sin embargo, return establece la propiedad done a true después de lo cual el generador no puede generar más valores.

function * generatorFunc() {
yield 'a';
return 'b'; // Generator ends here.
yield 'a'; // Will never be executed.
}

En la línea 3, creamos el objeto generador. Parece que estamos invocando la función generatorFunction. De hecho lo estamos haciendo. La diferencia es que en lugar de devolver cualquier valor, una función generadora siempre devuelve un objeto generador. El objeto generador es un iterador. Así que puedes usarlo en bucles for-of u otras funciones que acepten un iterable.

En la línea 4, llamamos al método next() en el generatorObject. Con esta llamada, el generador comienza a ejecutarse. Primero, se console.log el This will be executed first. Luego, se encuentra con un yield 'Hello, '. El generador devuelve el valor como un objeto { value: 'Hello, ', done: false } y se suspende/pausa. Ahora, espera la siguiente invocación.

En la línea 5, llamamos de nuevo a next(). Esta vez el generador se despierta y comienza a ejecutar desde donde lo dejó. La siguiente línea que encuentra es una console.log. Registra la cadena I will be printed after the pause. Se encuentra otro yield. El valor que se obtiene es el objeto { value: 'World!', done: false }. Extraemos la propiedad value y la registramos. El generador vuelve a dormir.

En la línea 6, volvemos a invocar a next(). Esta vez no hay más líneas que ejecutar. Recuerde que cada función devuelve implícitamente undefined si no se proporciona una declaración de retorno. Por lo tanto, el generador devuelve (en lugar de ceder) un objeto { value: undefined, done: true}. El done se establece en true. Esto señala el final de este generador. Ahora, no puede generar más valores o reanudar de nuevo ya que no hay más sentencias que ejecutar.

Necesitaremos hacer nuevo otro objeto generador para ejecutar el generador de nuevo.

Usos de los generadores

Hay muchos casos de uso impresionantes de los generadores. Veamos algunos de ellos.

Implementación de Iterables

Cuando implementas un iterador, tienes que hacer manualmente un objeto iterador con un método next(). Además, tienes que guardar manualmente el estado. A menudo, se hace muy difícil hacer eso. Dado que los generadores también son iterables, se pueden utilizar para implementar iterables sin el código boilerplate adicional. Veamos un ejemplo sencillo.

Problema: Queremos hacer un iterable personalizado que devuelva This, is, y iterable.. Aquí hay una implementación usando iteradores –

const iterableObj = {
() {
let step = 0;
return {
next() {
step++;
if (step === 1) {
return { value: 'This', done: false};
} else if (step === 2) {
return { value: 'is', done: false};
} else if (step === 3) {
return { value: 'iterable.', done: false};
}
return { value: '', done: true };
}
}
},
}for (const val of iterableObj) {
console.log(val);
}// This
// is
// iterable.

Aquí está lo mismo usando generadores –

function * iterableObj() {
yield 'This';
yield 'is';
yield 'iterable.'
}for (const val of iterableObj()) {
console.log(val);
}// This
// is
// iterable.

Puedes comparar ambas versiones. Es cierto que se trata de un ejemplo algo artificioso. Pero ilustra los puntos –

  • No tenemos que preocuparnos por Symbol.iterator
  • No tenemos que implementar next().
  • No tenemos que hacer manualmente el objeto de retorno de next() es decir { value: 'This', done: false }.
  • No tenemos que guardar el estado. En el ejemplo del iterador, el estado se guardó en la variable step. Su valor definía lo que salía del iterable. No tuvimos que hacer nada de esto en el generador.

Mejor funcionalidad asíncrona

El código que utiliza promesas y callbacks como –

function fetchJson(url) {
return fetch(url)
.then(request => request.text())
.then(text => {
return JSON.parse(text);
})
.catch(error => {
console.log(`ERROR: ${error.stack}`);
});
}

puede escribirse como (con la ayuda de librerías como co.js)-

const fetchJson = co.wrap(function * (url) {
try {
let request = yield fetch(url);
let text = yield request.text();
return JSON.parse(text);
}
catch (error) {
console.log(`ERROR: ${error.stack}`);
}
});

Algunos lectores habrán notado que es paralelo al uso de async/await. Eso no es una coincidencia. async/await puede seguir una estrategia similar y reemplazar el rendimiento con await en los casos en que las promesas están involucrados. Puede basarse en generadores. Ver este comentario para más información.

Secuencia de datos infinita

Es posible crear generadores que nunca terminan. Considere este ejemplo –

function * naturalNumbers() {
let num = 1;
while (true) {
yield num;
num = num + 1
}
}const numbers = naturalNumbers();console.log(numbers.next().value)
console.log(numbers.next().value)// 1
// 2

Hacemos un generador naturalNumbers. Dentro de la función, tenemos un bucle infinito while. En ese bucle, hacemos yield el num. Cuando el generador cede, se suspende. Cuando volvemos a llamar a next(), el generador se despierta, continúa desde donde estaba suspendido (en este caso yield num) y se ejecuta hasta que se encuentra otro yield o el generador termina. Como la siguiente sentencia es num = num + 1, actualiza num. Luego, pasa al inicio del bucle while. La condición sigue siendo verdadera. Encuentra la siguiente línea yield num. Da la actualización de num y se suspende. Esto continúa todo el tiempo que quieras.

Los generadores como observadores

Los generadores también pueden recibir valores utilizando la función next(val). Entonces el generador se llama observador ya que se despierta cuando recibe nuevos valores. En cierto sentido, sigue observando en busca de valores y actúa cuando obtiene uno. Puedes leer más sobre este patrón aquí.

Ventajas de los generadores

Como se ha visto con el ejemplo de Infinite Data Streams, es posible sólo gracias a la evaluación perezosa. La evaluación perezosa es un modelo de evaluación que retrasa la evaluación de una expresión hasta que se necesite su valor. Es decir, si no necesitamos el valor, no existe. Se calcula a medida que lo demandamos. Veamos un ejemplo –

function * powerSeries(number, power) {
let base = number;
while(true) {
yield Math.pow(base, power);
base++;
}
}

El powerSeries da la serie del número elevado a una potencia. Por ejemplo, la serie de potencias de 3 elevado a 2 sería 9(3²) 16(4²) 25(5²) 36(6²) 49(7²). Cuando hacemos const powersOf2 = powerSeries(3, 2); sólo creamos el objeto generador. Ninguno de los valores ha sido calculado. Ahora bien, si llamamos a next(), el 9 se calcularía y se volvería a sintonizar.

Eficiencia en memoria

Una consecuencia directa de Lazy Evaluation es que los generadores son eficientes en memoria. Generamos sólo los valores que se necesitan. Con las funciones normales, necesitábamos pregenerar todos los valores y guardarlos por si los utilizábamos más tarde. Sin embargo, con los generadores, podemos aplazar el cálculo hasta que lo necesitemos.

Podemos crear funciones combinadoras para actuar sobre los generadores. Los combinadores son funciones que combinan iterables existentes para crear otros nuevos.Uno de estos combinadores es take. Toma los primeros n elementos de un iterable. Aquí hay una implementación –

function * take(n, iter) {
let index = 0;
for (const val of iter) {
if (index >= n) {
return;
}
index = index + 1;
yield val;
}
}

Aquí hay algunos casos de uso interesantes de take

take(3, )// a b ctake(7, naturalNumbers());// 1 2 3 4 5 6 7take(5, powerSeries(3, 2));// 9 16 25 36 49

Aquí hay una implementación de librería cíclica (sin la funcionalidad de inversión).

function * cycled(iter) {
const arrOfValues =
while (true) {
for (const val of arrOfValues) {
yield val
}
}
}console.log(...take(10, cycled(take(3, naturalNumbers()))))// 1 2 3 1 2 3 1 2 3 1

Caveats

Hay algunos puntos que debes recordar mientras programas usando generadores.

  • Los objetos generadores son de acceso único. Una vez que has agotado todos los valores, no puedes volver a iterar sobre ellos. Para generar los valores de nuevo, necesitas hacer un nuevo objeto generador.
const numbers = naturalNumbers();console.log(...take(10, numbers)) // 1 2 3 4 5 6 7 8 9 10
console.log(...take(10, numbers)) // This will not give any data
  • Los objetos generadores no permiten el acceso aleatorio como es posible con los arrays. Como los valores se generan uno a uno, acceder a un valor aleatorio llevaría a computar los valores hasta ese elemento. Por lo tanto, no es un acceso aleatorio.

Conclusión

Aún quedan muchas cosas por cubrir en los generadores. Cosas como yield *, return() y throw(). Los generadores también hacen posible las coroutines. He listado algunas referencias que puedes leer para entender mejor los generadores.

Puedes dirigirte a la página de itertools de Python, y ver algunas de las utilidades que permiten trabajar con iteradores y generadores. Como ejercicio, puedes implementar las utilidades tú mismo.

admin

Deja una respuesta

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

lg