¡Esta es una revisión vieja del documento!
Tabla de Contenidos
Iteradores y generadores
Módulo perteneciente al curso Programación avanzada con JavaScript y ECMAScript.
Introducción
Los iteradores son un concepto totalmente nuevo en ECMAScript. Básicamente un iterador define un mecanismo que permite iterar (o recorrer) por los valores de un objeto. Cualquier objeto que proporcione un iterador, se conoce como un objeto iterable y podremos iterar por sus valores.
Los arrays, y los tipos Map y Set son iterables (ya que proporcionan su propio iterador). Pero los objetos que creemos nosotros pueden serlo también.
En este módulo vamos a ver cómo iterar a través de objetos iterables (bucle for-of) y cómo definir iteradores. Al final iremos un paso más allá y hablaremos de los generadores, un mecanismo que permite definir iteradores de forma muy sencilla.
DEMO: Tipos de bucles
Veremos los distintos tipos de bucles disponibles en JavaScript.
var data = [1, 10, 100, 1000];
Utilizando un bucle for:
let suma = 0; for (let idx = 0; idx < data.length; idx++) { suma += data[idx]; }
Utilizando en bucle for-in:
let suma = 0; for (let v in data) { suma += v; }
Si vemos el resultado de suma será 00123. La razón de este resultado es que el bucle for-in no itera sobre los valores de un objeto sino sobre sus claves / índices. Podemos verlo con:
let suma = 0; for (let v in data) { console.log("v: ", v, typeof(v)); suma += v; }
Las claves de los arrays, los índices, siempre son cadenas.
En ECMAScript 2015 tenemos el bucle for-of que sí itera sobre los valores:
let suma = 0; for (let v of data) { suma += v; }
Si queremos usar for-of para iterar sobre un objeto, este objeto debe ser iterable.
Iteradores
Un iterador es un objeto que permite iterar sobre otro objeto. En el caso de ECMAScript el iterador se obtiene siempre a partir del objeto que se vaya a iterar. Para ello se usa una función con un nombre específico, un símbolo accesible a través de Symbol.iterator. Por lo tanto un objeto será iterable si define la función Symbol.iterator que devuelve un iterador.
Es importante notar que la función Symbol.iterator no es el iterador en sí mismo. Esta función es la que permite obtener el iterador. El iterador realmente es un objeto con un solo método (next) que permite iterar sobre los valores.
Cada iterador permite iterar por todos los valores una sola vez y son “forward only“ (es decir solo pueden ir obteniendo los elementos de uno a uno y solo hacia adelante).
Cualquier objeto que tenga un iterador, decimos que es iterable, y tenemos dos maneras básicas de iterar sobre él:
- La primera es el uso del bucle
for…of. Este bucle funciona no solo en arrays, sino de hecho con cualquier objeto iterable. - La otra opción es el uso del operador spread. Si el objeto
objes iterable entonces podemos obtener un array con todos los valores de dicho objeto de la siguiente forma:
var values = [...obj]
Vamos a verlo…
Creando iteradores
a creación de un iterador es realmente sencilla aunque un poco “tediosa” por el código a usar. Básicamente para crear un iterador hay que:
- Crear una función, llamada
Symbol.iteratorque… - devuelva un objeto, que tenga tan solo una…
- función, llamada
next. Esta función debe… - devolver un objeto con dos propiedades:
- Una de las cuales debe llamarse
value - y la otra debe llamarse
doney es un booleano.
En resumen, el código base para crear un iterador es el siguiente:
[Symbol.iterator]() {
return {
next() {
return { done: true, value: xxx }
}
}
}
IMPORTANTE: Observa que el nombre de la función (Symbol.iterator) es un símbolo, no una cadena. De ahí que debamos colocarlo entre corchetes.
Entendiendo el código
Para iterar sobre un objeto se requiere un iterador. El iterador se obtiene una sola vez cuando se empieza a iterar por el objeto. El iterador se obtiene a través de la función Symbol.iterator.
Por supuesto, si se empiezan varias iteraciones (p. ej. se usan varios bucles for…of) se obtienen varios iteradores (cada iterador nos permite iterar una vez por todos los valores del objeto).
Una vez tenemos un iterador debemos obtener valores. Por cada valor a obtener se llama al método next del iterador. Voy a recalcarlo porque es muy importante: solo se llama al método next cuando se requiere un nuevo elemento. Es por eso que decimos que los iteradores usan “evaluación perezosa” (lazy evaluation). Si solo necesitamos dos elementos del iterador llamaremos solo dos veces al método next.
El método next nos devuelve un objeto con las propiedades done y value. La que manda es la propiedad done. Si vale true, entonces el valor de value es ignorado porque se asume que ya no hay valores sobre los que iterar.
Así, un iterador que devuelva N valores, si iteramos por todos ellos, el método next será llamado N+1 veces. En las N primeras debe devolver los N valores (y done a false) y en la última llamada debe devolver done a true (y es irrelevante lo que devuelva en value).
DEMO: Creando un iterador
var obj = { // Función que permitirá iterar [Symbol.iterator]() { let idx = 0; return { next() { idx++; return { value: 10, done: true } } } } }
Ahora podemos iterar:
for (let v of obj) { console.log(v); }
Veremos que no devuelve nada porque al poner done como true ignora el valor.
var obj = { // Función que permitirá iterar [Symbol.iterator]() { let idx = 0; return { next() { idx++; return { value: 10, done: idx > 1 } } } } }
for (let v of obj) { console.log(v); }
Ahora sí mostrará el 10.
Pongamos varios console.log para ver cómo se ejecuta:
var obj = { // Función que permitirá iterar [Symbol.iterator]() { console.log("obteniendo iterador"); let idx = 0; return { next() { console.log("obteniendo valor"); idx++; return { value: 10, done: idx > 1 } } } } }
console.log("inicio"); for (let v of obj) { console.log(v); } console.log("fin"); // -> inicio // -> obteniendo iterador // -> obteniendo valor // -> 10 // -> obteniendo valor // -> fin
DEMO: Un iterador un poco más realista
var repo = { _data: [], add(value) { this._data.push(value); }, [Symbol.iterator]() { let idx = 0; var self = this; return { next() { let current = self._data[idx]; idx++; return { value: current, done: idx > self._data.length } } } } }
Probemos:
repo.add({v: 40}); repo.add({v: 60}); for (let x of repo) { console.log(x); } // Salida: // Object { v: 40 } // Object { v: 60 }
DEMO: Un iterador un poco más realista (II)
Vamos a completar el ejemplo anterior haciendo el array data sea privado y no un miembro de repo. Para ello necesitamos una función por encima que nos dé un ámbito:
const repo = (() => let _data = []; var repo = { add(value) { this._data.push(value); }, [Symbol.iterator]() { let idx = 0; var self = this; return { next() { let current = _data[idx]; idx++; return { value: current, done: idx > _data.length } } } } } return repo; })();
Probamos:
repo.add({v: 42}); repo.add({v: 60}); for (let x of repo) { console.log(x); }
Iteradores infinitos
Dado que los iteradores se evalúan perezosamente podemos construir iteradores que nunca terminen. Para ello es tan sencillo como hacer un iterador que nunca ponga a true la propiedad done:
let fibonacci = { [Symbol.iterator]() { let pre = 0, cur = 1; return { next() { [pre, cur] = [cur, pre + cur]; return { done: false, value: cur } } } } }
El objeto fibonacci define un iterador, que nunca termina, que devuelve la sucesión de Fibonacci. Por supuesto si iteramos sobre este iterador debemos parar la iteración en algún momento o si no tendremos un bucle infinito (o algún otro error como falta de memoria si estamos guardando los valores en un array):
for (var n of fibonacci) { if (n > 1000) break; console.log(n); }
Este código imprime por la consola todos los valores de la sucesión de Fibonacci inferiores o iguales a 1000. Cuando encontramos uno superior, terminamos la iteración con break. A pesar de que el iterador potencialmente pueda devolver infinitos valores, al ser evaluados de forma perezosa, no hay ningún problema.
Generadores
Los generadores son un mecanismo auxiliar para definir iteradores. El resultado de un generador es un iterador. La gracia de los generadores es que permite definir un iterador utilizando una co-rutina.
Co-rutinas
Estamos acostumbrados a que una función tiene un punto de entrada (su inicio) y un punto de salida (su final o algún return). Tenemos claro que el flujo de ejecución empieza por el “llamante” (caller) y cuando éste llama a una función, el flujo entra dentro de la función y no sale hasta finalizar ésta en alguno de sus puntos de salida. En el momento en que se sale de la función llamada, el flujo de ejecución retorna al “llamante”.
Una co-rutina rompe este flujo en el sentido de que tiene más de un punto de entrada. Es decir, el flujo de ejecución puede entrar varias veces dentro de la co-rutina por distintos puntos de entrada. Esto complica el seguimiento del flujo, que ahora queda como sigue:
- Empieza en el “llamante” y cuando este invoca la co-rutina…
- El flujo empieza por el primer punto de entrada de la co-rutina
- Se ejecuta la co-rutina hasta encontrar un punto de salida
- En este momento el código vuelve al “llamante” (hasta ahora nos hemos comportado como una función normal)
- Si el llamante vuelve a invocar la co-rutina…
- El flujo entra en la co-rutina, pero no por el inicio, sino por el segundo punto de entrada que tenga definido.
- Se ejecuta de nuevo la co-rutina (recuerda que se ha entrado por el segundo punto de entrada) y cuando se sale de ella, el flujo regresa al “llamante”.
- Este proceso se puede repetir tantas veces como puntos de entrada distintos tenga definidos la co-rutina.
El uso de generadores nos da una sintaxis (muy sencilla) para definir esas co-rutinas. De hecho la sintaxis no está pensada para definir co-rutinas, sino para definir iteradores (ahora vamos a ver el porqué). Crear la co-rutina con todos sus puntos de entrada es tarea del motor de ECMAScript.
Creando generadores
La sintaxis para crear un generador es muy sencilla. Recuerda que la idea es crear un iterador. Hasta ahora hemos visto que el iterador tiene un método next. Pero un generador nos permite definir un iterador, no en base a un método, sino en base a su resultado: en base a los elementos que debe devolver el iterador.
Con un generador, en lugar de devolver el iterador, lo que hacemos es ir devolviendo, directamente, todos los valores y dejamos la tarea de construir un iterador al motor de ECMAScript. Observa el siguiente código:
var foo = { [Symbol.iterator]: function* () { yield 10; yield 20; } } for (data of foo) { console.log (data); }
El objeto foo usa un generador para crear su iterador. Debemos definir la función Symbol.iterator igualmente, pero ahora esa función es un generador.
Observa bien la sintaxis: function*. Este asterisco después de function indica que esa función no es una función “normal” sino que es un generador. Y eso nos habilita el uso de yield.
Usamos yield para ir devolviendo cada uno de los valores. Cada yield marca a la vez un punto de salida de la co-rutina y un punto de entrada.
Es decir: yield marca un punto de salida de la función, como return, pero hace que la siguiente vez que se llame a la misma función, se reanude la ejecución desde el mismo punto en el que estaba, o sea, a continuación del yield.
Es importante tener presente que el generador, dado que al final devuelve un iterador, se sigue evaluando perezosamente. Es decir, cuando se requiere el primer elemento, se llama al generador y se ejecuta todo el código hasta la primera sentencia yield. Cuando se encuentra un yield la ejecución sale del generador y no entrará de nuevo hasta que no se pida el siguiente elemento. Cuando el siguiente elemento se pide, la ejecución entra otra vez en el generador, pero no desde el principio, sino que continúa justo después del yield.
Si se llega al final del generador, entonces se asume que ya no hay más valores (lo equivalente a devolver done a true en el iterador).
Así pues los generadores ofrecen un mecanismo muy sencillo y potente para definir nuestros iteradores, centrándonos en los valores a devolver más que en la creación del iterador en sí.
Por supuesto un generador puede originar iteradores infinitos. Para ello basta que nunca se salga del generador sin que haya un yield. Un bucle infinito puede conseguirlo:
let fibonacci = { [Symbol.iterator]: function*() { let pre = 0, cur = 1; for (;;) { [pre, cur] = [cur, pre + cur]; yield cur; } } }
Esta es la implementación de Fibonacci usando un generador. Es equivalente a la que hemos visto antes. Observa cómo tenemos un bucle infinito, por lo que siempre estamos generando valores. Recuerda que eso se evalúa de forma perezosa: si solo se piden 10 valores, solo se iterará por ese bucle 10 veces (a pesar de ser realmente un bucle infinito).
DEMO: Creando un generador
Partamos del siguiente código de un iterador para iterar por las variables de un array privado:
const repo = (() => let _data = []; var repo = { add(value) { this._data.push(value); }, [Symbol.iterator]() { let idx = 0; var self = this; return { next() { let current = _data[idx]; idx++; return { value: current, done: idx > _data.length } } } } } return repo; })();
Vamos a generar un generador equivalente al iterador anterior. Lo primero es convertir la función Symbol.iterator (que nos devolvía directamente un iterador) a un generador:
(...) [Symbol.iterator]: function*() {
Ahora no tenemos ninguna necesidad de guardarnos ninguna variable. Nos basta con un for:
(...) [Symbol.iterator]: function*() { for (let idx = 0; idx < _data.length; idx++){ yield _data[idx]; } }
Código completo:
const repo = (() => let _data = []; var repo = { add(value) { this._data.push(value); }, [Symbol.iterator]: function*() { for (let idx = 0; idx < _data.length; idx++){ yield _data[idx]; } } } return repo; })();
Como data es un array, podríamos iterar con un bucle for-of:
const repo = (() => let _data = []; var repo = { add(value) { this._data.push(value); }, [Symbol.iterator]: function*() { for (let d of_data){ yield _d; } } } return repo; })();
Las ventajas de usar un generador respecto a un iterador es que el código queda más claro, nos centramos en hacer lo que queremos hacer: devolver los valores.
Generadores como objetos
Hasta ahora hemos visto cómo un generador nos permite construir un iterador de forma muy sencilla. Pero los generadores tienen otra característica interesante: ellos mismos pueden ser un objeto iterable. Es decir, no es necesario que un generador forme parte de un objeto, sino que el propio generador puede ser el objeto.
Observa el siguiente código:
var fibo = function*() { let pre = 0, cur = 1; for (;;) { [pre, cur] = [cur, pre + cur]; yield cur; } };
Observa como fibo es un generador, pero que no está definido dentro de ningún otro objeto. Así, pues podemos iterar directamente sobre los valores de este generador:
for (let f of fibo()) { if (f > 1000) break; console.log(f) }
OJO: Observa los paréntesis después de fibo. Si no los pones recibirás un error indicando que el objeto no es iterable.
Encadenar generadores
Un generador puede devolver todos los valores de otro generador usando yield*. Eso permite incluir los valores de un generador como parte de los valores generados por otro generador, de forma muy sencilla, sin tener que iterar manualmente sobre el generador interno:
var g1 = function*() { yield 2; yield 4; } var g2 = function*() { yield 0; for (var item of g1()) { yield item; } yield 6; } for (var item of g2()) { console.log(item) }
Este código imprime los valores 0, 2, 4 y 6 por la consola. Observa cómo el generador g2 está iterando manualmente sobre el generador g1 para hacer un yield de todos sus valores. Si se tiene esta necesidad, se puede solucionar más fácilmente con yield*:
var g1 = function*() { yield 2; yield 4; } var g2 = function*() { yield 0; yield* g1(); yield 6; } for (var item of g2()) { console.log(item) }
Este código es equivalente al anterior, pero es mucho más sencillo de leer.
DEMO: Generadores como objetos
var lost = function*() { yield 4; yield 8; yield 15; yield 16; yield 23; yield 42; } for (let item of lost()) { console.log(item); } // -> 4 // -> 8 // -> 15 // -> 16 // -> 23 // -> 42
Un generador puede invocar a otro generador.
var repeaterLost = function*(count) { for (let idx = 0; idx < count; idx++) { // Queremos devolver todos los valores del anterior generador // de una sola vez yield* lost(); } } for (let item of repeaterLost(2)){ console.log(item); } // -> 4 // -> 8 // -> 15 // -> 16 // -> 23 // -> 42 // -> 4 // -> 8 // -> 15 // -> 16 // -> 23 // -> 42
Podemos convertir los un generador en array con spread:
var arrLost = [...lost()]; // -> Array [ 4, 8 , 15, 16, 23, 42 ]
Ejercicios propuestos
Aquí van algunos ejercicios propuestos para repasar los conceptos de este módulo.
1. Generador de rango: Implementa un generador que le pases dos valores (min y max) y que devuelva un iterador que itere sobre este rango de valores.
2. Iterador de letras: Crea un objeto, que tenga un método addWord que permita añadir palabras. Luego este objeto debe permitir iterar y el resultado de esa iteración debe ser las letras iniciales de todas las palabras que el objeto tenga añadidas. Impleméntalo con iteradores y con generadores.
3. Iterador de factores primos: Haz un objeto que tenga una propiedad value y un iterador. La iteración sobre este objeto deberá devolver todos los factores primos de value. Impleméntalo con un iterador, con un generador y con un generador como objeto (le pasas el valor a factorizar como parámetro). Limita el valor máximo de value a 1000.
