Generadores en JavaScript
Las funciones convencionales retornan un único valor o ninguno. En cambio, los generadores pueden producir múltiples valores de manera secuencial, a medida que se soliciten. Son especialmente útiles cuando se trabaja con iterables, facilitando la creación de flujos de datos.
Funciones Generadoras
Para definir un generador, utilizamos una sintaxis particular: function*
, conocida como “función generadora”.
Así es como luce:
function* generarSecuencia() {
yield 1;
yield 2;
return 3;
}
Las funciones generadoras operan de manera distinta a las funciones normales. Cuando se invoca una función generadora, no se ejecuta de inmediato. En su lugar, retorna un objeto especial denominado “objeto generador” que controla la ejecución.
Observa esto:
function* generarSecuencia() {
yield 1;
yield 2;
return 3;
}
let generador = generarSecuencia();
console.log(generador); // [object Generator]
El código de la función aún no se ha ejecutado.
El método principal de un generador es next()
. Al llamarlo, se ejecuta hasta la declaración yield <value>
más cercana. Luego, la ejecución se detiene y el valor value
se devuelve al código externo.
El resultado de next()
es siempre un objeto con dos propiedades:
value
: el valor delyield
.done
:true
si el código de la función ha terminado, de lo contrariofalse
.
Por ejemplo, aquí creamos el generador y obtenemos su primer valor yield
:
function* generarSecuencia() {
yield 1;
yield 2;
return 3;
}
let generador = generarSecuencia();
let uno = generador.next();
console.log(JSON.stringify(uno)); // {value: 1, done: false}
Hemos obtenido solo el primer valor, y la ejecución de la función se ha detenido en la segunda línea.
Llamemos a generador.next()
nuevamente. Se reanuda la ejecución del código y devuelve el siguiente yield
:
let dos = generador.next();
console.log(JSON.stringify(dos)); // {value: 2, done: false}
Si lo llamamos por tercera vez, la ejecución llega a la declaración return
que finaliza la función:
let tres = generador.next();
console.log(JSON.stringify(tres)); // {value: 3, done: true}
El generador ha terminado. Observamos done: true
y procesamos value: 3
como el resultado final.
Nuevas llamadas a generador.next()
ya no tienen sentido. Devuelven el mismo objeto: {done: true}
.
¿function* f(…) o function *f(…)?
Ambas sintaxis son válidas. Sin embargo, la primera sintaxis es generalmente preferida, ya que la estrella *
denota que es una función generadora, describiendo el tipo y no el nombre, por lo que debe seguir a la palabra clave function
.
Generadores son iterables
Como probablemente has adivinado al observar el método next()
, los generadores son iterables.
Podemos recorrer sus valores usando for..of
:
function* generarSecuencia() {
yield 1;
yield 2;
return 3;
}
let generador = generarSecuencia();
for(let valor de generador) {
console.log(valor); // 1, luego 2
}
Parece mucho mejor que llamar a .next().value
, ¿verdad?
Ten en cuenta: el ejemplo anterior muestra 1 y luego 2. ¡No muestra 3! Esto se debe a que la iteración for..of
ignora el último valor cuando done: true
. Entonces, si queremos que todos los resultados se muestren con for..of
, debemos devolverlos con yield
:
function* generarSecuencia() {
yield 1;
yield 2;
yield 3;
}
let generador = generarSecuencia();
for(let valor de generador) {
console.log(valor); // 1, luego 2, luego 3
}
Como los generadores son iterables, podemos utilizar todas las funciones relacionadas, por ejemplo, la sintaxis de propagación ...
:
function* generarSecuencia() {
yield 1;
yield 2;
yield 3;
}
let secuencia = [0, ...generarSecuencia()];
console.log(secuencia); // 0, 1, 2, 3
En el código anterior, ...generarSecuencia()
convierte el objeto generador iterable en un array de elementos.
Usando generadores para iterables
En capítulos anteriores, creamos un objeto iterable range
que devuelve valores desde from
hasta to
.
Recordemos el código:
let rango = {
desde: 1,
hasta: 5,
[Symbol.iterator]() {
return {
actual: this.desde,
último: this.hasta,
next() {
if (this.actual <= this.último) {
return { done: false, value: this.actual++ };
} else {
return { done: true };
}
}
};
}
};
console.log([...rango]); // 1,2,3,4,5
Podemos usar una función generadora para la iteración proporcionándola como Symbol.iterator
.
Este es el mismo rango
, pero mucho más compacto:
let rango = {
desde: 1,
hasta: 5,
*[Symbol.iterator]() {
for(let valor = this.desde; valor <= this.hasta; valor++) {
yield valor;
}
}
};
console.log([...rango]); // 1,2,3,4,5
Esto funciona porque rango[Symbol.iterator]()
ahora retorna un generador, y los métodos del generador son exactamente lo que for..of
espera:
- tiene un método
.next()
- que devuelve valores en la forma
{value: ..., done: true/false}
No es una coincidencia. Los generadores se agregaron a JavaScript con los iteradores en mente, para implementarlos fácilmente.
Generadores pueden producir valores infinitamente
En los ejemplos anteriores, generamos secuencias finitas, pero también podemos crear un generador que produzca valores indefinidamente. Por ejemplo, una secuencia interminable de números pseudoaleatorios.
Esto requeriría un break
(o return
) en for..of
sobre dicho generador, de lo contrario, el bucle se repetiría indefinidamente.
Composición del generador
La composición del generador es una característica que permite “incrustar” generadores unos dentro de otros de manera transparente.
Por ejemplo, tenemos una función que genera una secuencia de números:
function* generarSecuencia(inicio, fin) {
for (let i = inicio; i <= fin; i++) yield i;
}
Ahora, queremos reutilizarlo para generar una secuencia más compleja:
- Primero, dígitos del 0 al 9 (códigos de caracteres 48…57).
- Luego, letras mayúsculas del alfabeto A..Z (códigos de caracteres 65…90).
- Finalmente, letras minúsculas del alfabeto a..z (códigos de caracteres 97…122).
Podemos usar esta secuencia para crear contraseñas seleccionando caracteres de ella. Para generarla, utilizamos la sintaxis especial yield*
para “incrustar” un generador en otro.
El generador compuesto:
function* generarSecuencia(inicio, fin) {
for (let i = inicio; i <= fin; i++) yield i;
}
function* generarCódigosContraseña() {
yield* generarSecuencia(48, 57); // 0..9
yield* generarSecuencia(65, 90); // A..Z
yield* generarSecuencia(97, 122); // a..z
}
let str = '';
for(let código of generarCódigosContraseña()) {
str += String.fromCharCode(código);
}
console.log(str); // 0..9A..Za..z
La directiva yield*
delega la ejecución a otro generador, reenviando sus yield
al exterior. Es como si los valores fueran proporcionados por el generador externo.
El resultado es el mismo que si insertamos el código de los generadores anidados:
function* generarSecuencia(inicio, fin) {
for (let i = inicio; i <= fin; i++) yield i;
}
function* generarAlphaNum() {
for (let i = 48; i <= 57; i++) yield i; // 0..9
for (let i = 65; i <= 90; i++) yield i; // A..Z
for (let i = 97; i <= 122; i++) yield i; // a..z
}
let str = '';
for(let código of generarAlphaNum()) {
str += String.fromCharCode(código);
}
console.log(str); // 0..9A..Za..z
La composición del generador es una forma eficiente de insertar un flujo de un generador en otro.
Generadores “yield” en lugar de “return”
Una de las principales diferencias entre yield
y return
en generadores es que return
no retorna un valor adicional en la secuencia, sino que detiene la ejecución, mientras que yield
retorna un valor y pausa la ejecución, permitiendo la reanudación posterior.
Por ejemplo, en una función generadora:
function* generador() {
yield 1;
yield 2;
return 3;
}
let g = generador();
console.log(g.next()); // {value: 1, done: false}
console.log(g.next()); // {value: 2, done: false}
console.log(g.next()); // {value: 3, done: true}
El return
finaliza el generador y establece done: true
. Sin embargo, los valores proporcionados por return
no se consideran parte de la secuencia para for..of
u otras iteraciones.